dirigent-cli 0.9.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.
@@ -0,0 +1,420 @@
1
+ """What a record kind renders as, where one line is not enough of it.
2
+
3
+ A formatter turns a record into a line. Some records carry more than a line's worth --
4
+ the closing ``run`` record carries every step and every failure -- and those get a
5
+ rendering keyed by ``kind``, built here and returned to the formatter to print.
6
+
7
+ The rendering is read off the record and nothing else. A local run deletes its database on
8
+ the way out, so the record is what survives it, and a stream read back by ``dg format``
9
+ next week has nothing else to consult either.
10
+ """
11
+
12
+ from collections.abc import Callable, Mapping, Sequence
13
+ from typing import Final, cast
14
+
15
+ from pydantic import BaseModel
16
+ from rich.console import Group, RenderableType
17
+ from rich.markup import escape
18
+
19
+ from dirigent_cli import schemas
20
+ from dirigent_cli.graph import GraphStep, render_graph
21
+ from dirigent_cli.output import (
22
+ Detail,
23
+ build_fields,
24
+ build_table,
25
+ detail_mode,
26
+ elapsed,
27
+ flagged,
28
+ labelled,
29
+ muted,
30
+ prioritised,
31
+ render_output,
32
+ styled,
33
+ )
34
+ from dirigent_core.protocol import Record
35
+
36
+ #: Fields a record carries for what is drawn beneath its line rather than for the line. A
37
+ #: line carries whole values, and a run's every step is not a line's worth of them.
38
+ BULKY: Final = frozenset(
39
+ {"steps", "failures", "windows", "packages", "settings", "history", "problems", "issues", "files", "token"}
40
+ )
41
+
42
+
43
+ def line(record: Record) -> Record:
44
+ """Drop what is rendered beneath the line from the line itself."""
45
+ return {name: value for name, value in record.items() if name not in BULKY}
46
+
47
+
48
+ def beneath(record: Record) -> RenderableType | None:
49
+ """Render what this record carries beyond its line, or nothing when it carries none."""
50
+ kind = record.get("kind")
51
+ render = RENDERERS.get(str(kind))
52
+ return None if render is None else render(record)
53
+
54
+
55
+ def _mappings(record: Record, field: str) -> list[Mapping[str, object]]:
56
+ """Read one of the record's own collections as the mappings it was written from."""
57
+ raw = record.get(field)
58
+ if not isinstance(raw, list):
59
+ return []
60
+ return [item for item in cast("list[object]", raw) if isinstance(item, Mapping)]
61
+
62
+
63
+ def _texts(record: Record, field: str) -> list[str]:
64
+ """Read one of the record's own collections as the lines it renders as."""
65
+ raw = record.get(field)
66
+ if not isinstance(raw, list):
67
+ return []
68
+ return [str(item) for item in cast("list[object]", raw)]
69
+
70
+
71
+ def _rows[Row: BaseModel](record: Record, field: str, model: type[Row]) -> list[Row]:
72
+ """Read one of the record's own collections back into the shape it was written from."""
73
+ raw = record.get(field)
74
+ if not isinstance(raw, list):
75
+ return []
76
+ items = cast("list[object]", raw)
77
+ return [model.model_validate(item) for item in items if isinstance(item, Mapping)]
78
+
79
+
80
+ def _run(record: Record) -> RenderableType | None:
81
+ """Render a closing run record: what the run was, what each step did, what failed."""
82
+ steps: list[schemas.StepSummary] = _rows(record, "steps", schemas.StepSummary)
83
+ failures: list[schemas.FailureSummary] = _rows(record, "failures", schemas.FailureSummary)
84
+ if not steps and not failures:
85
+ return None
86
+ level = detail_mode()
87
+ parts: list[RenderableType] = []
88
+ header = _header(record)
89
+ if header is not None:
90
+ parts.extend((header, ""))
91
+ if steps:
92
+ parts.append(_steps(steps, level, watched=_is_watched(record)))
93
+ if _is_watched(record):
94
+ outputs = _outputs(steps, level)
95
+ if outputs is not None:
96
+ parts.extend(("", outputs))
97
+ parts.extend(_failures(failures, level))
98
+ parts.extend(_kept(record))
99
+ return Group(*parts)
100
+
101
+
102
+ def _is_watched(record: Record) -> bool:
103
+ """Report whether this run was watched on a server, which knows more than a local one."""
104
+ return record.get("duration_ms") is not None or record.get("items_total") is not None
105
+
106
+
107
+ def _header(record: Record) -> RenderableType | None:
108
+ """Render what a watched run was: its version, what triggered it, how long, how many."""
109
+ if not _is_watched(record):
110
+ return None
111
+ total = record.get("items_total") or 0
112
+ failed = record.get("items_failed") or 0
113
+ return build_fields(
114
+ f"run {record.get('run_id')}{prioritised(record.get('priority'))}",
115
+ {
116
+ "pipeline": f"{record.get('pipeline')} (version {record.get('pipeline_version')})",
117
+ "status": record.get("message"),
118
+ "triggered by": record.get("triggered_by") or "-",
119
+ "duration": elapsed(record.get("duration_ms")),
120
+ "items": f"{total - failed}/{total}" if total else "-",
121
+ },
122
+ )
123
+
124
+
125
+ def _steps(steps: Sequence[schemas.StepSummary], level: Detail, *, watched: bool) -> RenderableType:
126
+ """Render what each step did.
127
+
128
+ A watched run reports attempts and the error the server recorded; a local run has its
129
+ outputs to hand and shows those instead of listing them again underneath.
130
+ """
131
+ if watched:
132
+ return build_table(
133
+ "steps",
134
+ ["step", "block", "outcome", "after", "attempts", "duration", "error"],
135
+ [
136
+ [
137
+ labelled(row.step, row.item) + flagged(row.warnings),
138
+ row.block,
139
+ styled(row.status),
140
+ ", ".join(row.depends_on) or "-",
141
+ str(row.attempts),
142
+ elapsed(row.duration_ms),
143
+ row.error or "-",
144
+ ]
145
+ for row in steps
146
+ ],
147
+ )
148
+ return build_table(
149
+ "steps",
150
+ ["step", "block", "outcome", "after", "duration", "output"],
151
+ [
152
+ [
153
+ labelled(row.step, row.item) + flagged(row.warnings),
154
+ row.block,
155
+ styled(row.status),
156
+ ", ".join(row.depends_on) or "-",
157
+ elapsed(row.duration_ms),
158
+ render_output(row.output, level, uri=row.artifact_uri, size_bytes=row.artifact_bytes),
159
+ ]
160
+ for row in steps
161
+ ],
162
+ )
163
+
164
+
165
+ def _outputs(steps: Sequence[schemas.StepSummary], level: Detail) -> RenderableType | None:
166
+ """Render what each settled step produced, for a run whose steps table has no room."""
167
+ produced = [row for row in steps if row.output or row.artifact_uri]
168
+ if not produced:
169
+ return None
170
+ return build_table(
171
+ "outputs",
172
+ ["step", "output"],
173
+ [
174
+ [
175
+ labelled(row.step, row.item),
176
+ render_output(row.output, level, uri=row.artifact_uri, size_bytes=row.artifact_bytes),
177
+ ]
178
+ for row in produced
179
+ ],
180
+ )
181
+
182
+
183
+ def _failures(failures: Sequence[schemas.FailureSummary], level: Detail) -> list[RenderableType]:
184
+ """Render what actually went wrong, step by step, with each attempt's own log lines."""
185
+ lines: list[RenderableType] = []
186
+ for failure in failures:
187
+ lines.append(
188
+ f"\n[bold red]{failure.step}[/] failed [dim]{failure.block}, attempt {failure.attempt}"
189
+ f"{', ' + failure.error_class if failure.error_class else ''}[/]"
190
+ )
191
+ lines.extend(f" [red]{text}[/]" for text in (failure.error or "no error was recorded").splitlines())
192
+ if failure.input and level is not Detail.SUMMARY:
193
+ lines.append(" [dim]it was given:[/]")
194
+ lines.extend(f" [dim]{text}[/]" for text in render_output(failure.input, level).splitlines())
195
+ if failure.logs:
196
+ lines.append(" [dim]last log lines:[/]")
197
+ lines.extend(f" [dim]{text}[/]" for text in failure.logs)
198
+ return lines
199
+
200
+
201
+ def _kept(record: Record) -> list[RenderableType]:
202
+ """Say where a local run's instance was kept, and how to point a server at it."""
203
+ kept = record.get("kept_at")
204
+ if not kept:
205
+ return []
206
+ lines: list[RenderableType] = ["\n[dim]the instance was kept at[/]", f" {kept}"]
207
+ scratch = record.get("scratch")
208
+ if scratch:
209
+ lines.extend(("[dim]this run wrote under[/]", f" {scratch}"))
210
+ lines.extend(
211
+ (
212
+ "[dim]point a server at it with[/]",
213
+ f" DIRIGENT_DATABASE_URL=sqlite+aiosqlite:///{kept}/dirigent.db",
214
+ )
215
+ )
216
+ return lines
217
+
218
+
219
+ def _issued(record: Record) -> RenderableType | None:
220
+ """Hand over a minted token: the form it is used in, and that it is shown once."""
221
+ token = record.get("token")
222
+ if not token:
223
+ return None
224
+ return Group(
225
+ "",
226
+ f" [bold]export DG_TOKEN={escape(str(token))}[/]",
227
+ "",
228
+ "[yellow]This is the only time the token is shown.[/]",
229
+ )
230
+
231
+
232
+ def _files(record: Record) -> list[RenderableType]:
233
+ """List what was written, named against the directory it was written into."""
234
+ written = _texts(record, "files")
235
+ if not written:
236
+ return []
237
+ directory = escape(str(record.get("directory") or "."))
238
+ parts: list[RenderableType] = [
239
+ f"Created a [bold]{escape(str(record.get('template') or 'basic'))}[/] project in [bold]{directory}[/]:",
240
+ *(f" [green]+[/] {escape(path)}" for path in written),
241
+ ]
242
+ skipped = _texts(record, "skipped")
243
+ if skipped:
244
+ parts.extend(f" [dim]= {escape(path)} (already there, left alone)[/]" for path in skipped)
245
+ if "pyproject.toml" in skipped:
246
+ version = escape(str(record.get("version") or ""))
247
+ parts.append(f"\nAdd [bold]dirigent-cli=={version}[/] to that pyproject.toml by hand.")
248
+ return parts
249
+
250
+
251
+ def _scaffolded(record: Record) -> RenderableType | None:
252
+ """Render a project scaffolded beside an instance somebody else runs."""
253
+ parts = _files(record)
254
+ if not parts:
255
+ return None
256
+ steps = _texts(record, "next")
257
+ if steps:
258
+ parts.append(
259
+ "\nThe instance is the containers: the stack builds this project's image on the"
260
+ "\npublished one, and the first admin comes from DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD"
261
+ "\nin .env."
262
+ "\n\nStart it from that directory:"
263
+ )
264
+ parts.extend(f" [bold]{index}. {escape(step)}[/]" for index, step in enumerate(steps, start=1))
265
+ return Group(*parts)
266
+ parts.append(
267
+ "\nThat is a working set of documents and no instance: nothing is running yet."
268
+ "\n Initialise one here: [bold]dg init[/]"
269
+ "\n Or address one that exists: [bold]export DG_URL=... DG_TOKEN=...[/]"
270
+ )
271
+ return Group(*parts)
272
+
273
+
274
+ def _initialised(record: Record) -> RenderableType | None:
275
+ """Render a new instance: what was written, what it is, and what it is not."""
276
+ parts = _files(record)
277
+ token = escape(str(record.get("token") or ""))
278
+ parts.append(
279
+ build_fields(
280
+ "instance",
281
+ {
282
+ "state": record.get("state"),
283
+ "schema": record.get("schema"),
284
+ "admin": record.get("admin"),
285
+ },
286
+ )
287
+ )
288
+ parts.append(
289
+ "\nThe token is shown once and never stored in readable form. Keep it:"
290
+ f"\n [bold]export DG_TOKEN={token}[/]"
291
+ "\n\nBuild the project's environment, start it, apply the example, run it:"
292
+ "\n [bold]uv sync[/]"
293
+ "\n [bold]uv run dg dev --keep-state[/] [dim]# plain dg dev starts by emptying .dirigent/state[/]"
294
+ "\n [bold]uv run dg apply[/]"
295
+ "\n [bold]uv run dg run hello-world --watch[/]"
296
+ "\n\n[yellow]This is an instance for one person on one machine[/]: SQLite on this disk, and"
297
+ "\nno secret key, so a connection carrying a credential cannot be stored until"
298
+ "\nDIRIGENT_SECRET_KEY is set. A real server is the compose stack, with PostgreSQL"
299
+ "\nand workers of its own."
300
+ )
301
+ return Group(*parts)
302
+
303
+
304
+ def _version(record: Record) -> RenderableType | None:
305
+ """Render the installed packages as the table `dg version` used to draw itself."""
306
+ packages = _mappings(record, "packages")
307
+ if not packages:
308
+ return None
309
+ return build_table(
310
+ "dirigent",
311
+ ["package", "version"],
312
+ [[str(one.get("package")), str(one.get("version"))] for one in packages],
313
+ )
314
+
315
+
316
+ def _config(record: Record) -> RenderableType | None:
317
+ """Render the effective configuration as a setting-per-row table."""
318
+ settings = record.get("settings")
319
+ if not isinstance(settings, Mapping):
320
+ return None
321
+ held = cast("Mapping[str, object]", settings)
322
+ return build_fields("effective configuration", dict(held)) if held else None
323
+
324
+
325
+ def _db_history(record: Record) -> RenderableType | None:
326
+ """Render the migration history the record carries, as alembic wrote it."""
327
+ history = str(record.get("history") or "")
328
+ return history or None
329
+
330
+
331
+ def _problems(record: Record) -> list[RenderableType]:
332
+ """Render what a refusal or a check found wrong, one line each."""
333
+ found: list[RenderableType] = [f" [red]-[/] {escape(text)}" for text in _texts(record, "problems")]
334
+ found.extend(
335
+ f" [red]-[/] {escape(str(one.get('location')))}: {escape(str(one.get('message')))}"
336
+ for one in _mappings(record, "issues")
337
+ )
338
+ return found
339
+
340
+
341
+ def _refusal(record: Record) -> RenderableType | None:
342
+ """Render the problems a refusal carries, which its line no longer spells out."""
343
+ found = _problems(record)
344
+ return Group(*found) if found else None
345
+
346
+
347
+ def _validation(record: Record) -> RenderableType | None:
348
+ """Render what a checked document is wrong about, or the shape of one that is right."""
349
+ parts = _problems(record)
350
+ steps = _mappings(record, "steps")
351
+ if steps:
352
+ parts.append(f" {muted('each step under the last one it waits for')}")
353
+ parts.extend(f" {escape(drawn)}" for drawn in render_graph([_graph_step(one) for one in steps]))
354
+ return Group(*parts) if parts else None
355
+
356
+
357
+ def _graph_step(one: Mapping[str, object]) -> GraphStep:
358
+ """Read one step of a validated document back into the shape the tree is drawn from."""
359
+ depends = one.get("depends_on")
360
+ return GraphStep(
361
+ name=str(one.get("name")),
362
+ block=str(one.get("block")),
363
+ depends_on=tuple(str(item) for item in cast("list[object]", depends or [])),
364
+ rule=str(one.get("rule")),
365
+ )
366
+
367
+
368
+ def _backfill(record: Record) -> RenderableType | None:
369
+ """Render a backfill: the windows it enumerated, and the run each one became."""
370
+ windows: list[schemas.BackfillWindow] = _rows(record, "windows", schemas.BackfillWindow)
371
+ if not windows:
372
+ return None
373
+ return build_table(
374
+ f"{record.get('pipeline')} {record.get('schedule')}",
375
+ ["window start", "window end", "run"],
376
+ [[one.window_start, one.window_end, one.run_id or one.detail or "-"] for one in windows],
377
+ )
378
+
379
+
380
+ def _profile(record: Record) -> RenderableType | None:
381
+ """Render a run profile: the chain that decided the run, and the split along it."""
382
+ path = record.get("critical_path")
383
+ if not isinstance(path, list):
384
+ return None
385
+ chain = " -> ".join(str(code) for code in cast("list[object]", path)) or "-"
386
+ duration = record.get("duration_ms")
387
+ parts = [
388
+ ("queued", record.get("queued_ms")),
389
+ ("running", record.get("running_ms")),
390
+ ("waiting", record.get("waiting_ms")),
391
+ ]
392
+ return build_table(
393
+ f"critical path: {chain}",
394
+ ["where the time went", "duration", "share of the run"],
395
+ [[name, elapsed(value), _share(value, duration)] for name, value in parts],
396
+ )
397
+
398
+
399
+ def _share(part: object, whole: object) -> str:
400
+ """Say what fraction of the run one part of it was, where the run has a duration."""
401
+ if not isinstance(part, int) or not isinstance(whole, int) or whole <= 0:
402
+ return "-"
403
+ return f"{round(100 * part / whole)}%"
404
+
405
+
406
+ #: What each record kind renders beneath its line. A kind that is not here renders as its
407
+ #: line alone, which is what makes a record from a newer dirigent readable rather than fatal.
408
+ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
409
+ "run": _run,
410
+ "run.profile": _profile,
411
+ "backfill": _backfill,
412
+ "version": _version,
413
+ "config": _config,
414
+ "db.history": _db_history,
415
+ "validation": _validation,
416
+ "error": _refusal,
417
+ "token.issued": _issued,
418
+ "project.scaffolded": _scaffolded,
419
+ "instance.initialised": _initialised,
420
+ }
@@ -0,0 +1,23 @@
1
+ # dirigent-{name}
2
+
3
+ A dirigent block pack. It contributes `{name}.hello`; replace that operator with the
4
+ blocks this pack is for.
5
+
6
+ The dirigent packages are not on an index yet, so point uv at a checkout before syncing:
7
+
8
+ ```toml
9
+ [tool.uv.sources]
10
+ dirigent-common = {{ path = "../dirigent/packages/dirigent-common", editable = true }}
11
+ dirigent-plugin = {{ path = "../dirigent/packages/dirigent-plugin", editable = true }}
12
+ dirigent-testing = {{ path = "../dirigent/packages/dirigent-testing", editable = true }}
13
+ ```
14
+
15
+ Then:
16
+
17
+ ```bash
18
+ uv sync
19
+ uv run pytest
20
+ ```
21
+
22
+ An instance discovers the pack by installing it: the entry point in `pyproject.toml` is
23
+ the whole registration.
@@ -0,0 +1,24 @@
1
+ """The {name} block pack."""
2
+
3
+ from dirigent_{name}.{name} import HelloConfig, HelloOperator, HelloOutput
4
+ from dirigent_plugin import Contribution, extension
5
+
6
+
7
+ class {title}Pack:
8
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
9
+
10
+ @extension
11
+ def contribute(self) -> Contribution:
12
+ """Contribute this pack's blocks."""
13
+ return Contribution(operators=[HelloOperator()])
14
+
15
+
16
+ plugin = {title}Pack()
17
+
18
+ __all__ = [
19
+ "HelloConfig",
20
+ "HelloOperator",
21
+ "HelloOutput",
22
+ "{title}Pack",
23
+ "plugin",
24
+ ]
@@ -0,0 +1,34 @@
1
+ """``{name}.hello``: the pack's first operator, here to be replaced."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from dirigent_common import BlockModel
8
+ from dirigent_plugin import Operator, OperatorSpec, StepContext
9
+
10
+
11
+ class HelloConfig(BlockModel):
12
+ """What the step takes."""
13
+
14
+ greeting: str = "hello"
15
+ """The word the step leads with."""
16
+
17
+
18
+ class HelloOutput(BlockModel):
19
+ """What the step hands downstream."""
20
+
21
+ message: str
22
+ """The composed greeting."""
23
+
24
+
25
+ class HelloOperator(Operator[HelloConfig, HelloOutput]):
26
+ """Composes a greeting, which is the smallest thing an operator can do."""
27
+
28
+ spec = OperatorSpec(id="{name}.hello", summary="Say hello.", idempotent=True)
29
+ config_model: ClassVar[type[BaseModel]] = HelloConfig
30
+ output_model: ClassVar[type[BaseModel]] = HelloOutput
31
+
32
+ async def execute(self, config: HelloConfig, ctx: StepContext) -> HelloOutput:
33
+ """Compose and return, in one call."""
34
+ return HelloOutput(message=f"{{config.greeting}} from {name}")
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "dirigent-{name}"
3
+ version = "0.1.0"
4
+ description = "A dirigent block pack."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = ["dirigent-common", "dirigent-plugin", "pydantic>=2"]
8
+
9
+ [project.entry-points."dirigent.plugins.v1"]
10
+ {name} = "dirigent_{name}:plugin"
11
+
12
+ [dependency-groups]
13
+ dev = ["dirigent-testing", "pytest>=8", "pytest-asyncio>=0.24"]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.12.0,<0.13.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.pytest.ini_options]
20
+ asyncio_mode = "auto"
21
+ testpaths = ["tests"]
@@ -0,0 +1,21 @@
1
+ """Tests for the {name} pack's wiring and its first operator."""
2
+
3
+ from dirigent_{name} import plugin
4
+ from dirigent_{name}.{name} import HelloOperator, HelloOutput
5
+ from dirigent_plugin import PROJECT_NAME, contribute, markers
6
+ from dirigent_testing import FakeContext, call_block
7
+ from pluginkit import PluginManager
8
+
9
+
10
+ def test_the_pack_registers_through_a_plugin_manager() -> None:
11
+ manager = PluginManager(PROJECT_NAME)
12
+ manager.add_extension_points(markers)
13
+ manager.register(plugin, name="{name}")
14
+ collected = [contribution.block_ids() for contribution in manager.caller(contribute)()]
15
+ assert collected == [["{name}.hello"]]
16
+
17
+
18
+ async def test_hello_says_hello(block_ctx: FakeContext) -> None:
19
+ output = await call_block(HelloOperator(), {{"greeting": "greetings"}}, block_ctx)
20
+ assert isinstance(output, HelloOutput)
21
+ assert output.message == "greetings from {name}"