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.
dirigent_cli/reaper.py ADDED
@@ -0,0 +1,115 @@
1
+ """Wiring the docker orphan reaper to an instance: what a run id means, and when a pass runs.
2
+
3
+ The reaping itself is ``dirigent_blocks.reap``, which knows docker and nothing else. This
4
+ module supplies the half it cannot have: the run lookup, which is a database read, and the
5
+ worker chore that puts a pass on a cadence.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from collections.abc import Mapping
12
+ from datetime import timedelta
13
+ from pathlib import Path
14
+ from uuid import UUID
15
+
16
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
17
+
18
+ from dirigent_blocks import reap, subprocess
19
+ from dirigent_blocks.docker import DAEMON_ENV
20
+ from dirigent_client.schemas import TERMINAL_RUN_STATUSES
21
+ from dirigent_core.config import Settings
22
+ from dirigent_core.database import session_scope
23
+ from dirigent_core.logging import get_logger
24
+ from dirigent_core.models import Run
25
+ from dirigent_core.worker import Chore
26
+
27
+ #: The docker config directory is inherited too: a pass runs the host's own CLI, and on a host
28
+ #: whose compose plugin is configured rather than installed system-wide, this is where it says so.
29
+ REAP_ENV = ("DOCKER_CONFIG", *DAEMON_ENV)
30
+
31
+ _logger = get_logger("worker")
32
+
33
+
34
+ def lookup(sessions: async_sessionmaker[AsyncSession]) -> reap.RunLookup:
35
+ """Build the reaper's run lookup against this instance's database."""
36
+
37
+ async def look_up(run_id: UUID) -> reap.RunFact | None:
38
+ async with session_scope(sessions) as session:
39
+ run = await session.get(Run, run_id)
40
+ if run is None:
41
+ return None
42
+ return reap.RunFact(status=run.status.value, active=run.status not in TERMINAL_RUN_STATUSES)
43
+
44
+ return look_up
45
+
46
+
47
+ def environment(root: Path) -> dict[str, str]:
48
+ """The environment a reaping docker invocation runs under: the worker's daemon, and its home.
49
+
50
+ A pass is worker maintenance rather than a step, so it keeps the worker's own ``HOME``
51
+ instead of a scratch one: that is where a docker install that is not system-wide puts the
52
+ compose plugin the teardown runs.
53
+ """
54
+ built = subprocess.environment(list(REAP_ENV), {}, root)
55
+ home = os.environ.get("HOME")
56
+ if home:
57
+ built["HOME"] = home
58
+ return built
59
+
60
+
61
+ def reachable() -> bool:
62
+ """Whether this host has a docker CLI at all, which is the cheapest gate there is."""
63
+ return shutil.which("docker") is not None
64
+
65
+
66
+ async def pass_once(
67
+ settings: Settings, sessions: async_sessionmaker[AsyncSession], *, dry_run: bool = False
68
+ ) -> list[reap.Reaped]:
69
+ """Run one reaping pass against the daemon this host's environment names."""
70
+ with tempfile.TemporaryDirectory(prefix="dirigent-reap-") as home:
71
+ root = Path(home)
72
+ return await reap.reap(
73
+ environment(root),
74
+ root,
75
+ lookup(sessions),
76
+ grace=settings.docker_reap_grace,
77
+ dry_run=dry_run,
78
+ )
79
+
80
+
81
+ def record(reaped: reap.Reaped) -> Mapping[str, object]:
82
+ """The fields one reaped project contributes, whether a record or a log line carries them."""
83
+ fields: dict[str, object] = {
84
+ "project": reaped.project,
85
+ "run_id": str(reaped.run_id),
86
+ "run_status": reaped.run_status,
87
+ "torn_down": reaped.torn_down,
88
+ }
89
+ if reaped.detail:
90
+ fields["detail"] = reaped.detail
91
+ return fields
92
+
93
+
94
+ def outcome(reaped: reap.Reaped, *, dry_run: bool = False) -> str:
95
+ """What happened to one project, in the words a record and a log line both carry."""
96
+ if dry_run:
97
+ return "would reap"
98
+ return "reaped" if reaped.torn_down else "could not reap"
99
+
100
+
101
+ def chore(settings: Settings, sessions: async_sessionmaker[AsyncSession]) -> Chore | None:
102
+ """Build the worker's reaping chore, or none where this worker cannot reach a daemon.
103
+
104
+ A worker with no docker CLI on its PATH has no stacks to reap and no way to reap them, so
105
+ it runs no loop at all rather than one that fails every five minutes.
106
+ """
107
+ interval = settings.docker_reap_interval
108
+ if interval <= timedelta(0) or not reachable():
109
+ return None
110
+
111
+ async def run() -> None:
112
+ for reaped in await pass_once(settings, sessions):
113
+ _logger.info(outcome(reaped), kind="docker_reaped", **record(reaped))
114
+
115
+ return Chore(name="docker-reap", interval=interval, run=run)
@@ -0,0 +1,63 @@
1
+ """Scaffolding for a new block pack: a package, one operator, a passing test."""
2
+
3
+ import re
4
+ from importlib.resources import files
5
+ from pathlib import Path
6
+ from typing import Final, Literal
7
+
8
+ from pydantic import BaseModel
9
+
10
+ #: What a pack may be called: a lowercase identifier fragment, because it becomes half a
11
+ #: module name and an entry-point key.
12
+ NAME_PATTERN: Final = re.compile(r"^[a-z][a-z0-9_]*$")
13
+
14
+
15
+ class ScaffoldRecord(BaseModel):
16
+ """One file the scaffold wrote."""
17
+
18
+ kind: Literal["scaffold"] = "scaffold"
19
+ path: str
20
+
21
+
22
+ class ScaffoldedRecord(BaseModel):
23
+ """The closing record: where the pack is and what to run next."""
24
+
25
+ kind: Literal["scaffolded"] = "scaffolded"
26
+ directory: str
27
+ next: list[str]
28
+
29
+
30
+ class ScaffoldError(Exception):
31
+ """A scaffold that cannot proceed, with the reason as the message."""
32
+
33
+
34
+ def scaffold_pack(parent: Path, name: str) -> list[Path]:
35
+ """Write a new pack under ``parent`` and answer with every file written.
36
+
37
+ The directory is refused when it already exists, so running the command twice cannot
38
+ half-overwrite somebody's edits.
39
+ """
40
+ if not NAME_PATTERN.match(name):
41
+ raise ScaffoldError(f"{name!r} is not a pack name: lowercase letters, digits and _, starting with a letter")
42
+ root = parent / f"dirigent-{name}"
43
+ if root.exists():
44
+ raise ScaffoldError(f"{root} already exists")
45
+ title = name.replace("_", " ").title().replace(" ", "")
46
+ module = root / "src" / f"dirigent_{name}"
47
+ written = {
48
+ root / "pyproject.toml": _template("pyproject.toml.tmpl", name=name, title=title),
49
+ root / "README.md": _template("README.md.tmpl", name=name, title=title),
50
+ module / "__init__.py": _template("__init__.py.tmpl", name=name, title=title),
51
+ module / "py.typed": "",
52
+ module / f"{name}.py": _template("operator.py.tmpl", name=name, title=title),
53
+ root / "tests" / "test_plugin.py": _template("test_plugin.py.tmpl", name=name, title=title),
54
+ }
55
+ for path, text in written.items():
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(text)
58
+ return list(written)
59
+
60
+
61
+ def _template(filename: str, **values: str) -> str:
62
+ """Read one packaged template and fill its placeholders."""
63
+ return (files("dirigent_cli") / "templates" / "pack" / filename).read_text().format(**values)
@@ -0,0 +1,85 @@
1
+ """What the closing ``run`` record of a run's stream carries.
2
+
3
+ A run's stream is written record by record through :mod:`dirigent_core.protocol`; these are
4
+ the shapes of the summaries the last record holds. They are the contract a script filters on,
5
+ and they are also what the end-of-run table and the failure diagnosis are rendered from, so a
6
+ field the rendering needs belongs here rather than in a second query.
7
+ """
8
+
9
+ from uuid import UUID
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from dirigent_common import JsonMap
14
+
15
+
16
+ class StepSummary(BaseModel):
17
+ """What one settled attempt amounted to, as the closing event carries it."""
18
+
19
+ model_config = ConfigDict(frozen=True)
20
+
21
+ step: str
22
+ item: str | None = None
23
+ block: str
24
+ status: str
25
+ depends_on: list[str] = Field(default_factory=list[str])
26
+ """The steps this one waited for, which is why it ran when it did."""
27
+
28
+ warnings: int = 0
29
+ """How many warnings or errors this attempt logged, whatever it settled as."""
30
+
31
+ attempts: int = 1
32
+ """How many attempts this step took to settle."""
33
+
34
+ duration_ms: int | None = None
35
+ output: JsonMap | None = None
36
+ error: str | None = None
37
+ artifact_uri: str | None = None
38
+ artifact_bytes: int | None = None
39
+
40
+
41
+ class FailureSummary(BaseModel):
42
+ """One failed attempt, with the diagnosis a deleted local database cannot be asked for."""
43
+
44
+ model_config = ConfigDict(frozen=True)
45
+
46
+ step: str
47
+ block: str
48
+ attempt: int
49
+ error_class: str | None = None
50
+ error: str | None = None
51
+ logs: list[str] = Field(default_factory=list[str])
52
+ input: JsonMap | None = None
53
+ """What the attempt was given, which is half of why it failed."""
54
+
55
+
56
+ class BackfillWindow(BaseModel):
57
+ """One window a backfill enumerated, as the closing backfill record carries it."""
58
+
59
+ model_config = ConfigDict(frozen=True)
60
+
61
+ window_start: str
62
+ window_end: str
63
+ run_id: str | None = None
64
+ detail: str | None = None
65
+
66
+
67
+ class RunFinished(BaseModel):
68
+ """The run reached a terminal status; the last line of a run's stream."""
69
+
70
+ model_config = ConfigDict(frozen=True)
71
+
72
+ run_id: UUID | None = None
73
+ pipeline: str
74
+ pipeline_version: int | None = None
75
+ status: str
76
+ triggered_by: str | None = None
77
+ duration_ms: int | None = None
78
+ items_total: int = 0
79
+ items_failed: int = 0
80
+ error: str | None = None
81
+ exit_code: int = 0
82
+ steps: list[StepSummary] = Field(default_factory=list[StepSummary])
83
+ failures: list[FailureSummary] = Field(default_factory=list[FailureSummary])
84
+ kept_at: str | None = None
85
+ """Where a ``--keep`` local run left its throwaway instance."""
@@ -0,0 +1,76 @@
1
+ """Where a document comes from: a file, a URL, or standard input."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Final
6
+
7
+ import httpx2
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from dirigent_client.enums import ProvenanceSource
11
+
12
+ STDIN: Final = "-"
13
+ FETCH_TIMEOUT: Final = 30.0
14
+
15
+
16
+ class SourceError(Exception):
17
+ """A document could not be read from where it was said to be."""
18
+
19
+
20
+ class Document(BaseModel):
21
+ """One document's text, and where it came from."""
22
+
23
+ model_config = ConfigDict(frozen=True)
24
+
25
+ text: str
26
+ source: ProvenanceSource
27
+ ref: str
28
+
29
+ @property
30
+ def label(self) -> str:
31
+ """Render the origin for a plan line or an error message."""
32
+ return self.ref
33
+
34
+
35
+ def is_url(reference: str) -> bool:
36
+ """Report whether a reference names a URL rather than a path."""
37
+ return reference.startswith(("http://", "https://"))
38
+
39
+
40
+ def read_document(reference: str) -> Document:
41
+ """Read a document from a file, a URL, or standard input."""
42
+ if reference == STDIN:
43
+ text = sys.stdin.read()
44
+ if not text.strip():
45
+ raise SourceError("nothing arrived on standard input")
46
+ return Document(text=text, source=ProvenanceSource.API, ref="(stdin)")
47
+ if is_url(reference):
48
+ return Document(text=fetch(reference), source=ProvenanceSource.URL, ref=reference)
49
+ path = Path(reference)
50
+ if not path.is_file():
51
+ raise SourceError(f"{reference} is neither a file, a URL, nor '-' for standard input")
52
+ return Document(text=path.read_text(), source=ProvenanceSource.FILE, ref=str(path))
53
+
54
+
55
+ def fetch(url: str) -> str:
56
+ """Fetch a document over HTTP."""
57
+ try:
58
+ response = httpx2.get(url, timeout=FETCH_TIMEOUT, follow_redirects=True)
59
+ response.raise_for_status()
60
+ except httpx2.HTTPError as error:
61
+ raise SourceError(f"{url} could not be fetched: {type(error).__name__}: {error}") from error
62
+ return response.text
63
+
64
+
65
+ def read_path(path: Path) -> Document:
66
+ """Read a document from a path already known to be one, keeping its provenance."""
67
+ return Document(text=path.read_text(), source=ProvenanceSource.FILE, ref=str(path))
68
+
69
+
70
+ def looks_like_a_document(reference: str) -> bool:
71
+ """Report whether an argument names a document rather than a pipeline.
72
+
73
+ Unambiguous only because a pipeline name is a DNS label: it can never contain ``/``,
74
+ ``.``, or a scheme.
75
+ """
76
+ return reference == STDIN or is_url(reference) or Path(reference).is_file()
dirigent_cli/stream.py ADDED
@@ -0,0 +1,180 @@
1
+ """The run's story, as protocol records encoded or rendered for this invocation.
2
+
3
+ One renderer serves all of it: the events a run emits here, and the same events read back
4
+ off a stored stream by ``dg format``. Verbosity decides which events are emitted; it
5
+ never changes the shape of a line.
6
+ """
7
+
8
+ import sys
9
+ from collections.abc import Iterable, Mapping
10
+ from datetime import UTC, datetime
11
+ from typing import Any, Final, cast
12
+
13
+ from rich.markup import escape
14
+ from rich.style import Style
15
+
16
+ from dirigent_core.protocol import Format, Record, make, render
17
+
18
+ #: Supporting text is grey rather than rich's dim attribute, which many terminals render at
19
+ #: too little contrast to read on a dark background.
20
+ MUTED: Final = "grey58"
21
+
22
+ #: Colours a step is tracked by, assigned in document order and reused for every line it
23
+ #: writes. Colour is the fast path for the eye; the fixed column is what makes the stream
24
+ #: readable where there is no colour at all.
25
+ STEP_COLOURS: Final[tuple[str, ...]] = (
26
+ "cyan",
27
+ "magenta",
28
+ "green",
29
+ "yellow",
30
+ "blue",
31
+ "bright_cyan",
32
+ "bright_magenta",
33
+ )
34
+
35
+ #: How each role of a console line is coloured. A level that is routine is left unmarked:
36
+ #: marking every line is the noise that marking exists to cut through.
37
+ ROLES: Final[Mapping[str, str]] = {
38
+ "time": MUTED,
39
+ "kind": MUTED,
40
+ "key": "cyan",
41
+ "step.": "blue",
42
+ "level.info": "green",
43
+ "level.warning": "bold yellow",
44
+ "level.error": "bold red",
45
+ "level.critical": "bold red",
46
+ "level.debug": MUTED,
47
+ }
48
+
49
+ _step_colours: dict[str, str] = {}
50
+
51
+ _scratch: str | None = None
52
+
53
+
54
+ def track_steps(names: Iterable[str]) -> None:
55
+ """Give each step a colour, in the order the document wrote them.
56
+
57
+ Concurrent steps interleave, so a reader following one step down the page has only its
58
+ name to go on; a stable colour makes that one glance instead of one search.
59
+ """
60
+ _step_colours.clear()
61
+ for index, name in enumerate(names):
62
+ _step_colours[name] = STEP_COLOURS[index % len(STEP_COLOURS)]
63
+
64
+
65
+ def use_scratch_prefix(prefix: str | None) -> None:
66
+ """Name the prefix this run's URIs share, so the console rendering can leave it out.
67
+
68
+ The prefix is stated once, on the run's own opening event, which is what makes the
69
+ shorter form a spelling rather than a truncation: it round-trips.
70
+ """
71
+ global _scratch # noqa: PLW0603 - one process-wide run context, set once per invocation
72
+ _scratch = prefix.rstrip("/") if prefix else None
73
+
74
+
75
+ def paint(role: str, text: str) -> str:
76
+ """Colour one part of a console line, escaping it so rich reads none of it as markup."""
77
+ style = ROLES.get(role)
78
+ if style is None and role.startswith("step."):
79
+ # A stored stream has no tracked colours: dg format never saw the document.
80
+ style = _step_colours.get(role.removeprefix("step.")) or ROLES.get("step.")
81
+ safe = escape(text)
82
+ return f"[{style}]{safe}[/]" if style else safe
83
+
84
+
85
+ def ansi(role: str, text: str) -> str:
86
+ """Colour one part of a line for a stream that is written to rather than printed.
87
+
88
+ The logging handler writes to its stream directly, so rich markup would arrive as the
89
+ literal brackets that spell it. The roles and their colours are the ones the rendered
90
+ stream uses, resolved to escape sequences here.
91
+ """
92
+ style = _style_for(role)
93
+ if style is None or not _colouring():
94
+ return text
95
+ return Style.parse(style).render(text)
96
+
97
+
98
+ def _style_for(role: str) -> str | None:
99
+ """Resolve one role to its style, tracked colours included."""
100
+ style = ROLES.get(role)
101
+ if style is None and role.startswith("step."):
102
+ style = _step_colours.get(role.removeprefix("step.")) or ROLES.get("step.")
103
+ return style
104
+
105
+
106
+ def _colouring() -> bool:
107
+ """Report whether the diagnostic stream may be sent colour at all."""
108
+ from dirigent_cli.output import error_console
109
+
110
+ return not error_console.no_color and error_console.is_terminal
111
+
112
+
113
+ def relative(value: str) -> str:
114
+ """Spell one URI relative to the prefix this run's URIs share, when it is under it."""
115
+ return str(_relative(value))
116
+
117
+
118
+ def shorten(record: Record) -> Record:
119
+ """Spell this run's own URIs relative to the prefix they all share.
120
+
121
+ Rendering only, and only where the prefix was already stated: two URIs that differ must
122
+ still read differently, so nothing else about a value is touched.
123
+ """
124
+ if _scratch is None:
125
+ return record
126
+ return {name: _relative(value) for name, value in record.items()}
127
+
128
+
129
+ def _relative(value: Any) -> Any:
130
+ """Drop the shared scratch prefix from a URI, leaving every other value alone."""
131
+ if isinstance(value, Mapping):
132
+ nested = cast("Mapping[str, Any]", value)
133
+ return {name: _relative(item) for name, item in nested.items()}
134
+ if isinstance(value, str) and _scratch is not None and value.startswith(f"{_scratch}/"):
135
+ return value[len(_scratch) + 1 :]
136
+ return value
137
+
138
+
139
+ class Sink:
140
+ """Where a run's story goes: stdout, in one output, for the whole invocation."""
141
+
142
+ def __init__(self, output: Format = "console") -> None:
143
+ """Fix the output this invocation writes."""
144
+ self.output: Format = output
145
+
146
+ @property
147
+ def rendered(self) -> bool:
148
+ """Report whether this invocation is being read by a person."""
149
+ return self.output == "console"
150
+
151
+ def write(self, record: Record) -> None:
152
+ """Write one record and flush it, so a reader sees it as it happens.
153
+
154
+ A rendered invocation goes through the same formatter ``dg format`` uses, so a run
155
+ watched live and the same run read back off a file look the same.
156
+ """
157
+ if self.rendered:
158
+ _emit(_formatter().render(record))
159
+ else:
160
+ sys.stdout.write(render(record, self.output) + "\n")
161
+ sys.stdout.flush()
162
+
163
+ def event(self, kind: str, /, **kwargs: Any) -> None:
164
+ """Build one record and write it, stamped now when the event carries no moment."""
165
+ kwargs.setdefault("at", datetime.now(UTC))
166
+ self.write(make(kind, **kwargs))
167
+
168
+
169
+ def _emit(item: Any) -> None:
170
+ """Print one rendered record, reaching the shared console late to avoid a cycle."""
171
+ from dirigent_cli.output import emit_rendered
172
+
173
+ emit_rendered(item)
174
+
175
+
176
+ def _formatter() -> Any:
177
+ """Reach the console formatter late: it imports this module for the painting."""
178
+ from dirigent_cli.formatters import Console
179
+
180
+ return Console()