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
code on failure.
## Task inputs
## Task definitions
An optional `task-inputs.json` beside a group's task files declares the fields
for its web forms and its task order. It is parsed as data only and uses version
`1`:
Each non-hidden directory directly below `/opt/tasks` is a task group. Its
direct `*.py` files are the exposed tasks. Use an optional
`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
{
"version": 1,
"label": "Example Tasks",
"title": "Example Tasks",
"tasks": {
"task.py": {
"inspect.py": {
"title": "Inspect item",
"description": "Checks one item without changing it.",
"inputs": [
{"name": "identifier", "label": "Identifier", "type": "text", "required": true},
{"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2},
{"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`,
`multi_choice`, and `file`. Text may also define a regular-expression
`pattern`; integer fields accept `minimum`, `maximum`, and `step`. Choices may
use strings or objects with `value` and `label`. File fields accept a list of
lowercase filename extensions in `accept` and a byte limit in `maximum_bytes`.
An input can define `required` and `default`.
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.
`title` is optional on a group and task. A group otherwise uses its directory
name; a task otherwise uses its filename without `.py`, with underscores made
readable. `description` is optional on a task and otherwise comes from the
first non-empty line of its module docstring. The order of keys in `tasks`
controls the listed tasks' display order. Include a task as `{}` when only
ordering is needed. Unlisted direct Python files follow alphabetically.
For a task with a review-first operation, declare `execution` with the input
field and its review and execution values. The control panel generates the
mode selector attached to the submit button. A successful review run offers a
button that repeats the same saved inputs using the execution value; it does not reuse a stale filesystem
snapshot, so the task must still validate its inputs before changing anything.
Each task declaration may contain `title`, `description`, `inputs`,
`wait_for_result`, `download_artifacts`, and `execution`. Set
`wait_for_result` for short tasks where the form should remain blocked until it
reports success or failure. The control panel disables its controls and warns
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 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
cancelled.
## Downloads
## Artifacts
Tasks can write downloadable files to the directory named by
`SERVER_MAINTENANCE_ARTIFACTS`. Set `download_artifacts` to `true` alongside
`wait_for_result` to start the first artifact download after a successful web
run. Run-detail pages also show available downloads. The server deletes an
artifact after its HTTP transfer ends; any undownloaded artifact is removed
after 24 hours. A browser cannot confirm that a downloaded file was retained
on the client device.
Tasks can write files to the directory named by
`SERVER_MAINTENANCE_ARTIFACTS`. Run-detail pages show them when the task
finishes. JSON artifacts offer an **Open** link that renders the JSON in a new
tab and leaves the artifact available; other artifacts offer a download link.
The server deletes an artifact after its download transfer ends; any artifact
not downloaded is removed after 24 hours. A browser cannot confirm that a
downloaded file was retained on the client device.
## Background tasks
+33 -14
View File
@@ -117,7 +117,12 @@ class TaskCatalog:
group_path,
{
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))
@@ -125,7 +130,7 @@ class TaskCatalog:
Group(
id=group_path.name,
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,
)
)
@@ -191,8 +196,12 @@ class TaskCatalog:
group_id=group_path.name,
filename=path.name,
path=path,
display_name=display_name(path.stem),
description=read_description(path),
display_name=declaration.title if declaration and declaration.title else display_name(path.stem),
description=(
declaration.description
if declaration and declaration.description is not None
else read_description(path)
),
inputs=(
(*declaration.inputs, declaration.execution.field)
if declaration and declaration.execution
@@ -287,7 +296,7 @@ def read_description(path: Path) -> str | None:
@dataclass(frozen=True)
class GroupManifest:
label: str | None
title: str | None
declarations: dict[str, "TaskDeclaration"]
@@ -299,22 +308,22 @@ def load_input_manifest(
manifest_path = group_path / "task-inputs.json"
if not manifest_path.exists():
return GroupManifest(label=None, declarations={})
return GroupManifest(title=None, declarations={})
if manifest_path.is_symlink():
raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}")
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as 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(
f"Input manifest {manifest_path} has an invalid structure."
)
if data.get("version") != 1 or not isinstance(data.get("tasks"), dict):
raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.")
label = data.get("label")
if label is not None and (not isinstance(label, str) or not label.strip()):
raise ValueError(f"Input manifest {manifest_path} label must be non-empty text.")
title = data.get("title")
if title is not None and (not isinstance(title, str) or not title.strip()):
raise ValueError(f"Input manifest {manifest_path} title must be non-empty text.")
unknown_tasks = set(data["tasks"]) - task_filenames
if unknown_tasks:
@@ -322,7 +331,7 @@ def load_input_manifest(
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
return GroupManifest(
label=label,
title=title,
declarations={
filename: parse_task_declaration(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items()
@@ -332,6 +341,8 @@ def load_input_manifest(
@dataclass(frozen=True)
class TaskDeclaration:
title: str | None
description: str | None
inputs: tuple[InputField, ...]
wait_for_result: bool
download_artifacts: bool
@@ -345,13 +356,19 @@ def parse_task_declaration(
) -> TaskDeclaration:
if (
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.")
raw_inputs = declaration.get("inputs", [])
if not isinstance(raw_inputs, 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)
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]
if len(names) != len(set(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:
raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.")
return TaskDeclaration(
title=title,
description=description,
inputs=fields,
wait_for_result=wait_for_result,
download_artifacts=download_artifacts,
@@ -381,7 +400,7 @@ def parse_execution_mode(
if raw is None:
return None
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:
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.")
field = InputField(
name=field_name,
label=values["label"],
label="Run mode",
type="choice",
required=True,
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)
if run is None:
abort(404)
return {
"artifacts": [
{
artifacts = []
for name in manager.artifacts(run.id):
artifact = {
"name": 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>")
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) {
const item = document.createElement("li");
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.textContent = artifact.name;
link.textContent = `Download ${artifact.name}`;
}
item.append(link);
list.append(item);
}
@@ -51,15 +58,24 @@ for (const container of document.querySelectorAll("#run-artifacts")) {
window.setInterval(() => refreshArtifacts(container), 2000);
}
for (const control of document.querySelectorAll("select[data-execution-mode]")) {
const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label");
const updateButtonLabel = () => {
if (buttonLabel && control.selectedOptions[0]) {
buttonLabel.textContent = control.selectedOptions[0].textContent;
for (const choice of document.querySelectorAll("button[data-execution-choice]")) {
choice.addEventListener("click", () => {
const control = choice.closest(".split-button");
const value = control?.querySelector("input[data-execution-value]");
const label = control?.querySelector(".button-label");
if (!control || !value || !label) {
return;
}
};
control.addEventListener("change", updateButtonLabel);
updateButtonLabel();
value.value = choice.dataset.value || "";
label.textContent = choice.dataset.label || "Run";
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]")) {
@@ -78,6 +94,9 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
controls.forEach((control) => {
control.disabled = value;
});
form.querySelectorAll("details.split-menu").forEach((menu) => {
menu.open = false;
});
if (value) {
window.addEventListener("beforeunload", warnBeforeLeaving);
} else {
@@ -103,11 +122,12 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
return;
}
const artifacts = (await response.json()).artifacts;
if (!artifacts.length) {
const artifact = artifacts.find((item) => !item.view_url);
if (!artifact) {
return;
}
const link = document.createElement("a");
link.href = artifacts[0].url;
link.href = artifact.url;
link.download = "";
document.body.append(link);
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);
display: grid;
gap: 1rem;
grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
padding: .8rem 0;
}
@@ -61,19 +61,26 @@ a {
align-items: end;
display: grid;
gap: .75rem;
grid-template-columns: minmax(20rem, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
}
.task-options {
display: grid;
gap: .75rem;
justify-items: start;
min-width: 0;
}
.task-input { margin: 0; }
.task-input label,
.task-input fieldset {
display: block;
max-width: 100%;
width: fit-content;
}
.task-input fieldset label {
display: block;
}
@@ -85,9 +92,16 @@ a {
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 {
width: 100%;
field-sizing: content;
}
.task-input input[type="text"],
.task-input input[type="password"] {
width: min(15rem, 100%);
}
.task-input fieldset {
@@ -118,24 +132,63 @@ button {
.split-button {
display: inline-flex;
position: relative;
}
.split-button select,
.split-button button,
.task-action > button {
margin: 0;
white-space: nowrap;
}
.split-button select {
.split-primary {
border-bottom-right-radius: 0;
border-right: 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-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 {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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') }}">
</head>
<body>
+14 -9
View File
@@ -94,17 +94,22 @@
{% if task.execution %}
<div class="split-button">
{% set execution = task.execution.field %}
{% set selected = values.get(execution.name, execution.default) %}
<label class="visually-hidden" for="{{ task.id|replace('/', '-') }}-{{ execution.name }}">Run mode</label>
<select id="{{ task.id|replace('/', '-') }}-{{ execution.name }}" name="{{ execution.name }}" data-execution-mode>
{% for option in execution.options %}
<option value="{{ option.value }}" {% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<button type="submit">
<span class="button-label">{{ task.execution.field.options[0].label }}</span>
{% set submitted = values.get(execution.name) %}
{% set selected = submitted[0] if submitted else execution.default %}
{% set selected_option = execution.options|selectattr("value", "equalto", selected)|first %}
<input type="hidden" name="{{ execution.name }}" value="{{ selected }}" data-execution-value>
<button class="split-primary" type="submit">
<span class="button-label">{{ selected_option.label if selected_option else execution.options[0].label }}</span>
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
</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>
{% else %}
<button type="submit">
+1 -1
View File
@@ -30,7 +30,7 @@
</ul>
{% endif %}
<section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden>
<h2>Downloads</h2>
<h2>Artifacts</h2>
<ul></ul>
</section>
<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"]
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"
group = root / "service"
write_task(root, "service", "status.py", '"""Shows status."""\n')
(group / "task-inputs.json").write_text(
'''{"version":1,"label":"Service UI","tasks":{}}''',
'''{"version":1,"title":"Service UI","tasks":{}}''',
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"
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:
root = tmp_path / "tasks"
group = root / "examples"
@@ -219,7 +243,6 @@ def test_execution_declaration_generates_an_action_choice(tmp_path: Path) -> Non
"migrate.py": {
"execution": {
"field": "mode",
"label": "Action",
"dry_run_value": "report",
"dry_run_label": "Report only",
"execute_value": "apply",
+43 -2
View File
@@ -213,7 +213,6 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
"migrate.py": {
"execution": {
"field": "mode",
"label": "Action",
"dry_run_value": "report",
"dry_run_label": "Report only",
"execute_value": "apply",
@@ -230,7 +229,8 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
page = client.get("/")
assert b"Report only" 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
response = client.post("/tasks/examples/migrate.py/run")
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
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:
task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n')
(task.parent / "task-inputs.json").write_text(