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/__init__.py +5 -0
- dirigent_cli/aliases.py +41 -0
- dirigent_cli/commands.py +2525 -0
- dirigent_cli/context.py +136 -0
- dirigent_cli/formatters.py +158 -0
- dirigent_cli/graph.py +109 -0
- dirigent_cli/health.py +294 -0
- dirigent_cli/local.py +790 -0
- dirigent_cli/main.py +1169 -0
- dirigent_cli/output.py +543 -0
- dirigent_cli/params.py +389 -0
- dirigent_cli/profiles.py +221 -0
- dirigent_cli/project.py +643 -0
- dirigent_cli/py.typed +0 -0
- dirigent_cli/reaper.py +115 -0
- dirigent_cli/scaffold.py +63 -0
- dirigent_cli/schemas.py +85 -0
- dirigent_cli/sources.py +76 -0
- dirigent_cli/stream.py +180 -0
- dirigent_cli/summaries.py +420 -0
- dirigent_cli/templates/pack/README.md.tmpl +23 -0
- dirigent_cli/templates/pack/__init__.py.tmpl +24 -0
- dirigent_cli/templates/pack/operator.py.tmpl +34 -0
- dirigent_cli/templates/pack/pyproject.toml.tmpl +21 -0
- dirigent_cli/templates/pack/test_plugin.py.tmpl +21 -0
- dirigent_cli/timing.py +322 -0
- dirigent_cli/triggers.py +631 -0
- dirigent_cli-0.9.0.dist-info/METADATA +24 -0
- dirigent_cli-0.9.0.dist-info/RECORD +32 -0
- dirigent_cli-0.9.0.dist-info/WHEEL +4 -0
- dirigent_cli-0.9.0.dist-info/entry_points.txt +4 -0
- dirigent_cli-0.9.0.dist-info/licenses/LICENSE +18 -0
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)
|
dirigent_cli/scaffold.py
ADDED
|
@@ -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)
|
dirigent_cli/schemas.py
ADDED
|
@@ -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."""
|
dirigent_cli/sources.py
ADDED
|
@@ -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()
|