glab-cli-extras 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.
Files changed (44) hide show
  1. glab_cli_extras/__init__.py +3 -0
  2. glab_cli_extras/artifacts/__init__.py +0 -0
  3. glab_cli_extras/artifacts/job_logs.py +95 -0
  4. glab_cli_extras/artifacts/threads.py +147 -0
  5. glab_cli_extras/cli/__init__.py +0 -0
  6. glab_cli_extras/cli/watch.py +426 -0
  7. glab_cli_extras/daemon/__init__.py +0 -0
  8. glab_cli_extras/daemon/__main__.py +10 -0
  9. glab_cli_extras/daemon/engine.py +491 -0
  10. glab_cli_extras/daemon/lifecycle.py +186 -0
  11. glab_cli_extras/daemon/locks.py +85 -0
  12. glab_cli_extras/daemon/scheduler.py +52 -0
  13. glab_cli_extras/daemon/server.py +79 -0
  14. glab_cli_extras/daemon/transport.py +185 -0
  15. glab_cli_extras/events.py +165 -0
  16. glab_cli_extras/gitlab/__init__.py +0 -0
  17. glab_cli_extras/gitlab/budget.py +135 -0
  18. glab_cli_extras/gitlab/client.py +225 -0
  19. glab_cli_extras/gitlab/glab.py +175 -0
  20. glab_cli_extras/gitlab/queries/__init__.py +11 -0
  21. glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  22. glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  23. glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  24. glab_cli_extras/gitlab/refs.py +151 -0
  25. glab_cli_extras/paths.py +79 -0
  26. glab_cli_extras/py.typed +0 -0
  27. glab_cli_extras/resources/__init__.py +19 -0
  28. glab_cli_extras/resources/base.py +146 -0
  29. glab_cli_extras/resources/branch.py +265 -0
  30. glab_cli_extras/resources/mr.py +445 -0
  31. glab_cli_extras/resources/mr_list.py +150 -0
  32. glab_cli_extras/resources/pipeline.py +319 -0
  33. glab_cli_extras/serde.py +75 -0
  34. glab_cli_extras/watch/__init__.py +0 -0
  35. glab_cli_extras/watch/changelog.py +86 -0
  36. glab_cli_extras/watch/claude.py +193 -0
  37. glab_cli_extras/watch/client.py +316 -0
  38. glab_cli_extras/watch/sinks.py +83 -0
  39. glab_cli_extras/watch/streams.py +80 -0
  40. glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
  41. glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
  42. glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
  43. glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
  44. glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,85 @@
1
+ """Exclusive OS file locks: `fcntl.flock` on POSIX, `msvcrt.locking` on Windows.
2
+
3
+ The OS releases the lock when the holding process dies, so a lock file left on disk means nothing. Being able to take
4
+ the daemon lock proves that no daemon is running.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from types import TracebackType
14
+
15
+
16
+ class LockTimeout(TimeoutError):
17
+ pass
18
+
19
+
20
+ class FileLock:
21
+ def __init__(self, path: Path) -> None:
22
+ self.path = path
23
+ self._fd: int | None = None
24
+
25
+ @property
26
+ def held(self) -> bool:
27
+ return self._fd is not None
28
+
29
+ def _try_lock(self, fd: int) -> bool:
30
+ if sys.platform == "win32":
31
+ import msvcrt
32
+
33
+ try:
34
+ os.lseek(fd, 0, os.SEEK_SET)
35
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
36
+ except OSError:
37
+ return False
38
+ return True
39
+ import fcntl
40
+
41
+ try:
42
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
43
+ except OSError:
44
+ return False
45
+ return True
46
+
47
+ def try_acquire(self) -> bool:
48
+ if self._fd is not None:
49
+ return True
50
+ self.path.parent.mkdir(parents=True, exist_ok=True)
51
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
52
+ if self._try_lock(fd):
53
+ self._fd = fd
54
+ return True
55
+ os.close(fd)
56
+ return False
57
+
58
+ def acquire(self, timeout: float) -> None:
59
+ deadline = time.monotonic() + timeout
60
+ while not self.try_acquire():
61
+ if time.monotonic() >= deadline:
62
+ raise LockTimeout(f"could not lock {self.path} within {timeout:.0f} s")
63
+ time.sleep(0.05)
64
+
65
+ def release(self) -> None:
66
+ fd, self._fd = self._fd, None
67
+ if fd is None:
68
+ return
69
+ if sys.platform == "win32":
70
+ import msvcrt
71
+
72
+ try:
73
+ os.lseek(fd, 0, os.SEEK_SET)
74
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
75
+ except OSError:
76
+ pass
77
+ os.close(fd)
78
+
79
+ def __enter__(self) -> FileLock:
80
+ return self
81
+
82
+ def __exit__(
83
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
84
+ ) -> None:
85
+ self.release()
@@ -0,0 +1,52 @@
1
+ """Which resource polls next.
2
+
3
+ Resources wait in due-time order. Among those that are due, urgent ones go first (probes that can end a watch), then
4
+ the one served longest ago. That round-robin keeps one busy pipeline from starving a quiet MR watch when the budget
5
+ is short. The scheduler holds no clock and does no I/O; the engine passes the time in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class _Entry:
15
+ due: float
16
+ urgent: bool
17
+
18
+
19
+ class Scheduler:
20
+ def __init__(self) -> None:
21
+ self._entries: dict[str, _Entry] = {}
22
+ self._last_served: dict[str, float] = {}
23
+
24
+ def schedule(self, key: str, due: float, *, urgent: bool = False) -> None:
25
+ self._entries[key] = _Entry(due, urgent)
26
+
27
+ def remove(self, key: str) -> None:
28
+ self._entries.pop(key, None)
29
+
30
+ def forget(self, key: str) -> None:
31
+ self._entries.pop(key, None)
32
+ self._last_served.pop(key, None)
33
+
34
+ def __contains__(self, key: str) -> bool:
35
+ return key in self._entries
36
+
37
+ def due_at(self, key: str) -> float | None:
38
+ entry = self._entries.get(key)
39
+ return entry.due if entry else None
40
+
41
+ def next_due(self) -> float | None:
42
+ return min((e.due for e in self._entries.values()), default=None)
43
+
44
+ def ready(self, now: float) -> list[str]:
45
+ """Due keys in serving order: urgent first, then least recently served, then earliest due."""
46
+ due = [(key, entry) for key, entry in self._entries.items() if entry.due <= now]
47
+ due.sort(key=lambda item: (not item[1].urgent, self._last_served.get(item[0], float("-inf")), item[1].due))
48
+ return [key for key, _ in due]
49
+
50
+ def mark_served(self, key: str, now: float) -> None:
51
+ self._entries.pop(key, None)
52
+ self._last_served[key] = now
@@ -0,0 +1,79 @@
1
+ """Dispatch of daemon requests to the engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any
8
+
9
+ from glab_cli_extras import __version__
10
+ from glab_cli_extras.artifacts.job_logs import LOGS_MODES, LogsMode
11
+ from glab_cli_extras.daemon.engine import Engine, EngineError
12
+ from glab_cli_extras.daemon.transport import PROTOCOL_VERSION
13
+ from glab_cli_extras.events import ALL_KINDS
14
+ from glab_cli_extras.gitlab.refs import RefError, ResourceRef
15
+
16
+
17
+ class RequestError(ValueError):
18
+ pass
19
+
20
+
21
+ def _str(request: dict[str, Any], name: str) -> str:
22
+ value = request.get(name)
23
+ if not isinstance(value, str) or not value:
24
+ raise RequestError(f"{name} must be a non-empty string")
25
+ return value
26
+
27
+
28
+ def handle(engine: Engine, request: dict[str, Any], stop: asyncio.Event) -> dict[str, Any]:
29
+ op = request.get("op")
30
+ try:
31
+ if op == "hello":
32
+ return {"ok": True, "protocol": PROTOCOL_VERSION, "version": __version__, "pid": os.getpid()}
33
+ if op == "subscribe":
34
+ options = request.get("options") or {}
35
+ if not isinstance(options, dict):
36
+ raise RequestError("options must be an object")
37
+ raw_events = options.get("events")
38
+ if not isinstance(raw_events, list) or not all(isinstance(e, str) for e in raw_events):
39
+ raise RequestError("options.events must be a list of event kinds")
40
+ unknown = set(raw_events) - ALL_KINDS
41
+ if unknown:
42
+ raise RequestError(f"unknown event kinds: {', '.join(sorted(unknown))}")
43
+ logs = options.get("logs", "failed")
44
+ if logs not in LOGS_MODES:
45
+ raise RequestError(f"options.logs must be one of {', '.join(LOGS_MODES)}")
46
+ logs_mode: LogsMode = logs
47
+ result = engine.subscribe(
48
+ _str(request, "client_id"),
49
+ ResourceRef.from_wire(request.get("resource")),
50
+ frozenset(raw_events),
51
+ logs_mode,
52
+ options.get("baseline"),
53
+ )
54
+ return {"ok": True, **result}
55
+ if op == "poll":
56
+ cursors = request.get("cursors") or {}
57
+ if not isinstance(cursors, dict) or not all(isinstance(v, int) for v in cursors.values()):
58
+ raise RequestError("cursors must map subscription ids to integers")
59
+ return {"ok": True, **engine.poll(_str(request, "client_id"), cursors)}
60
+ if op == "unsubscribe":
61
+ removed = engine.unsubscribe(_str(request, "client_id"), _str(request, "subscription_id"))
62
+ return {"ok": True, "removed": removed}
63
+ if op == "status":
64
+ now = engine.clock()
65
+ return {
66
+ "ok": True,
67
+ "pid": os.getpid(),
68
+ "version": __version__,
69
+ "protocol": PROTOCOL_VERSION,
70
+ "uptime_s": round(now - engine.started_at, 1),
71
+ "idle_s": round(engine.idle_for(now), 1),
72
+ **engine.status(),
73
+ }
74
+ if op == "stop":
75
+ stop.set()
76
+ return {"ok": True, "pid": os.getpid()}
77
+ return {"ok": False, "error": f"unknown op {op!r}"}
78
+ except (RequestError, RefError, EngineError) as exc:
79
+ return {"ok": False, "error": str(exc)}
@@ -0,0 +1,185 @@
1
+ """The daemon transport: NDJSON, one request and one response per connection.
2
+
3
+ POSIX uses a Unix socket in the private state dir. Windows uses loopback TCP on a random port; the port and a random
4
+ token are in a file only the user can read, and every request must carry the token, because any local user can
5
+ connect to a loopback port. Setting `GLAB_CLI_EXTRAS_TRANSPORT=tcp` selects TCP on POSIX too, which is how the
6
+ Windows path is tested on Linux.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import hmac
14
+ import json
15
+ import os
16
+ import secrets
17
+ import socket
18
+ from collections.abc import Awaitable, Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from glab_cli_extras.paths import daemon_state_dir, ensure_private_dir
24
+
25
+ PROTOCOL_VERSION = 1
26
+ MAX_LINE = 4 * 1024 * 1024
27
+ TRANSPORT_ENV = "GLAB_CLI_EXTRAS_TRANSPORT"
28
+
29
+ Handler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
30
+
31
+
32
+ class DaemonUnavailable(ConnectionError):
33
+ """No daemon answers on the endpoint."""
34
+
35
+
36
+ def use_tcp() -> bool:
37
+ return os.name == "nt" or os.environ.get(TRANSPORT_ENV) == "tcp" or not hasattr(socket, "AF_UNIX")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DaemonFiles:
42
+ state_dir: Path
43
+
44
+ @classmethod
45
+ def default(cls) -> DaemonFiles:
46
+ return cls(daemon_state_dir())
47
+
48
+ def _name(self, suffix: str) -> Path:
49
+ return self.state_dir / f"daemon-v{PROTOCOL_VERSION}.{suffix}"
50
+
51
+ @property
52
+ def lock(self) -> Path:
53
+ return self._name("lock")
54
+
55
+ @property
56
+ def spawn_lock(self) -> Path:
57
+ return self.state_dir / f"spawn-v{PROTOCOL_VERSION}.lock"
58
+
59
+ @property
60
+ def socket(self) -> Path:
61
+ return self._name("sock")
62
+
63
+ @property
64
+ def endpoint(self) -> Path:
65
+ return self._name("json")
66
+
67
+ @property
68
+ def log(self) -> Path:
69
+ return self._name("log")
70
+
71
+ def describe_endpoint(self) -> str:
72
+ if use_tcp():
73
+ return str(self.endpoint)
74
+ return str(self.socket)
75
+
76
+
77
+ def _write_private(path: Path, content: str) -> None:
78
+ tmp = path.with_suffix(".tmp")
79
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
80
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
81
+ fh.write(content)
82
+ os.replace(tmp, path)
83
+
84
+
85
+ async def _read_line(reader: asyncio.StreamReader) -> bytes:
86
+ return await reader.readuntil(b"\n")
87
+
88
+
89
+ async def start_server(files: DaemonFiles, handler: Handler) -> tuple[asyncio.AbstractServer, Callable[[], None]]:
90
+ """Start listening. Return the server and a cleanup function that removes the endpoint file."""
91
+ ensure_private_dir(files.state_dir)
92
+ token = secrets.token_hex(16) if use_tcp() else None
93
+
94
+ async def on_connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
95
+ try:
96
+ line = await asyncio.wait_for(_read_line(reader), 10)
97
+ request = json.loads(line)
98
+ if not isinstance(request, dict):
99
+ response: dict[str, Any] = {"ok": False, "error": "request must be a JSON object"}
100
+ elif token is not None and not hmac.compare_digest(str(request.get("token", "")), token):
101
+ response = {"ok": False, "error": "bad token"}
102
+ else:
103
+ request.pop("token", None)
104
+ response = await handler(request)
105
+ writer.write(json.dumps(response, separators=(",", ":")).encode() + b"\n")
106
+ await writer.drain()
107
+ except (TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError, OSError):
108
+ pass
109
+ finally:
110
+ writer.close()
111
+ with contextlib.suppress(OSError, ConnectionError):
112
+ await writer.wait_closed()
113
+
114
+ if token is None:
115
+ with contextlib.suppress(FileNotFoundError):
116
+ files.socket.unlink()
117
+ server = await asyncio.start_unix_server(on_connection, path=str(files.socket), limit=MAX_LINE)
118
+ os.chmod(files.socket, 0o600)
119
+
120
+ def cleanup() -> None:
121
+ with contextlib.suppress(OSError):
122
+ files.socket.unlink()
123
+
124
+ return server, cleanup
125
+
126
+ server = await asyncio.start_server(on_connection, host="127.0.0.1", port=0, limit=MAX_LINE)
127
+ port = server.sockets[0].getsockname()[1]
128
+ _write_private(files.endpoint, json.dumps({"port": port, "token": token, "pid": os.getpid()}))
129
+
130
+ def cleanup_tcp() -> None:
131
+ with contextlib.suppress(OSError):
132
+ files.endpoint.unlink()
133
+
134
+ return server, cleanup_tcp
135
+
136
+
137
+ def _connect(files: DaemonFiles, timeout: float) -> tuple[socket.socket, str | None]:
138
+ if use_tcp():
139
+ try:
140
+ endpoint = json.loads(files.endpoint.read_text(encoding="utf-8"))
141
+ port, token = int(endpoint["port"]), str(endpoint["token"])
142
+ except (OSError, ValueError, KeyError, TypeError) as exc:
143
+ raise DaemonUnavailable(f"no daemon endpoint in {files.endpoint}") from exc
144
+ try:
145
+ return socket.create_connection(("127.0.0.1", port), timeout=timeout), token
146
+ except OSError as exc:
147
+ raise DaemonUnavailable(str(exc)) from exc
148
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
149
+ sock.settimeout(timeout)
150
+ try:
151
+ sock.connect(str(files.socket))
152
+ except OSError as exc:
153
+ sock.close()
154
+ raise DaemonUnavailable(str(exc)) from exc
155
+ return sock, None
156
+
157
+
158
+ def request(files: DaemonFiles, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
159
+ """Send one request and return the response. Raises DaemonUnavailable when nothing answers."""
160
+ sock, token = _connect(files, timeout)
161
+ with sock:
162
+ body = dict(payload)
163
+ if token is not None:
164
+ body["token"] = token
165
+ try:
166
+ sock.sendall(json.dumps(body, separators=(",", ":")).encode() + b"\n")
167
+ chunks = []
168
+ total = 0
169
+ while True:
170
+ chunk = sock.recv(65536)
171
+ if not chunk:
172
+ break
173
+ chunks.append(chunk)
174
+ total += len(chunk)
175
+ if chunk.endswith(b"\n") or total > MAX_LINE:
176
+ break
177
+ except OSError as exc:
178
+ raise DaemonUnavailable(str(exc)) from exc
179
+ raw = b"".join(chunks)
180
+ if not raw.strip():
181
+ raise DaemonUnavailable("the daemon closed the connection without answering")
182
+ response = json.loads(raw)
183
+ if not isinstance(response, dict):
184
+ raise DaemonUnavailable("the daemon sent an unexpected response")
185
+ return response
@@ -0,0 +1,165 @@
1
+ """The event model, the catalogue of event kinds per resource, and the `--events`/`--exclude-events` filter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ WATCH_STARTED = "watch_started"
11
+ WATCH_DEGRADED = "watch_degraded"
12
+ WATCH_ENDED = "watch_ended"
13
+ LIFECYCLE_KINDS: tuple[str, ...] = (WATCH_STARTED, WATCH_DEGRADED, WATCH_ENDED)
14
+
15
+ PIPELINE_KINDS: tuple[str, ...] = ("status_changed", "job_started", "job_finished", "downstream_created", "finished")
16
+ MR_OWN_KINDS: tuple[str, ...] = (
17
+ "state_changed",
18
+ "draft_changed",
19
+ "title_changed",
20
+ "description_changed",
21
+ "pushed",
22
+ "merge_status_changed",
23
+ "approvals_changed",
24
+ "reviewers_changed",
25
+ "head_pipeline_changed",
26
+ "thread_created",
27
+ "thread_updated",
28
+ "thread_resolved",
29
+ "thread_unresolved",
30
+ )
31
+ MRS_KINDS: tuple[str, ...] = ("mr_opened", "mr_updated", "mr_merged", "mr_closed", "mr_reopened")
32
+ BRANCH_KINDS: tuple[str, ...] = (
33
+ "pushed",
34
+ "force_pushed",
35
+ "created",
36
+ "deleted",
37
+ "pipeline_created",
38
+ "pipeline_finished",
39
+ )
40
+
41
+ KINDS_BY_RESOURCE: Mapping[str, tuple[str, ...]] = {
42
+ "pipeline": PIPELINE_KINDS,
43
+ "mr": MR_OWN_KINDS + PIPELINE_KINDS,
44
+ "mrs": MRS_KINDS,
45
+ "branch": BRANCH_KINDS,
46
+ }
47
+
48
+ GROUPS: Mapping[str, tuple[str, ...]] = {
49
+ "status": (
50
+ "state_changed",
51
+ "draft_changed",
52
+ "merge_status_changed",
53
+ "approvals_changed",
54
+ "reviewers_changed",
55
+ "title_changed",
56
+ "description_changed",
57
+ *MRS_KINDS,
58
+ ),
59
+ "comments": ("thread_created", "thread_updated", "thread_resolved", "thread_unresolved"),
60
+ "push": ("pushed", "force_pushed", "created", "deleted"),
61
+ "pipeline": (
62
+ "head_pipeline_changed",
63
+ *PIPELINE_KINDS,
64
+ "pipeline_created",
65
+ "pipeline_finished",
66
+ ),
67
+ "lifecycle": LIFECYCLE_KINDS,
68
+ }
69
+
70
+ ALL_KINDS: frozenset[str] = frozenset(k for kinds in KINDS_BY_RESOURCE.values() for k in kinds) | frozenset(
71
+ LIFECYCLE_KINDS
72
+ )
73
+
74
+
75
+ class FilterError(ValueError):
76
+ pass
77
+
78
+
79
+ def _expand(resource_kind: str, values: Iterable[str], option: str) -> set[str]:
80
+ produced = set(KINDS_BY_RESOURCE[resource_kind]) | set(LIFECYCLE_KINDS)
81
+ expanded: set[str] = set()
82
+ for raw in values:
83
+ name = raw.strip()
84
+ if not name:
85
+ continue
86
+ if name in GROUPS:
87
+ members = set(GROUPS[name]) & produced
88
+ if not members:
89
+ raise FilterError(f"{option}: the group {name!r} has no events for a {resource_kind} watch")
90
+ expanded |= members
91
+ elif name in ALL_KINDS:
92
+ if name not in produced:
93
+ raise FilterError(f"{option}: a {resource_kind} watch never produces {name!r}")
94
+ expanded.add(name)
95
+ else:
96
+ known = ", ".join(sorted(GROUPS))
97
+ raise FilterError(f"{option}: unknown event kind or group {name!r} (groups: {known})")
98
+ return expanded
99
+
100
+
101
+ def split_values(values: Iterable[str] | None) -> list[str]:
102
+ """Flatten repeated and comma-separated option values."""
103
+ return [part for value in values or () for part in value.split(",") if part.strip()]
104
+
105
+
106
+ def resolve_filter(resource_kind: str, include: Iterable[str] | None, exclude: Iterable[str] | None) -> frozenset[str]:
107
+ """Return the event kinds a watch reports. Lifecycle events are always in, and cannot be excluded."""
108
+ include_list = split_values(include)
109
+ exclude_list = split_values(exclude)
110
+ if include_list:
111
+ selected = _expand(resource_kind, include_list, "--events")
112
+ else:
113
+ selected = set(KINDS_BY_RESOURCE[resource_kind])
114
+ if exclude_list:
115
+ excluded = _expand(resource_kind, exclude_list, "--exclude-events")
116
+ if excluded & set(LIFECYCLE_KINDS):
117
+ raise FilterError("--exclude-events: lifecycle events are always reported and cannot be excluded")
118
+ selected -= excluded
119
+ return frozenset(selected | set(LIFECYCLE_KINDS))
120
+
121
+
122
+ def utc_now_iso() -> str:
123
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class RawEvent:
128
+ """An event as a poller produces it, before the daemon assigns its sequence number."""
129
+
130
+ kind: str
131
+ data: dict[str, Any] = field(default_factory=dict)
132
+ artifacts: tuple[str, ...] = ()
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class Event:
137
+ kind: str
138
+ resource: str
139
+ seq: int
140
+ observed_at: str
141
+ data: dict[str, Any]
142
+ artifacts: tuple[str, ...] = ()
143
+
144
+ def to_json(self) -> dict[str, Any]:
145
+ payload: dict[str, Any] = {
146
+ "kind": self.kind,
147
+ "resource": self.resource,
148
+ "seq": self.seq,
149
+ "observed_at": self.observed_at,
150
+ "data": self.data,
151
+ }
152
+ if self.artifacts:
153
+ payload["artifacts"] = list(self.artifacts)
154
+ return payload
155
+
156
+ @classmethod
157
+ def from_json(cls, data: Mapping[str, Any]) -> Event:
158
+ return cls(
159
+ kind=str(data["kind"]),
160
+ resource=str(data["resource"]),
161
+ seq=int(data["seq"]),
162
+ observed_at=str(data["observed_at"]),
163
+ data=dict(data.get("data") or {}),
164
+ artifacts=tuple(str(a) for a in data.get("artifacts") or ()),
165
+ )
File without changes