Expand Python maintenance tasks
This commit is contained in:
@@ -61,6 +61,7 @@ class InputField:
|
|||||||
options: tuple[InputOption, ...]
|
options: tuple[InputOption, ...]
|
||||||
accept: tuple[str, ...]
|
accept: tuple[str, ...]
|
||||||
maximum_bytes: int | None
|
maximum_bytes: int | None
|
||||||
|
sensitive: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -325,7 +326,7 @@ def parse_input_field(manifest_path: Path, filename: str, raw: Any) -> InputFiel
|
|||||||
raise ValueError(f"{manifest_path} task {filename} has a non-object input field.")
|
raise ValueError(f"{manifest_path} task {filename} has a non-object input field.")
|
||||||
allowed = {
|
allowed = {
|
||||||
"name", "label", "type", "required", "default", "minimum", "maximum",
|
"name", "label", "type", "required", "default", "minimum", "maximum",
|
||||||
"step", "pattern", "options", "accept", "maximum_bytes",
|
"step", "pattern", "options", "accept", "maximum_bytes", "sensitive",
|
||||||
}
|
}
|
||||||
unknown = set(raw) - allowed
|
unknown = set(raw) - allowed
|
||||||
if unknown:
|
if unknown:
|
||||||
@@ -344,6 +345,9 @@ def parse_input_field(manifest_path: Path, filename: str, raw: Any) -> InputFiel
|
|||||||
required = raw.get("required", False)
|
required = raw.get("required", False)
|
||||||
if not isinstance(required, bool):
|
if not isinstance(required, bool):
|
||||||
raise ValueError(f"{manifest_path} task {filename} input {name} required must be true or false.")
|
raise ValueError(f"{manifest_path} task {filename} input {name} required must be true or false.")
|
||||||
|
sensitive = raw.get("sensitive", False)
|
||||||
|
if not isinstance(sensitive, bool):
|
||||||
|
raise ValueError(f"{manifest_path} task {filename} input {name} sensitive must be true or false.")
|
||||||
|
|
||||||
numeric_keys = ("minimum", "maximum", "step")
|
numeric_keys = ("minimum", "maximum", "step")
|
||||||
numeric_values = {key: raw.get(key) for key in numeric_keys}
|
numeric_values = {key: raw.get(key) for key in numeric_keys}
|
||||||
@@ -407,6 +411,7 @@ def parse_input_field(manifest_path: Path, filename: str, raw: Any) -> InputFiel
|
|||||||
options=options,
|
options=options,
|
||||||
accept=accept,
|
accept=accept,
|
||||||
maximum_bytes=maximum_bytes,
|
maximum_bytes=maximum_bytes,
|
||||||
|
sensitive=sensitive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,24 +34,30 @@ class RunManager:
|
|||||||
input_data: dict[str, str | int | list[str]] | None = None,
|
input_data: dict[str, str | int | list[str]] | None = None,
|
||||||
uploads: Mapping[str, PendingUpload] | None = None,
|
uploads: Mapping[str, PendingUpload] | None = None,
|
||||||
) -> tuple[Run, bool]:
|
) -> tuple[Run, bool]:
|
||||||
|
sensitive_names = {field.name for field in task.inputs if field.sensitive}
|
||||||
|
stored_input = self._redact_input(input_data or {}, sensitive_names)
|
||||||
run, created = self.store.enqueue(
|
run, created = self.store.enqueue(
|
||||||
target_kind="task",
|
target_kind="task",
|
||||||
target_path=task.id,
|
target_path=task.id,
|
||||||
queue_group=task.group_id,
|
queue_group=task.group_id,
|
||||||
input_data=input_data,
|
input_data=stored_input,
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
try:
|
try:
|
||||||
uploaded_inputs = self._save_uploads(run.id, uploads or {})
|
uploaded_inputs = self._save_uploads(run.id, uploads or {})
|
||||||
|
execution_input = {**(input_data or {}), **uploaded_inputs}
|
||||||
|
stored_input = self._redact_input(execution_input, sensitive_names)
|
||||||
|
run = self.store.set_input(run.id, stored_input)
|
||||||
|
if sensitive_names:
|
||||||
|
self._write_execution_input(run.id, execution_input)
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
self._delete_uploads(run.id)
|
self._delete_uploads(run.id)
|
||||||
|
self._delete_execution_input(run.id)
|
||||||
return self.store.finish(
|
return self.store.finish(
|
||||||
run.id,
|
run.id,
|
||||||
status="failed",
|
status="failed",
|
||||||
message=f"Could not save uploaded file: {error}",
|
message=f"Could not save uploaded file: {error}",
|
||||||
), True
|
), True
|
||||||
if uploaded_inputs:
|
|
||||||
run = self.store.set_input(run.id, {**(input_data or {}), **uploaded_inputs})
|
|
||||||
self._wake_worker(task.group_id)
|
self._wake_worker(task.group_id)
|
||||||
return run, created
|
return run, created
|
||||||
|
|
||||||
@@ -77,6 +83,7 @@ class RunManager:
|
|||||||
completed = self.store.get(run.id)
|
completed = self.store.get(run.id)
|
||||||
if completed is not None and completed.status == "cancelled":
|
if completed is not None and completed.status == "cancelled":
|
||||||
self._delete_uploads(run.id)
|
self._delete_uploads(run.id)
|
||||||
|
self._delete_execution_input(run.id)
|
||||||
return completed
|
return completed
|
||||||
|
|
||||||
def artifacts(self, run_id: int) -> list[str]:
|
def artifacts(self, run_id: int) -> list[str]:
|
||||||
@@ -128,6 +135,13 @@ class RunManager:
|
|||||||
continue
|
continue
|
||||||
if expired:
|
if expired:
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
for path in runs_root.glob("*.execution-input.json"):
|
||||||
|
run_id_text = path.name.removesuffix(".execution-input.json")
|
||||||
|
if not run_id_text.isdigit():
|
||||||
|
continue
|
||||||
|
run = self.store.get(int(run_id_text))
|
||||||
|
if run is None or run.status not in {"queued", "running"}:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
def run_background_task(self, task: BackgroundTask) -> None:
|
def run_background_task(self, task: BackgroundTask) -> None:
|
||||||
"""Run a declared internal task without creating high-volume run history."""
|
"""Run a declared internal task without creating high-volume run history."""
|
||||||
@@ -239,12 +253,14 @@ class RunManager:
|
|||||||
|
|
||||||
log_path = self.settings.state_root / "runs" / f"{run.id}.log"
|
log_path = self.settings.state_root / "runs" / f"{run.id}.log"
|
||||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
input_path = self.settings.state_root / "runs" / f"{run.id}.input.json"
|
input_path = self._execution_input_path(run.id)
|
||||||
artifact_root = self._artifact_root(run.id)
|
artifact_root = self._artifact_root(run.id)
|
||||||
artifact_root.mkdir(parents=True, exist_ok=True)
|
artifact_root.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
input_path.write_text(run.input_json + "\n", encoding="utf-8")
|
if not input_path.exists():
|
||||||
input_path.chmod(0o600)
|
input_path = self.settings.state_root / "runs" / f"{run.id}.input.json"
|
||||||
|
input_path.write_text(run.input_json + "\n", encoding="utf-8")
|
||||||
|
input_path.chmod(0o600)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
self._delete_uploads(run.id)
|
self._delete_uploads(run.id)
|
||||||
shutil.rmtree(artifact_root, ignore_errors=True)
|
shutil.rmtree(artifact_root, ignore_errors=True)
|
||||||
@@ -284,6 +300,7 @@ class RunManager:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._processes.pop(run.id, None)
|
self._processes.pop(run.id, None)
|
||||||
self._delete_uploads(run.id)
|
self._delete_uploads(run.id)
|
||||||
|
self._delete_execution_input(run.id)
|
||||||
|
|
||||||
if self.store.is_cancel_requested(run.id) or (
|
if self.store.is_cancel_requested(run.id) or (
|
||||||
parent_run_id is not None and self.store.is_cancel_requested(parent_run_id)
|
parent_run_id is not None and self.store.is_cancel_requested(parent_run_id)
|
||||||
@@ -330,6 +347,32 @@ class RunManager:
|
|||||||
def _artifact_root(self, run_id: int) -> Path:
|
def _artifact_root(self, run_id: int) -> Path:
|
||||||
return self.settings.state_root / "runs" / f"{run_id}.artifacts"
|
return self.settings.state_root / "runs" / f"{run_id}.artifacts"
|
||||||
|
|
||||||
|
def _execution_input_path(self, run_id: int) -> Path:
|
||||||
|
return self.settings.state_root / "runs" / f"{run_id}.execution-input.json"
|
||||||
|
|
||||||
|
def _write_execution_input(
|
||||||
|
self,
|
||||||
|
run_id: int,
|
||||||
|
input_data: Mapping[str, str | int | list[str]],
|
||||||
|
) -> None:
|
||||||
|
path = self._execution_input_path(run_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(input_data, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
def _delete_execution_input(self, run_id: int) -> None:
|
||||||
|
self._execution_input_path(run_id).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact_input(
|
||||||
|
input_data: Mapping[str, str | int | list[str]],
|
||||||
|
sensitive_names: set[str],
|
||||||
|
) -> dict[str, str | int | list[str]]:
|
||||||
|
return {
|
||||||
|
name: "[redacted]" if name in sensitive_names else value
|
||||||
|
for name, value in input_data.items()
|
||||||
|
}
|
||||||
|
|
||||||
def _save_uploads(
|
def _save_uploads(
|
||||||
self,
|
self,
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ def create_app(settings: Settings | None = None) -> Flask:
|
|||||||
if request.accept_mimetypes.best == "application/json":
|
if request.accept_mimetypes.best == "application/json":
|
||||||
return {"errors": errors}, 400
|
return {"errors": errors}, 400
|
||||||
return render_index(
|
return render_index(
|
||||||
form_values={task.id: submitted_values},
|
form_values={
|
||||||
|
task.id: {
|
||||||
|
field.name: [] if field.sensitive else submitted_values[field.name]
|
||||||
|
for field in task.inputs
|
||||||
|
}
|
||||||
|
},
|
||||||
input_errors={task.id: errors},
|
input_errors={task.id: errors},
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,12 +68,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set html_type = "number" if field.type == "integer" else "datetime-local" if field.type == "datetime" else field.type %}
|
{% set html_type = "password" if field.sensitive else "number" if field.type == "integer" else "datetime-local" if field.type == "datetime" else field.type %}
|
||||||
<label>{{ field.label }}{% if field.required %} (required){% endif %}
|
<label>{{ field.label }}{% if field.required %} (required){% endif %}
|
||||||
<input
|
<input
|
||||||
type="{{ html_type }}"
|
type="{{ html_type }}"
|
||||||
name="{{ field.name }}"
|
name="{{ field.name }}"
|
||||||
value="{{ values.get(field.name, field.default if field.default is not none else "") }}"
|
value="{{ "" if field.sensitive else values.get(field.name, field.default if field.default is not none else "") }}"
|
||||||
|
{% if field.sensitive %}autocomplete="off"{% endif %}
|
||||||
{% if field.required %}required{% endif %}
|
{% if field.required %}required{% endif %}
|
||||||
{% if field.minimum is not none %}min="{{ field.minimum }}"{% endif %}
|
{% if field.minimum is not none %}min="{{ field.minimum }}"{% endif %}
|
||||||
{% if field.maximum is not none %}max="{{ field.maximum }}"{% endif %}
|
{% if field.maximum is not none %}max="{{ field.maximum }}"{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user