queue-watch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
queue_watch/model.py ADDED
@@ -0,0 +1,188 @@
1
+ """Snapshot contract v1: the only shape views consume and adapters produce.
2
+
3
+ A snapshot is one observation of one source:
4
+
5
+ {
6
+ "schema": "queue-watch.snapshot/1",
7
+ "observed_at": "<ISO 8601 with offset>",
8
+ "records": [Record, ...],
9
+ "views": [View, ...], # optional; defaults come from DEFAULT_VIEWS
10
+ "warnings": ["...", ...], # optional; partial data, truncation, skipped input
11
+ "note": "..." # optional; caveats shown in the inspector
12
+ }
13
+
14
+ Record fields: ``id``, ``kind``, ``state`` and ``title`` are required. ``state`` is
15
+ one of STATES; adapters keep their own vocabulary in ``raw_state``. ``ref`` and
16
+ ``sha`` identify the change so records from different sources can be correlated.
17
+ Optional: ``raw_state``, ``subtitle``, ``ref``, ``sha``, ``owner``, ``position``
18
+ (int), ``created_at``, ``updated_at``, ``started_at``, ``finished_at`` (ISO 8601),
19
+ ``url``, ``fields`` (JSON object for the inspector) and ``children`` (records,
20
+ for example jobs and steps).
21
+
22
+ View: ``{"kind", "label", "columns": [{"key", "label", "width", "format"}]}``.
23
+ A column key is a record field name or ``fields.<name>``; FORMATS lists formats.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from datetime import datetime
29
+ from typing import Any
30
+
31
+ SCHEMA = "queue-watch.snapshot/1"
32
+ STATES = (
33
+ "queued",
34
+ "running",
35
+ "blocked",
36
+ "success",
37
+ "failure",
38
+ "cancelled",
39
+ "neutral",
40
+ "unknown",
41
+ )
42
+ ACTIVE = frozenset({"queued", "running", "blocked"})
43
+ ATTENTION = frozenset({"failure", "blocked"})
44
+ FORMATS = ("text", "state", "clock", "age", "seconds", "duration", "sha")
45
+ TIME_FIELDS = ("created_at", "updated_at", "started_at", "finished_at")
46
+ TEXT_FIELDS = ("raw_state", "subtitle", "ref", "sha", "owner", "url")
47
+
48
+ DEFAULT_VIEWS: dict[str, dict] = {
49
+ "run": {
50
+ "kind": "run",
51
+ "label": "Runs",
52
+ "columns": [
53
+ {"key": "state", "label": "State", "width": 12, "format": "state"},
54
+ {"key": "title", "label": "Workflow / title", "width": 25},
55
+ {"key": "ref", "label": "Branch", "width": 18},
56
+ {"key": "sha", "label": "SHA", "width": 8, "format": "sha"},
57
+ {"key": "created_at", "label": "Age", "width": 9, "format": "age"},
58
+ ],
59
+ },
60
+ "queue_entry": {
61
+ "kind": "queue_entry",
62
+ "label": "Queue",
63
+ "columns": [
64
+ {"key": "position", "label": "#", "width": 4},
65
+ {"key": "state", "label": "State", "width": 12, "format": "state"},
66
+ {"key": "title", "label": "Change", "width": 38},
67
+ {"key": "owner", "label": "Owner", "width": 19},
68
+ {"key": "created_at", "label": "Age", "width": 10, "format": "age"},
69
+ ],
70
+ },
71
+ "attempt": {
72
+ "kind": "attempt",
73
+ "label": "Recent attempts",
74
+ "columns": [
75
+ {"key": "state", "label": "Outcome", "width": 10, "format": "state"},
76
+ {"key": "title", "label": "Change", "width": 38},
77
+ {"key": "finished_at", "label": "Finished", "width": 18, "format": "clock"},
78
+ ],
79
+ },
80
+ }
81
+
82
+
83
+ class SnapshotError(ValueError):
84
+ """A snapshot does not satisfy contract v1."""
85
+
86
+
87
+ def _iso(value: Any, where: str) -> None:
88
+ if not isinstance(value, str):
89
+ raise SnapshotError(f"{where} must be an ISO 8601 string")
90
+ try:
91
+ parsed = datetime.fromisoformat(value)
92
+ except ValueError as error:
93
+ raise SnapshotError(f"{where} is not ISO 8601: {value!r}") from error
94
+ if parsed.tzinfo is None:
95
+ raise SnapshotError(f"{where} needs a UTC offset: {value!r}")
96
+
97
+
98
+ def validate_record(record: Any, where: str = "record") -> None:
99
+ if not isinstance(record, dict):
100
+ raise SnapshotError(f"{where} must be an object")
101
+ for key in ("id", "kind", "title"):
102
+ if not isinstance(record.get(key), str) or not record[key]:
103
+ raise SnapshotError(f"{where}.{key} must be a non-empty string")
104
+ if record.get("state") not in STATES:
105
+ raise SnapshotError(
106
+ f"{where}.state must be one of {', '.join(STATES)}, got {record.get('state')!r}"
107
+ )
108
+ for key in TEXT_FIELDS:
109
+ if record.get(key) is not None and not isinstance(record[key], str):
110
+ raise SnapshotError(f"{where}.{key} must be a string")
111
+ position = record.get("position")
112
+ if position is not None and (isinstance(position, bool) or not isinstance(position, int)):
113
+ raise SnapshotError(f"{where}.position must be an integer")
114
+ for key in TIME_FIELDS:
115
+ if record.get(key) is not None:
116
+ _iso(record[key], f"{where}.{key}")
117
+ if record.get("fields") is not None and not isinstance(record["fields"], dict):
118
+ raise SnapshotError(f"{where}.fields must be an object")
119
+ children = record.get("children")
120
+ if children is not None:
121
+ if not isinstance(children, list):
122
+ raise SnapshotError(f"{where}.children must be a list")
123
+ for index, child in enumerate(children):
124
+ validate_record(child, f"{where}.children[{index}]")
125
+
126
+
127
+ def validate_snapshot(snapshot: Any) -> dict:
128
+ """Return ``snapshot`` unchanged if it satisfies contract v1, else raise."""
129
+ if not isinstance(snapshot, dict):
130
+ raise SnapshotError("snapshot must be an object")
131
+ if snapshot.get("schema") != SCHEMA:
132
+ raise SnapshotError(f"schema must be {SCHEMA!r}, got {snapshot.get('schema')!r}")
133
+ _iso(snapshot.get("observed_at"), "observed_at")
134
+ records = snapshot.get("records")
135
+ if not isinstance(records, list):
136
+ raise SnapshotError("records must be a list")
137
+ seen = set()
138
+ for index, record in enumerate(records):
139
+ validate_record(record, f"records[{index}]")
140
+ if record["id"] in seen:
141
+ raise SnapshotError(f"records[{index}].id {record['id']!r} is duplicated")
142
+ seen.add(record["id"])
143
+ for key in ("warnings",):
144
+ value = snapshot.get(key, [])
145
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
146
+ raise SnapshotError(f"{key} must be a list of strings")
147
+ if snapshot.get("note") is not None and not isinstance(snapshot["note"], str):
148
+ raise SnapshotError("note must be a string")
149
+ for index, view in enumerate(snapshot.get("views", [])):
150
+ where = f"views[{index}]"
151
+ if not isinstance(view, dict) or not isinstance(view.get("kind"), str):
152
+ raise SnapshotError(f"{where}.kind must be a string")
153
+ columns = view.get("columns")
154
+ if not isinstance(columns, list) or not columns:
155
+ raise SnapshotError(f"{where}.columns must be a non-empty list")
156
+ for number, column in enumerate(columns):
157
+ if not isinstance(column, dict) or not isinstance(column.get("key"), str):
158
+ raise SnapshotError(f"{where}.columns[{number}].key must be a string")
159
+ if column.get("format", "text") not in FORMATS:
160
+ raise SnapshotError(
161
+ f"{where}.columns[{number}].format must be one of {', '.join(FORMATS)}"
162
+ )
163
+ return snapshot
164
+
165
+
166
+ def views_for(snapshot: dict) -> list[dict]:
167
+ """Declared views first, then defaults for any record kind left undeclared."""
168
+ views = list(snapshot.get("views", []))
169
+ declared = {view["kind"] for view in views}
170
+ for record in snapshot.get("records", []):
171
+ kind = record["kind"]
172
+ if kind not in declared:
173
+ declared.add(kind)
174
+ views.append(
175
+ DEFAULT_VIEWS.get(kind)
176
+ or {
177
+ "kind": kind,
178
+ "label": kind.replace("_", " ").capitalize(),
179
+ "columns": DEFAULT_VIEWS["run"]["columns"],
180
+ }
181
+ )
182
+ return views
183
+
184
+
185
+ def lookup(record: dict, key: str) -> Any:
186
+ if key.startswith("fields."):
187
+ return (record.get("fields") or {}).get(key.removeprefix("fields."))
188
+ return record.get(key)
queue_watch/process.py ADDED
@@ -0,0 +1,67 @@
1
+ """Subprocess and text helpers shared by adapters and views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import signal
12
+ from typing import Any
13
+
14
+
15
+ def check_host(host: str | None) -> str | None:
16
+ """An SSH destination, never something ssh would parse as an option."""
17
+ if host is not None and (not isinstance(host, str) or not host or host.startswith("-")):
18
+ raise ValueError("host must be an SSH destination, not an option")
19
+ return host
20
+
21
+
22
+ def over_ssh(host: str, argv: list[str], cwd: str | None = None) -> list[str]:
23
+ """Run ``argv`` on ``host`` with every argument quoted for the remote shell."""
24
+ command = shlex.join(argv)
25
+ if cwd:
26
+ # Keep a leading ~/ unquoted so the remote shell expands it.
27
+ target = "~/" + shlex.quote(cwd[2:]) if cwd.startswith("~/") else shlex.quote(cwd)
28
+ command = f"cd {target} && {command}"
29
+ return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host, "--", command]
30
+
31
+
32
+ async def run_json(
33
+ argv: list[str], *, stdin: str | None = None, timeout: float = 25, cwd: str | None = None
34
+ ) -> Any:
35
+ """Run ``argv`` and parse stdout as JSON; a nonzero exit raises RuntimeError.
36
+
37
+ The command gets its own process group so a timeout also stops children that
38
+ inherited its output pipes; otherwise they would hold the read open.
39
+ """
40
+ process = await asyncio.create_subprocess_exec(
41
+ *argv,
42
+ stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
43
+ stdout=asyncio.subprocess.PIPE,
44
+ stderr=asyncio.subprocess.PIPE,
45
+ cwd=cwd,
46
+ start_new_session=True,
47
+ )
48
+ try:
49
+ stdout, stderr = await asyncio.wait_for(
50
+ process.communicate(stdin.encode() if stdin is not None else None), timeout
51
+ )
52
+ except (TimeoutError, asyncio.CancelledError):
53
+ with contextlib.suppress(ProcessLookupError):
54
+ os.killpg(process.pid, signal.SIGKILL)
55
+ # A child that left the group can still hold the pipes; do not wait on it.
56
+ with contextlib.suppress(TimeoutError):
57
+ await asyncio.wait_for(process.communicate(), 2)
58
+ raise
59
+ if process.returncode:
60
+ message = stderr.decode(errors="replace").strip()[-1200:]
61
+ raise RuntimeError(message or f"{argv[0]} exited {process.returncode}")
62
+ return json.loads(stdout)
63
+
64
+
65
+ def clean(value: Any) -> str:
66
+ """Keep remote text literal and strip terminal control characters."""
67
+ return re.sub(r"[\x00-\x1f\x7f-\x9f]", " ", str(value if value is not None else ""))