Improve task control panel layout
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 47s
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 1m40s
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 1m18s
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 47s
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 1m40s
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 1m18s
This commit is contained in:
@@ -37,6 +37,7 @@ for its web forms and its task order. It is parsed as data only and uses version
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"label": "Example Tasks",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"task.py": {
|
"task.py": {
|
||||||
"inputs": [
|
"inputs": [
|
||||||
@@ -61,7 +62,7 @@ history. The form masks sensitive text, the database records `[redacted]`, and
|
|||||||
the original value is available only through a private execution input file.
|
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 `{}`
|
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
|
when it only needs ordering; unlisted scripts appear afterward in alphabetical
|
||||||
order.
|
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
|
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
|
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
|
leaving the page while the task is running; tasks without it keep the normal
|
||||||
@@ -69,8 +70,8 @@ queued-run behavior.
|
|||||||
|
|
||||||
For a task with a review-first operation, declare `execution` with the input
|
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
|
field and its review and execution values. The control panel generates the
|
||||||
dropdown. A successful review run offers a button that repeats the same saved
|
mode selector attached to the submit button. A successful review run offers a
|
||||||
inputs using the execution value; it does not reuse a stale filesystem
|
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.
|
snapshot, so the task must still validate its inputs before changing anything.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -12,10 +12,19 @@ from typing import Any
|
|||||||
|
|
||||||
FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
|
FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
|
||||||
FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice", "file"})
|
FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice", "file"})
|
||||||
|
DISPLAY_ACRONYMS = {
|
||||||
|
"api": "API", "csv": "CSV", "iata": "IATA", "icao": "ICAO", "id": "ID",
|
||||||
|
"ids": "IDs", "url": "URL", "urls": "URLs", "uuid": "UUID",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def display_name(value: str) -> str:
|
def display_name(value: str) -> str:
|
||||||
return value.replace("_", " ")
|
titled = value.replace("_", " ").title()
|
||||||
|
return re.sub(
|
||||||
|
r"\b[A-Za-z]+\b",
|
||||||
|
lambda match: DISPLAY_ACRONYMS.get(match.group().casefold(), match.group()),
|
||||||
|
titled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -104,12 +113,19 @@ class TaskCatalog:
|
|||||||
or not group_path.is_dir()
|
or not group_path.is_dir()
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
tasks = tuple(self._tasks_in_group(group_path))
|
manifest = load_input_manifest(
|
||||||
|
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((".", "_"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tasks = tuple(self._tasks_in_group(group_path, manifest.declarations))
|
||||||
groups.append(
|
groups.append(
|
||||||
Group(
|
Group(
|
||||||
id=group_path.name,
|
id=group_path.name,
|
||||||
path=group_path,
|
path=group_path,
|
||||||
display_name=display_name(group_path.name),
|
display_name=manifest.label or display_name(group_path.name),
|
||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -140,7 +156,11 @@ class TaskCatalog:
|
|||||||
tasks.extend(load_background_tasks(group_path))
|
tasks.extend(load_background_tasks(group_path))
|
||||||
return tuple(tasks)
|
return tuple(tasks)
|
||||||
|
|
||||||
def _tasks_in_group(self, group_path: Path) -> list[Task]:
|
def _tasks_in_group(
|
||||||
|
self,
|
||||||
|
group_path: Path,
|
||||||
|
declarations_by_filename: dict[str, "TaskDeclaration"] | None = None,
|
||||||
|
) -> list[Task]:
|
||||||
task_paths = [
|
task_paths = [
|
||||||
path
|
path
|
||||||
for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name)
|
for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name)
|
||||||
@@ -151,10 +171,11 @@ class TaskCatalog:
|
|||||||
or path.suffix != ".py"
|
or path.suffix != ".py"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
if declarations_by_filename is None:
|
||||||
declarations_by_filename = load_input_manifest(
|
declarations_by_filename = load_input_manifest(
|
||||||
group_path,
|
group_path,
|
||||||
{path.name for path in task_paths},
|
{path.name for path in task_paths},
|
||||||
)
|
).declarations
|
||||||
paths_by_filename = {path.name: path for path in task_paths}
|
paths_by_filename = {path.name: path for path in task_paths}
|
||||||
ordered_filenames = [
|
ordered_filenames = [
|
||||||
*declarations_by_filename,
|
*declarations_by_filename,
|
||||||
@@ -264,37 +285,49 @@ def read_description(path: Path) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GroupManifest:
|
||||||
|
label: str | None
|
||||||
|
declarations: dict[str, "TaskDeclaration"]
|
||||||
|
|
||||||
|
|
||||||
def load_input_manifest(
|
def load_input_manifest(
|
||||||
group_path: Path,
|
group_path: Path,
|
||||||
task_filenames: set[str],
|
task_filenames: set[str],
|
||||||
) -> dict[str, "TaskDeclaration"]:
|
) -> GroupManifest:
|
||||||
"""Read the optional data-only input declaration for one task group."""
|
"""Read the optional data-only input declaration for one task group."""
|
||||||
|
|
||||||
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 {}
|
return GroupManifest(label=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", "tasks"}:
|
if not isinstance(data, dict) or set(data) - {"version", "label", "tasks"}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Input manifest {manifest_path} must contain only version and tasks."
|
f"Input manifest {manifest_path} has an invalid structure."
|
||||||
)
|
)
|
||||||
if data["version"] != 1 or not isinstance(data["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")
|
||||||
|
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.")
|
||||||
|
|
||||||
unknown_tasks = set(data["tasks"]) - task_filenames
|
unknown_tasks = set(data["tasks"]) - task_filenames
|
||||||
if unknown_tasks:
|
if unknown_tasks:
|
||||||
names = ", ".join(sorted(unknown_tasks))
|
names = ", ".join(sorted(unknown_tasks))
|
||||||
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
|
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
|
||||||
|
|
||||||
return {
|
return GroupManifest(
|
||||||
|
label=label,
|
||||||
|
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()
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ 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]")) {
|
||||||
|
const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label");
|
||||||
|
const updateButtonLabel = () => {
|
||||||
|
if (buttonLabel && control.selectedOptions[0]) {
|
||||||
|
buttonLabel.textContent = control.selectedOptions[0].textContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
control.addEventListener("change", updateButtonLabel);
|
||||||
|
updateButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
|
for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
|
||||||
const result = form.querySelector(".task-result");
|
const result = form.querySelector(".task-result");
|
||||||
const controls = [...form.querySelectorAll("input, select, button")];
|
const controls = [...form.querySelectorAll("input, select, button")];
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-heading,
|
.group-heading {
|
||||||
.tasks li {
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -47,6 +46,9 @@ a {
|
|||||||
|
|
||||||
.tasks li {
|
.tasks li {
|
||||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
|
||||||
padding: .8rem 0;
|
padding: .8rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +57,17 @@ a {
|
|||||||
margin: .2rem 0;
|
margin: .2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tasks form {
|
.task-form {
|
||||||
min-width: 16rem;
|
align-items: end;
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input {
|
.task-input {
|
||||||
margin: .5rem 0;
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input label,
|
.task-input label,
|
||||||
@@ -75,6 +82,11 @@ a {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-input input:not([type="checkbox"]),
|
||||||
|
.task-input select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.task-input fieldset {
|
.task-input fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -97,6 +109,31 @@ button {
|
|||||||
padding: .4rem .7rem;
|
padding: .4rem .7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-action {
|
||||||
|
align-items: stretch;
|
||||||
|
border-left: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
grid-column: -1;
|
||||||
|
grid-row: 1;
|
||||||
|
margin-left: .25rem;
|
||||||
|
padding-left: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action select,
|
||||||
|
.task-action button {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
@@ -118,6 +155,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-result {
|
.task-result {
|
||||||
|
grid-column: 1 / -1;
|
||||||
margin: .5rem 0 0;
|
margin: .5rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +171,22 @@ button:disabled {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 58rem) {
|
||||||
|
.tasks li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 42rem) {
|
||||||
|
.task-form {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
<ul class="tasks">
|
<ul class="tasks">
|
||||||
{% for task in group.tasks %}
|
{% for task in group.tasks %}
|
||||||
{% set task_run = latest[("task", task.id)] %}
|
{% set task_run = latest[("task", task.id)] %}
|
||||||
<li>
|
<li class="task">
|
||||||
<div>
|
<div class="task-details">
|
||||||
<h3>{{ task.display_name }}</h3>
|
<h3>{{ task.display_name }}</h3>
|
||||||
{% if task.description %}<p>{{ task.description }}</p>{% endif %}
|
{% if task.description %}<p>{{ task.description }}</p>{% endif %}
|
||||||
{% if task_run %}
|
{% if task_run %}
|
||||||
@@ -37,10 +37,11 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('run_task', task_id=task.id) }}" enctype="multipart/form-data"{% if task.wait_for_result %} data-wait-for-result{% endif %}{% if task.download_artifacts %} data-download-artifacts{% endif %}>
|
<form class="task-form" method="post" action="{{ url_for('run_task', task_id=task.id) }}" enctype="multipart/form-data"{% if task.wait_for_result %} data-wait-for-result{% endif %}{% if task.download_artifacts %} data-download-artifacts{% endif %}>
|
||||||
{% set values = form_values.get(task.id, {}) %}
|
{% set values = form_values.get(task.id, {}) %}
|
||||||
{% set errors = input_errors.get(task.id, {}) %}
|
{% set errors = input_errors.get(task.id, {}) %}
|
||||||
{% for field in task.inputs %}
|
{% for field in task.inputs %}
|
||||||
|
{% if not task.execution or field.name != task.execution.field.name %}
|
||||||
<div class="task-input">
|
<div class="task-input">
|
||||||
{% if field.type == "file" %}
|
{% if field.type == "file" %}
|
||||||
<label>{{ field.label }}{% if field.required %} (required){% endif %}
|
<label>{{ field.label }}{% if field.required %} (required){% endif %}
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% elif field.type == "choice" %}
|
{% elif field.type == "choice" %}
|
||||||
<label>{{ field.label }}{% if field.required %} (required){% endif %}
|
<label>{{ field.label }}
|
||||||
<select name="{{ field.name }}" {% if field.required %}required{% endif %}>
|
<select name="{{ field.name }}" {% if field.required %}required{% endif %}>
|
||||||
{% if not field.required %}<option value="">No selection</option>{% endif %}
|
{% if not field.required %}<option value="">No selection</option>{% endif %}
|
||||||
{% set selected = values.get(field.name, field.default or "") %}
|
{% set selected = values.get(field.name, field.default or "") %}
|
||||||
@@ -85,11 +86,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %}
|
{% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<div class="task-action">
|
||||||
|
{% if task.execution %}
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<span class="button-label">Run</span>
|
<span class="button-label">{{ task.execution.field.options[0].label if task.execution else "Run" }}</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>
|
||||||
|
</div>
|
||||||
{% if task.wait_for_result %}<p class="task-result" aria-live="polite"></p>{% endif %}
|
{% if task.wait_for_result %}<p class="task-result" aria-live="polite"></p>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ def test_discovers_only_direct_non_hidden_python_tasks(tmp_path: Path) -> None:
|
|||||||
groups = catalog.groups()
|
groups = catalog.groups()
|
||||||
|
|
||||||
assert [group.id for group in groups] == ["media_tasks", "space group"]
|
assert [group.id for group in groups] == ["media_tasks", "space group"]
|
||||||
assert groups[0].display_name == "media tasks"
|
assert groups[0].display_name == "Media Tasks"
|
||||||
assert [task.filename for task in groups[0].tasks] == ["check_one.py", "with-hyphen.py"]
|
assert [task.filename for task in groups[0].tasks] == ["check_one.py", "with-hyphen.py"]
|
||||||
assert [task.display_name for task in groups[0].tasks] == ["check one", "with-hyphen"]
|
assert [task.display_name for task in groups[0].tasks] == ["Check One", "With-Hyphen"]
|
||||||
assert groups[1].tasks[0].display_name == "already named"
|
assert groups[1].tasks[0].display_name == "Already Named"
|
||||||
assert groups[0].tasks[0].description == "Checks one thing."
|
assert groups[0].tasks[0].description == "Checks one thing."
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ def test_ignores_symlinked_groups_and_tasks(tmp_path: Path) -> None:
|
|||||||
(group / "linked.py").symlink_to(external / "outside" / "outside.py")
|
(group / "linked.py").symlink_to(external / "outside" / "outside.py")
|
||||||
|
|
||||||
assert TaskCatalog(root).groups() == (
|
assert TaskCatalog(root).groups() == (
|
||||||
Group(id="safe", path=group, display_name="safe", tasks=()),
|
Group(id="safe", path=group, display_name="Safe", tasks=()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +119,31 @@ 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:
|
||||||
|
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":{}}''',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
discovered = TaskCatalog(root).group("service")
|
||||||
|
|
||||||
|
assert discovered is not None
|
||||||
|
assert discovered.display_name == "Service UI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_names_keep_common_acronyms_capitalized(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "tasks"
|
||||||
|
write_task(root, "tools", "replace_api_urls.py", '"""Updates URLs."""\n')
|
||||||
|
|
||||||
|
task = TaskCatalog(root).task("tools/replace_api_urls.py")
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.display_name == "Replace API URLs"
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def test_index_discovers_task_and_run_endpoint(settings) -> None:
|
|||||||
|
|
||||||
page = client.get("/")
|
page = client.get("/")
|
||||||
assert page.status_code == 200
|
assert page.status_code == 200
|
||||||
assert b"status checks" in page.data
|
assert b"Status Checks" in page.data
|
||||||
assert b"Shows status." in page.data
|
assert b"Shows status." in page.data
|
||||||
|
|
||||||
response = client.post("/tasks/status_checks/show_status.py/run")
|
response = client.post("/tasks/status_checks/show_status.py/run")
|
||||||
@@ -230,6 +230,7 @@ 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
|
||||||
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])
|
||||||
deadline = time.monotonic() + 3
|
deadline = time.monotonic() + 3
|
||||||
|
|||||||
Reference in New Issue
Block a user