Refine Python tools task interface
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 39s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 53s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 1m17s

This commit is contained in:
ajp_anton
2026-08-18 04:21:11 +00:00
parent b256147d28
commit 7e6dea8b24
11 changed files with 311 additions and 95 deletions
+75 -37
View File
@@ -28,51 +28,89 @@ first line of their module docstring. A task must not require stdin or command
line arguments, should write useful output, and must return a non-zero exit line arguments, should write useful output, and must return a non-zero exit
code on failure. code on failure.
## Task inputs ## Task definitions
An optional `task-inputs.json` beside a group's task files declares the fields Each non-hidden directory directly below `/opt/tasks` is a task group. Its
for its web forms and its task order. It is parsed as data only and uses version direct `*.py` files are the exposed tasks. Use an optional
`1`: `task-inputs.json` beside those files to define the group's title, task titles
and descriptions, task order, and web form fields. It is parsed as data only
and currently uses version `1`:
```json ```json
{ {
"version": 1, "version": 1,
"label": "Example Tasks", "title": "Example Tasks",
"tasks": { "tasks": {
"task.py": { "inspect.py": {
"title": "Inspect item",
"description": "Checks one item without changing it.",
"inputs": [ "inputs": [
{"name": "identifier", "label": "Identifier", "type": "text", "required": true}, {"name": "identifier", "label": "Identifier", "type": "text", "required": true},
{"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2}, {"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2},
{"name": "mode", "label": "Mode", "type": "choice", "options": ["standard", "extended"]}, {"name": "mode", "label": "Mode", "type": "choice", "options": ["standard", "extended"]},
{"name": "alerts", "label": "Alerts", "type": "multi_choice", "options": ["email", "webhook"]} {"name": "alerts", "label": "Alerts", "type": "multi_choice", "options": ["email", "webhook"]},
] {"name": "review_csv", "label": "Review CSV", "type": "file", "accept": [".csv"], "maximum_bytes": 10485760}
],
"wait_for_result": true,
"execution": {
"field": "mode",
"dry_run_value": "review",
"dry_run_label": "Review",
"execute_value": "apply",
"execute_label": "Execute"
}
} }
} }
} }
``` ```
Supported field types are `text`, `integer`, `date`, `datetime`, `choice`, `title` is optional on a group and task. A group otherwise uses its directory
`multi_choice`, and `file`. Text may also define a regular-expression name; a task otherwise uses its filename without `.py`, with underscores made
`pattern`; integer fields accept `minimum`, `maximum`, and `step`. Choices may readable. `description` is optional on a task and otherwise comes from the
use strings or objects with `value` and `label`. File fields accept a list of first non-empty line of its module docstring. The order of keys in `tasks`
lowercase filename extensions in `accept` and a byte limit in `maximum_bytes`. controls the listed tasks' display order. Include a task as `{}` when only
An input can define `required` and `default`. ordering is needed. Unlisted direct Python files follow alphabetically.
Set `sensitive` to `true` for a value or upload that must not appear in run
history. The form masks sensitive text, the database records `[redacted]`, and
the original value is available only through a private execution input file.
Tasks listed in the manifest appear in JSON order. A listed task may use `{}`
when it only needs ordering; unlisted scripts appear afterward in alphabetical
order. `label` is optional and overrides the group name displayed in the UI.
Set `wait_for_result` to `true` for a short task whose form should wait for its
final success or failure status. This disables the form and warns before
leaving the page while the task is running; tasks without it keep the normal
queued-run behavior.
For a task with a review-first operation, declare `execution` with the input Each task declaration may contain `title`, `description`, `inputs`,
field and its review and execution values. The control panel generates the `wait_for_result`, `download_artifacts`, and `execution`. Set
mode selector attached to the submit button. A successful review run offers a `wait_for_result` for short tasks where the form should remain blocked until it
button that repeats the same saved inputs using the execution value; it does not reuse a stale filesystem reports success or failure. The control panel disables its controls and warns
snapshot, so the task must still validate its inputs before changing anything. before page unload while such a task runs. `download_artifacts` additionally
starts the first non-JSON artifact download after a successful browser run;
reserve it for files such as archives that the browser should download
automatically.
Every input has `name`, `label`, and `type`; `required` defaults to `false`.
`default` is allowed for every type except `file`.
- `text`: optional `pattern`, a Python-compatible regular expression.
- `integer`: optional `minimum`, `maximum`, and positive `step`.
- `date` and `datetime`: ISO-formatted browser date controls.
- `choice`: `options` is required, as strings or `{ "value", "label" }` objects.
- `multi_choice`: the same required `options`; its `default` is an array.
- `file`: optional lowercase extension list in `accept` and positive byte cap
in `maximum_bytes`.
Set `sensitive` to `true` on text or file inputs that must not appear in run
history. Text is masked, the database records `[redacted]`, and the original
value is available only through a private execution-input file. Uploaded files
are likewise retained privately for the run and then removed.
For review-first tasks, use `execution`. It produces one split button: click
the main part to use its current mode, or use the arrow to select the other
mode. A successful review run can be repeated with its saved inputs in the
execution mode from the run-detail page. The task must still revalidate the
live filesystem before changing anything.
```json
{
"field": "mode",
"dry_run_value": "review",
"dry_run_label": "Review",
"execute_value": "apply",
"execute_label": "Execute"
}
```
The server validates every submitted value before a run is queued. It records The server validates every submitted value before a run is queued. It records
the validated object in the run database and writes it to a per-run file. the validated object in the run database and writes it to a per-run file.
@@ -84,15 +122,15 @@ are stored in a private per-run directory; their absolute paths are provided in
the input JSON and the files are removed when the task finishes, fails, or is the input JSON and the files are removed when the task finishes, fails, or is
cancelled. cancelled.
## Downloads ## Artifacts
Tasks can write downloadable files to the directory named by Tasks can write files to the directory named by
`SERVER_MAINTENANCE_ARTIFACTS`. Set `download_artifacts` to `true` alongside `SERVER_MAINTENANCE_ARTIFACTS`. Run-detail pages show them when the task
`wait_for_result` to start the first artifact download after a successful web finishes. JSON artifacts offer an **Open** link that renders the JSON in a new
run. Run-detail pages also show available downloads. The server deletes an tab and leaves the artifact available; other artifacts offer a download link.
artifact after its HTTP transfer ends; any undownloaded artifact is removed The server deletes an artifact after its download transfer ends; any artifact
after 24 hours. A browser cannot confirm that a downloaded file was retained not downloaded is removed after 24 hours. A browser cannot confirm that a
on the client device. downloaded file was retained on the client device.
## Background tasks ## Background tasks
+33 -14
View File
@@ -117,7 +117,12 @@ class TaskCatalog:
group_path, group_path,
{ {
path.name for path in group_path.iterdir() path.name for path in group_path.iterdir()
if path.is_file() and not path.is_symlink() and path.suffix == ".py" and not path.name.startswith((".", "_")) if (
path.is_file()
and not path.is_symlink()
and path.suffix == ".py"
and not path.name.startswith((".", "_"))
)
}, },
) )
tasks = tuple(self._tasks_in_group(group_path, manifest.declarations)) tasks = tuple(self._tasks_in_group(group_path, manifest.declarations))
@@ -125,7 +130,7 @@ class TaskCatalog:
Group( Group(
id=group_path.name, id=group_path.name,
path=group_path, path=group_path,
display_name=manifest.label or display_name(group_path.name), display_name=manifest.title or display_name(group_path.name),
tasks=tasks, tasks=tasks,
) )
) )
@@ -191,8 +196,12 @@ class TaskCatalog:
group_id=group_path.name, group_id=group_path.name,
filename=path.name, filename=path.name,
path=path, path=path,
display_name=display_name(path.stem), display_name=declaration.title if declaration and declaration.title else display_name(path.stem),
description=read_description(path), description=(
declaration.description
if declaration and declaration.description is not None
else read_description(path)
),
inputs=( inputs=(
(*declaration.inputs, declaration.execution.field) (*declaration.inputs, declaration.execution.field)
if declaration and declaration.execution if declaration and declaration.execution
@@ -287,7 +296,7 @@ def read_description(path: Path) -> str | None:
@dataclass(frozen=True) @dataclass(frozen=True)
class GroupManifest: class GroupManifest:
label: str | None title: str | None
declarations: dict[str, "TaskDeclaration"] declarations: dict[str, "TaskDeclaration"]
@@ -299,22 +308,22 @@ def load_input_manifest(
manifest_path = group_path / "task-inputs.json" manifest_path = group_path / "task-inputs.json"
if not manifest_path.exists(): if not manifest_path.exists():
return GroupManifest(label=None, declarations={}) return GroupManifest(title=None, declarations={})
if manifest_path.is_symlink(): if manifest_path.is_symlink():
raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}") raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}")
try: try:
data = json.loads(manifest_path.read_text(encoding="utf-8")) data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error
if not isinstance(data, dict) or set(data) - {"version", "label", "tasks"}: if not isinstance(data, dict) or set(data) - {"version", "title", "tasks"}:
raise ValueError( raise ValueError(
f"Input manifest {manifest_path} has an invalid structure." f"Input manifest {manifest_path} has an invalid structure."
) )
if data.get("version") != 1 or not isinstance(data.get("tasks"), dict): if data.get("version") != 1 or not isinstance(data.get("tasks"), dict):
raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.") raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.")
label = data.get("label") title = data.get("title")
if label is not None and (not isinstance(label, str) or not label.strip()): if title is not None and (not isinstance(title, str) or not title.strip()):
raise ValueError(f"Input manifest {manifest_path} label must be non-empty text.") raise ValueError(f"Input manifest {manifest_path} title must be non-empty text.")
unknown_tasks = set(data["tasks"]) - task_filenames unknown_tasks = set(data["tasks"]) - task_filenames
if unknown_tasks: if unknown_tasks:
@@ -322,7 +331,7 @@ def load_input_manifest(
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.") raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
return GroupManifest( return GroupManifest(
label=label, title=title,
declarations={ declarations={
filename: parse_task_declaration(manifest_path, filename, declaration) filename: parse_task_declaration(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items() for filename, declaration in data["tasks"].items()
@@ -332,6 +341,8 @@ def load_input_manifest(
@dataclass(frozen=True) @dataclass(frozen=True)
class TaskDeclaration: class TaskDeclaration:
title: str | None
description: str | None
inputs: tuple[InputField, ...] inputs: tuple[InputField, ...]
wait_for_result: bool wait_for_result: bool
download_artifacts: bool download_artifacts: bool
@@ -345,13 +356,19 @@ def parse_task_declaration(
) -> TaskDeclaration: ) -> TaskDeclaration:
if ( if (
not isinstance(declaration, dict) not isinstance(declaration, dict)
or set(declaration) - {"inputs", "wait_for_result", "download_artifacts", "execution"} or set(declaration) - {"title", "description", "inputs", "wait_for_result", "download_artifacts", "execution"}
): ):
raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.") raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.")
raw_inputs = declaration.get("inputs", []) raw_inputs = declaration.get("inputs", [])
if not isinstance(raw_inputs, list): if not isinstance(raw_inputs, list):
raise ValueError(f"{manifest_path} task {filename} inputs must be a list.") raise ValueError(f"{manifest_path} task {filename} inputs must be a list.")
fields = tuple(parse_input_field(manifest_path, filename, raw) for raw in raw_inputs) fields = tuple(parse_input_field(manifest_path, filename, raw) for raw in raw_inputs)
title = declaration.get("title")
if title is not None and (not isinstance(title, str) or not title.strip()):
raise ValueError(f"{manifest_path} task {filename} title must be non-empty text.")
description = declaration.get("description")
if description is not None and (not isinstance(description, str) or not description.strip()):
raise ValueError(f"{manifest_path} task {filename} description must be non-empty text.")
names = [field.name for field in fields] names = [field.name for field in fields]
if len(names) != len(set(names)): if len(names) != len(set(names)):
raise ValueError(f"{manifest_path} task {filename} has duplicate input names.") raise ValueError(f"{manifest_path} task {filename} has duplicate input names.")
@@ -365,6 +382,8 @@ def parse_task_declaration(
if download_artifacts and not wait_for_result: if download_artifacts and not wait_for_result:
raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.") raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.")
return TaskDeclaration( return TaskDeclaration(
title=title,
description=description,
inputs=fields, inputs=fields,
wait_for_result=wait_for_result, wait_for_result=wait_for_result,
download_artifacts=download_artifacts, download_artifacts=download_artifacts,
@@ -381,7 +400,7 @@ def parse_execution_mode(
if raw is None: if raw is None:
return None return None
required_keys = { required_keys = {
"field", "label", "dry_run_value", "dry_run_label", "execute_value", "execute_label", "field", "dry_run_value", "dry_run_label", "execute_value", "execute_label",
} }
if not isinstance(raw, dict) or set(raw) != required_keys: if not isinstance(raw, dict) or set(raw) != required_keys:
raise ValueError(f"{manifest_path} task {filename} has an invalid execution declaration.") raise ValueError(f"{manifest_path} task {filename} has an invalid execution declaration.")
@@ -395,7 +414,7 @@ def parse_execution_mode(
raise ValueError(f"{manifest_path} task {filename} execution values must differ.") raise ValueError(f"{manifest_path} task {filename} execution values must differ.")
field = InputField( field = InputField(
name=field_name, name=field_name,
label=values["label"], label="Run mode",
type="choice", type="choice",
required=True, required=True,
default=values["dry_run_value"], default=values["dry_run_value"],
+17 -6
View File
@@ -210,15 +210,26 @@ def create_app(settings: Settings | None = None) -> Flask:
run = store.get(run_id) run = store.get(run_id)
if run is None: if run is None:
abort(404) abort(404)
return { artifacts = []
"artifacts": [ for name in manager.artifacts(run.id):
{ artifact = {
"name": name, "name": name,
"url": url_for("download_artifact", run_id=run.id, filename=name), "url": url_for("download_artifact", run_id=run.id, filename=name),
} }
for name in manager.artifacts(run.id) if name.casefold().endswith(".json"):
] artifact["view_url"] = url_for("view_artifact", run_id=run.id, filename=name)
} artifacts.append(artifact)
return {"artifacts": artifacts}
@app.get("/runs/<int:run_id>/artifacts/<filename>/view")
def view_artifact(run_id: int, filename: str):
run = store.get(run_id)
if run is None or run.status != "succeeded" or not filename.casefold().endswith(".json"):
abort(404)
artifact = manager.artifact_path(run.id, filename)
if artifact is None:
abort(404)
return send_file(artifact, mimetype="application/json", as_attachment=False, conditional=False)
@app.get("/runs/<int:run_id>/artifacts/<filename>") @app.get("/runs/<int:run_id>/artifacts/<filename>")
def download_artifact(run_id: int, filename: str): def download_artifact(run_id: int, filename: str):
+31 -11
View File
@@ -37,8 +37,15 @@ const refreshArtifacts = async (container) => {
for (const artifact of artifacts) { for (const artifact of artifacts) {
const item = document.createElement("li"); const item = document.createElement("li");
const link = document.createElement("a"); const link = document.createElement("a");
if (artifact.view_url) {
link.href = artifact.view_url;
link.target = "_blank";
link.rel = "noopener";
link.textContent = `Open ${artifact.name}`;
} else {
link.href = artifact.url; link.href = artifact.url;
link.textContent = artifact.name; link.textContent = `Download ${artifact.name}`;
}
item.append(link); item.append(link);
list.append(item); list.append(item);
} }
@@ -51,15 +58,24 @@ for (const container of document.querySelectorAll("#run-artifacts")) {
window.setInterval(() => refreshArtifacts(container), 2000); window.setInterval(() => refreshArtifacts(container), 2000);
} }
for (const control of document.querySelectorAll("select[data-execution-mode]")) { for (const choice of document.querySelectorAll("button[data-execution-choice]")) {
const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label"); choice.addEventListener("click", () => {
const updateButtonLabel = () => { const control = choice.closest(".split-button");
if (buttonLabel && control.selectedOptions[0]) { const value = control?.querySelector("input[data-execution-value]");
buttonLabel.textContent = control.selectedOptions[0].textContent; const label = control?.querySelector(".button-label");
if (!control || !value || !label) {
return;
} }
}; value.value = choice.dataset.value || "";
control.addEventListener("change", updateButtonLabel); label.textContent = choice.dataset.label || "Run";
updateButtonLabel(); control.querySelectorAll("button[data-execution-choice]").forEach((option) => {
option.setAttribute("aria-pressed", String(option === choice));
});
const menu = control.querySelector("details");
if (menu) {
menu.open = false;
}
});
} }
for (const form of document.querySelectorAll("form[data-wait-for-result]")) { for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
@@ -78,6 +94,9 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
controls.forEach((control) => { controls.forEach((control) => {
control.disabled = value; control.disabled = value;
}); });
form.querySelectorAll("details.split-menu").forEach((menu) => {
menu.open = false;
});
if (value) { if (value) {
window.addEventListener("beforeunload", warnBeforeLeaving); window.addEventListener("beforeunload", warnBeforeLeaving);
} else { } else {
@@ -103,11 +122,12 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
return; return;
} }
const artifacts = (await response.json()).artifacts; const artifacts = (await response.json()).artifacts;
if (!artifacts.length) { const artifact = artifacts.find((item) => !item.view_url);
if (!artifact) {
return; return;
} }
const link = document.createElement("a"); const link = document.createElement("a");
link.href = artifacts[0].url; link.href = artifact.url;
link.download = ""; link.download = "";
document.body.append(link); document.body.append(link);
link.click(); link.click();
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#17372d"/>
<path d="M15 20 29 32 15 44" fill="none" stroke="#b7f0cd" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"/>
<path d="M35 43h14" fill="none" stroke="#b7f0cd" stroke-linecap="round" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

+61 -8
View File
@@ -48,7 +48,7 @@ a {
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
padding: .8rem 0; padding: .8rem 0;
} }
@@ -61,19 +61,26 @@ a {
align-items: end; align-items: end;
display: grid; display: grid;
gap: .75rem; gap: .75rem;
grid-template-columns: minmax(20rem, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-width: 0; min-width: 0;
} }
.task-options { .task-options {
display: grid; display: grid;
gap: .75rem; gap: .75rem;
justify-items: start;
min-width: 0; min-width: 0;
} }
.task-input { margin: 0; } .task-input { margin: 0; }
.task-input label, .task-input label,
.task-input fieldset {
display: block;
max-width: 100%;
width: fit-content;
}
.task-input fieldset label { .task-input fieldset label {
display: block; display: block;
} }
@@ -85,9 +92,16 @@ a {
max-width: 100%; max-width: 100%;
} }
.task-input input:not([type="checkbox"]), .task-input input[type="date"],
.task-input input[type="datetime-local"],
.task-input input[type="number"],
.task-input select { .task-input select {
width: 100%; field-sizing: content;
}
.task-input input[type="text"],
.task-input input[type="password"] {
width: min(15rem, 100%);
} }
.task-input fieldset { .task-input fieldset {
@@ -118,24 +132,63 @@ button {
.split-button { .split-button {
display: inline-flex; display: inline-flex;
position: relative;
} }
.split-button select,
.split-button button, .split-button button,
.task-action > button { .task-action > button {
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
} }
.split-button select { .split-primary {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
border-right: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.split-button button { .split-menu summary {
align-items: center;
border: 1px solid currentColor;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-top-left-radius: 0; border-top-left-radius: 0;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 100%;
justify-content: center;
list-style: none;
min-width: 2.25rem;
}
.split-menu summary::-webkit-details-marker {
display: none;
}
.split-menu-options {
background: Canvas;
border: 1px solid currentColor;
display: grid;
gap: .2rem;
min-width: max-content;
padding: .25rem;
position: absolute;
right: 0;
top: calc(100% + .25rem);
z-index: 1;
}
.split-menu:not([open]) .split-menu-options {
display: none;
}
.split-menu-options button {
text-align: left;
width: 100%;
}
.is-waiting .split-menu {
opacity: .6;
pointer-events: none;
} }
.visually-hidden { .visually-hidden {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Server Maintenance{% endblock %}</title> <title>{% block title %}Server Maintenance{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
+14 -9
View File
@@ -94,17 +94,22 @@
{% if task.execution %} {% if task.execution %}
<div class="split-button"> <div class="split-button">
{% set execution = task.execution.field %} {% set execution = task.execution.field %}
{% set selected = values.get(execution.name, execution.default) %} {% set submitted = values.get(execution.name) %}
<label class="visually-hidden" for="{{ task.id|replace('/', '-') }}-{{ execution.name }}">Run mode</label> {% set selected = submitted[0] if submitted else execution.default %}
<select id="{{ task.id|replace('/', '-') }}-{{ execution.name }}" name="{{ execution.name }}" data-execution-mode> {% set selected_option = execution.options|selectattr("value", "equalto", selected)|first %}
{% for option in execution.options %} <input type="hidden" name="{{ execution.name }}" value="{{ selected }}" data-execution-value>
<option value="{{ option.value }}" {% if option.value == selected %}selected{% endif %}>{{ option.label }}</option> <button class="split-primary" type="submit">
{% endfor %} <span class="button-label">{{ selected_option.label if selected_option else execution.options[0].label }}</span>
</select>
<button type="submit">
<span class="button-label">{{ task.execution.field.options[0].label }}</span>
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %} {% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
</button> </button>
<details class="split-menu">
<summary aria-label="Choose run mode" title="Choose run mode">&#9662;</summary>
<div class="split-menu-options">
{% for option in execution.options %}
<button type="button" data-execution-choice data-value="{{ option.value }}" data-label="{{ option.label }}"{% if option.value == selected %} aria-pressed="true"{% endif %}>{{ option.label }}</button>
{% endfor %}
</div>
</details>
</div> </div>
{% else %} {% else %}
<button type="submit"> <button type="submit">
+1 -1
View File
@@ -30,7 +30,7 @@
</ul> </ul>
{% endif %} {% endif %}
<section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden> <section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden>
<h2>Downloads</h2> <h2>Artifacts</h2>
<ul></ul> <ul></ul>
</section> </section>
<h2>Output</h2> <h2>Output</h2>
+26 -3
View File
@@ -119,12 +119,12 @@ def test_manifest_orders_listed_tasks_and_allows_empty_declarations(tmp_path: Pa
assert [task.filename for task in tasks.tasks] == ["review.py", "apply.py", "status.py"] assert [task.filename for task in tasks.tasks] == ["review.py", "apply.py", "status.py"]
def test_manifest_can_override_the_group_display_name(tmp_path: Path) -> None: def test_manifest_can_override_the_group_title(tmp_path: Path) -> None:
root = tmp_path / "tasks" root = tmp_path / "tasks"
group = root / "service" group = root / "service"
write_task(root, "service", "status.py", '"""Shows status."""\n') write_task(root, "service", "status.py", '"""Shows status."""\n')
(group / "task-inputs.json").write_text( (group / "task-inputs.json").write_text(
'''{"version":1,"label":"Service UI","tasks":{}}''', '''{"version":1,"title":"Service UI","tasks":{}}''',
encoding="utf-8", encoding="utf-8",
) )
@@ -144,6 +144,30 @@ def test_display_names_keep_common_acronyms_capitalized(tmp_path: Path) -> None:
assert task.display_name == "Replace API URLs" assert task.display_name == "Replace API URLs"
def test_manifest_can_override_task_title_and_description(tmp_path: Path) -> None:
root = tmp_path / "tasks"
group = root / "examples"
write_task(root, "examples", "inspect.py", '"""Inspects the default thing."""\n')
(group / "task-inputs.json").write_text(
'''{
"version": 1,
"tasks": {
"inspect.py": {
"title": "Inspect saved item",
"description": "Checks one saved item without changing it."
}
}
}''',
encoding="utf-8",
)
task = TaskCatalog(root).task("examples/inspect.py")
assert task is not None
assert task.display_name == "Inspect saved item"
assert task.description == "Checks one saved item without changing it."
def test_rejects_manifest_for_unknown_task(tmp_path: Path) -> None: def test_rejects_manifest_for_unknown_task(tmp_path: Path) -> None:
root = tmp_path / "tasks" root = tmp_path / "tasks"
group = root / "examples" group = root / "examples"
@@ -219,7 +243,6 @@ def test_execution_declaration_generates_an_action_choice(tmp_path: Path) -> Non
"migrate.py": { "migrate.py": {
"execution": { "execution": {
"field": "mode", "field": "mode",
"label": "Action",
"dry_run_value": "report", "dry_run_value": "report",
"dry_run_label": "Report only", "dry_run_label": "Report only",
"execute_value": "apply", "execute_value": "apply",
+43 -2
View File
@@ -213,7 +213,6 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
"migrate.py": { "migrate.py": {
"execution": { "execution": {
"field": "mode", "field": "mode",
"label": "Action",
"dry_run_value": "report", "dry_run_value": "report",
"dry_run_label": "Report only", "dry_run_label": "Report only",
"execute_value": "apply", "execute_value": "apply",
@@ -230,7 +229,8 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
page = client.get("/") page = client.get("/")
assert b"Report only" in page.data assert b"Report only" in page.data
assert b"Apply changes" in page.data assert b"Apply changes" in page.data
assert b"data-execution-mode" in page.data assert b"data-execution-value" in page.data
assert b"data-execution-choice" in page.data
assert b'class="split-button"' in page.data assert b'class="split-button"' in page.data
response = client.post("/tasks/examples/migrate.py/run") response = client.post("/tasks/examples/migrate.py/run")
run_id = int(response.headers["Location"].rsplit("/", 1)[1]) run_id = int(response.headers["Location"].rsplit("/", 1)[1])
@@ -294,6 +294,47 @@ print("archive created")
assert app.extensions["run_manager"].artifact_path(run_id, "review.zip") is None assert app.extensions["run_manager"].artifact_path(run_id, "review.zip") is None
def test_json_artifact_can_be_opened_without_deleting_it(settings) -> None:
write_task(
settings.task_root,
"exports",
"report.py",
'''"""Creates a report."""
import os
from pathlib import Path
artifact = Path(os.environ["SERVER_MAINTENANCE_ARTIFACTS"]) / "report.json"
artifact.write_text('{"status":"ok"}', encoding="utf-8")
''',
)
app = create_app(settings)
client = app.test_client()
response = client.post("/tasks/exports/report.py/run")
run_id = int(response.headers["Location"].rsplit("/", 1)[1])
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
run = app.extensions["run_store"].get(run_id)
assert run is not None
if run.status == "succeeded":
break
time.sleep(0.01)
else:
raise AssertionError("JSON artifact task did not finish")
listed = client.get(f"/runs/{run_id}/artifacts").get_json()
artifact = listed["artifacts"][0]
assert artifact == {
"name": "report.json",
"url": f"/runs/{run_id}/artifacts/report.json",
"view_url": f"/runs/{run_id}/artifacts/report.json/view",
}
opened = client.get(artifact["view_url"])
assert opened.mimetype == "application/json"
assert opened.get_json() == {"status": "ok"}
assert app.extensions["run_manager"].artifact_path(run_id, "report.json") is not None
def test_file_input_rejects_the_wrong_extension(settings) -> None: def test_file_input_rejects_the_wrong_extension(settings) -> None:
task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n') task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n')
(task.parent / "task-inputs.json").write_text( (task.parent / "task-inputs.json").write_text(