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,136 @@
1
+ """The state every server-talking command shares: which server, and with what token."""
2
+
3
+ import logging
4
+ import os
5
+ from collections.abc import Awaitable
6
+ from functools import cached_property
7
+
8
+ import typer
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from dirigent_cli.output import (
12
+ FULL_VERBOSITY,
13
+ Detail,
14
+ configure,
15
+ refuse,
16
+ write_refusal,
17
+ )
18
+ from dirigent_cli.profiles import Endpoint, ProfileError, resolve_endpoint
19
+ from dirigent_cli.stream import ansi
20
+ from dirigent_client import BlockingDirigent, DirigentError
21
+ from dirigent_core.logging import NOISY_LOGGERS, configure_logging
22
+ from dirigent_core.protocol import Format
23
+
24
+ VERBOSITY: dict[int, str] = {0: "WARNING", 1: "INFO"}
25
+
26
+ LOG_LEVEL_ENV = "DIRIGENT_LOG_LEVEL"
27
+
28
+
29
+ class CliState(BaseModel):
30
+ """The addressing flags, how loud to be, and the endpoint they resolve to."""
31
+
32
+ model_config = ConfigDict(frozen=True, ignored_types=(cached_property,))
33
+
34
+ url: str | None = None
35
+ token: str | None = None
36
+ profile: str | None = None
37
+ verbose: int = 0
38
+ debug: bool = False
39
+ debug_all: bool = False
40
+ output: Format = "json"
41
+
42
+ chosen: bool = False
43
+ """Whether a flag or the environment named the output, rather than it being the default.
44
+
45
+ A command run once by a person -- ``dg init`` -- renders unless it was asked for records,
46
+ and it can only tell the difference if the default is distinguishable from a choice.
47
+ """
48
+
49
+ @property
50
+ def json_output(self) -> bool:
51
+ """Report whether this invocation writes records rather than a rendering."""
52
+ return self.output != "console"
53
+
54
+ @property
55
+ def detail(self) -> Detail:
56
+ """Resolve how much this invocation emits, and how much of a value it shows.
57
+
58
+ Verbosity decides it, and the output does not: the same flags produce the same
59
+ records whether they are written as NDJSON or rendered, which is what makes
60
+ ``dg run | dg format`` the same thing as ``dg run -o console``.
61
+ """
62
+ if self.debug or self.debug_all or self.verbose >= FULL_VERBOSITY:
63
+ return Detail.FULL
64
+ return Detail.VALUES if self.verbose else Detail.SUMMARY
65
+
66
+ @property
67
+ def level(self) -> str:
68
+ """Resolve how loud to be: the flags first, then the environment, then quiet.
69
+
70
+ ``--debug-all`` names a level as well as a cap: a flag that only lifted the caps
71
+ would print nothing at all on its own.
72
+ """
73
+ if self.debug or self.debug_all:
74
+ return "DEBUG"
75
+ if self.verbose:
76
+ return VERBOSITY.get(self.verbose, "DEBUG")
77
+ return os.environ.get(LOG_LEVEL_ENV, VERBOSITY[0]).upper()
78
+
79
+ @property
80
+ def floors(self) -> dict[str, int]:
81
+ """Cap the libraries whose debug output would drown dirigent's own."""
82
+ if self.debug_all:
83
+ return {}
84
+ if self.level == "DEBUG":
85
+ return dict.fromkeys(NOISY_LOGGERS, logging.WARNING)
86
+ return dict(NOISY_LOGGERS)
87
+
88
+ def configure_output(self) -> None:
89
+ """Point process logging at the level this invocation asked for, and pick the medium."""
90
+ configure(output=self.output, detail=self.detail)
91
+ configure_logging(
92
+ self.level,
93
+ "console",
94
+ floors=self.floors,
95
+ cap_foreign=not self.debug_all,
96
+ paint=ansi,
97
+ )
98
+
99
+ @cached_property
100
+ def resolved(self) -> Endpoint:
101
+ """Resolve the endpoint once: flags first, then DG_*, then the selected profile."""
102
+ return self.endpoint()
103
+
104
+ def endpoint(self, *, needs_token: bool = True) -> Endpoint:
105
+ """Resolve the endpoint, refusing a profile without a token unless told not to."""
106
+ try:
107
+ return resolve_endpoint(url=self.url, token=self.token, profile=self.profile, needs_token=needs_token)
108
+ except ProfileError as error:
109
+ raise typer.BadParameter(str(error)) from error
110
+
111
+
112
+ def state_of(ctx: typer.Context) -> CliState:
113
+ """Read the shared state off the Typer context, defaulting when there is none."""
114
+ found = ctx.find_object(CliState)
115
+ return found if found is not None else CliState()
116
+
117
+
118
+ class Session(BlockingDirigent):
119
+ """The SDK, driven from the CLI's synchronous commands."""
120
+
121
+ def call[T](self, awaitable: Awaitable[T]) -> T:
122
+ """Run one API call, turning a refusal into a printed error and a non-zero exit."""
123
+ try:
124
+ return super().call(awaitable)
125
+ except DirigentError as error:
126
+ if error.problem is not None:
127
+ write_refusal(error.problem)
128
+ else:
129
+ refuse(error.message, status=error.status or 1)
130
+ raise typer.Exit(code=1) from error
131
+
132
+
133
+ def client_for(state: CliState, *, needs_token: bool = True) -> Session:
134
+ """Build the API client for a resolved endpoint."""
135
+ endpoint = state.endpoint(needs_token=needs_token)
136
+ return Session(url=endpoint.url, token=endpoint.token, api_prefix=endpoint.api_prefix)
@@ -0,0 +1,158 @@
1
+ """The formatters ``dg format`` dispatches on: a name, a version, and one render method.
2
+
3
+ The registry holds the built-ins and whatever a plugin in the ``dirigent.formatters`` entry
4
+ point group contributes, so a third party ships a formatter without touching the CLI. A
5
+ formatter renders a record kind it has never heard of rather than failing, because a record
6
+ from a newer dirigent or from a plugin's own event still has to read.
7
+ """
8
+
9
+ import re
10
+ from collections.abc import Iterable, Iterator, Mapping
11
+ from typing import Final, cast
12
+
13
+ from pluginkit import PluginManager
14
+ from rich.console import Group, RenderableType
15
+
16
+ from dirigent_cli import summaries
17
+ from dirigent_cli.stream import paint, shorten, use_scratch_prefix
18
+ from dirigent_common import Formatter
19
+ from dirigent_core.protocol import Record, console, parse
20
+ from dirigent_plugin import PROJECT_NAME, markers
21
+
22
+ #: Where a third party registers a formatter of its own.
23
+ ENTRY_POINT_GROUP: Final = "dirigent.formatters"
24
+
25
+ #: The formatter an omitted positional means.
26
+ DEFAULT: Final = "console"
27
+
28
+ #: The owner recorded for the built-in names, which a plugin may not claim either.
29
+ BUILT_IN: Final = "dirigent-cli"
30
+
31
+
32
+ class DuplicateFormatter(Exception):
33
+ """Two plugins claimed the same formatter name."""
34
+
35
+ def __init__(self, name: str, first: str, second: str) -> None:
36
+ """Name the formatter and both plugins."""
37
+ super().__init__(
38
+ f"formatter {name!r} is contributed by both {first!r} and {second!r}, and a formatter name is "
39
+ f"what `dg format` dispatches on, so one of the two packages must be uninstalled or renamed."
40
+ )
41
+ self.name = name
42
+ self.plugins = (first, second)
43
+
44
+
45
+ class Console:
46
+ """The fixed-grammar rendering: padded columns, then every field the record carries.
47
+
48
+ Columns are padded and never cut, so a value wider than its column pushes the line out
49
+ rather than losing the half that says which one it is.
50
+ """
51
+
52
+ name = "console"
53
+ version = "1"
54
+
55
+ def render(self, record: Record) -> RenderableType:
56
+ """Render one record as a padded line, plus whatever its kind renders beneath."""
57
+ return _with_summary(record, console(shorten(summaries.line(record)), paint=paint))
58
+
59
+
60
+ class Compact:
61
+ """The same grammar with no column padding.
62
+
63
+ Every field still goes out whole: this trades the columns a wide terminal can afford,
64
+ not any of the record's content.
65
+ """
66
+
67
+ name = "compact"
68
+ version = "1"
69
+
70
+ def render(self, record: Record) -> str:
71
+ """Render one record as an unpadded line, whole.
72
+
73
+ What ``console`` renders as a table beneath a line stays on the line here: a table
74
+ is column padding, and this formatter spends none. One record is still one line.
75
+ """
76
+ return console(shorten(record), paint=paint, pad=False)
77
+
78
+
79
+ def _with_summary(record: Record, line: str) -> RenderableType:
80
+ """Put a kind's own rendering under its line, where the kind has one.
81
+
82
+ The line keeps the whole-line spelling it has on its own: a record's line is not cut or
83
+ folded to a terminal's width, whatever is rendered beneath it.
84
+ """
85
+ beneath = summaries.beneath(record)
86
+ return line if beneath is None else Group(line, beneath)
87
+
88
+
89
+ #: What a template looks like, so the habit `docker --format` teaches gets a real answer.
90
+ TEMPLATE = re.compile(r"\{\{.*\}\}")
91
+
92
+
93
+ def looks_like_a_template(named: str) -> bool:
94
+ """Report whether this argument is a template rather than a formatter's name."""
95
+ return bool(TEMPLATE.search(named))
96
+
97
+
98
+ def registry(
99
+ *,
100
+ group: str = ENTRY_POINT_GROUP,
101
+ extra: Mapping[str, object] | None = None,
102
+ ) -> dict[str, Formatter]:
103
+ """Build the registry: the built-ins, then whatever an installed plugin contributes.
104
+
105
+ ``extra`` registers plugin objects that are not installed as distributions.
106
+ """
107
+ found: dict[str, Formatter] = {}
108
+ origins: dict[str, str] = {}
109
+ for built_in in (Console(), Compact()):
110
+ found[built_in.name] = built_in
111
+ origins[built_in.name] = BUILT_IN
112
+ manager = PluginManager(PROJECT_NAME)
113
+ manager.add_extension_points(markers)
114
+ manager.load_entrypoints(group)
115
+ for name, plugin in (extra or {}).items():
116
+ manager.register(plugin, name=name)
117
+ # The hook's return annotation is a declaration, not an enforcement: a plugin may answer
118
+ # with anything, and anything that is not a formatter is not registered.
119
+ for plugin_name, contributed in manager.caller(markers.formatters).collect_with_plugins():
120
+ for formatter in contributed:
121
+ if not isinstance(formatter, Formatter): # pyright: ignore[reportUnnecessaryIsInstance]
122
+ continue
123
+ owner = origins.get(formatter.name)
124
+ if owner is not None:
125
+ raise DuplicateFormatter(formatter.name, owner, plugin_name)
126
+ origins[formatter.name] = plugin_name
127
+ found[formatter.name] = formatter
128
+ return found
129
+
130
+
131
+ def names() -> tuple[str, ...]:
132
+ """List the registered formatter names, the default first."""
133
+ registered = registry()
134
+ rest = sorted(name for name in registered if name != DEFAULT)
135
+ return (DEFAULT, *rest) if DEFAULT in registered else tuple(rest)
136
+
137
+
138
+ def rendered(lines: Iterable[str], formatter: Formatter) -> Iterator[RenderableType]:
139
+ """Render a stored stream, passing through anything that is not a record."""
140
+ for line in lines:
141
+ text = line.rstrip("\n")
142
+ record = parse(text)
143
+ if record is None:
144
+ yield text
145
+ continue
146
+ _adopt_scratch(record)
147
+ # The protocol leaves the answer open; what this prints it with is rich's console.
148
+ yield cast("RenderableType", formatter.render(record))
149
+
150
+
151
+ def _adopt_scratch(record: Record) -> None:
152
+ """Take the prefix a run's URIs share off the run's own opening record.
153
+
154
+ The live rendering is told the prefix by the run; a stream read back has only what the
155
+ stream says, and the run states it once so that the shorter spelling round-trips.
156
+ """
157
+ if record.get("kind") == "run" and isinstance(record.get("scratch"), str):
158
+ use_scratch_prefix(str(record["scratch"]))
dirigent_cli/graph.py ADDED
@@ -0,0 +1,109 @@
1
+ """A pipeline's shape as an indented tree: what runs first, and what waits for what."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any, NamedTuple, cast
5
+
6
+ #: The rule a step has unless it says otherwise, and so the one not worth printing.
7
+ DEFAULT_RULE = "all_success"
8
+
9
+ INDENT = " "
10
+
11
+
12
+ class GraphStep(NamedTuple):
13
+ """One step as the tree draws it."""
14
+
15
+ name: str
16
+ block: str
17
+ depends_on: tuple[str, ...] = ()
18
+ rule: str = DEFAULT_RULE
19
+
20
+
21
+ def steps_of_document(document: Mapping[str, Any]) -> list[GraphStep]:
22
+ """Read the steps out of a ``dirigent/v1`` document, in the order it wrote them."""
23
+ steps = document.get("steps")
24
+ if not isinstance(steps, dict):
25
+ return []
26
+ read: list[GraphStep] = []
27
+ for name, body in cast("dict[str, Any]", steps).items():
28
+ held = cast("dict[str, Any]", body) if isinstance(body, dict) else {}
29
+ depends = held.get("depends_on")
30
+ read.append(
31
+ GraphStep(
32
+ name=str(name),
33
+ block=str(held.get("block", "-")),
34
+ depends_on=tuple(str(item) for item in cast("list[Any]", depends or ())),
35
+ rule=str(held.get("rule", DEFAULT_RULE)),
36
+ )
37
+ )
38
+ return read
39
+
40
+
41
+ def render_graph(steps: Sequence[GraphStep]) -> list[str]:
42
+ """Draw the graph as indented lines, each step under the last step it waits for.
43
+
44
+ A step with several dependencies is drawn once, under the last of them, and names the
45
+ others rather than appearing twice: one step is one line, whatever its in-degree.
46
+ """
47
+ ordered = _in_order(steps)
48
+ position = {step.name: index for index, step in enumerate(ordered)}
49
+ known = set(position)
50
+ parents = {step.name: _parent(step, position) for step in ordered}
51
+ children: dict[str | None, list[GraphStep]] = {}
52
+ for step in ordered:
53
+ children.setdefault(parents[step.name], []).append(step)
54
+
55
+ lines: list[str] = []
56
+ drawn: set[str] = set()
57
+
58
+ def draw(step: GraphStep, depth: int) -> None:
59
+ """Write one step's line, then the steps that wait on it."""
60
+ if step.name in drawn:
61
+ return
62
+ drawn.add(step.name)
63
+ lines.append(INDENT * depth + _line(step, parents[step.name], known))
64
+ for child in children.get(step.name, []):
65
+ draw(child, depth + 1)
66
+
67
+ for root in children.get(None, []):
68
+ draw(root, 0)
69
+ # A cycle has no root to reach it from, and a step left undrawn is worse than one drawn
70
+ # at the margin.
71
+ for step in ordered:
72
+ draw(step, 0)
73
+ return lines
74
+
75
+
76
+ def _line(step: GraphStep, parent: str | None, known: set[str]) -> str:
77
+ """Render one step: its name, its block, and whatever the tree cannot show."""
78
+ rendered = f"{step.name} ({step.block})"
79
+ others = [name for name in step.depends_on if name != parent and name in known]
80
+ if others:
81
+ rendered += f" also after {', '.join(others)}"
82
+ if step.rule != DEFAULT_RULE:
83
+ rendered += f" when {step.rule}"
84
+ return rendered
85
+
86
+
87
+ def _parent(step: GraphStep, position: Mapping[str, int]) -> str | None:
88
+ """The dependency a step is drawn under: the last one to run, or nothing for a root."""
89
+ known = [name for name in step.depends_on if name in position]
90
+ return max(known, key=lambda name: position[name]) if known else None
91
+
92
+
93
+ def _in_order(steps: Sequence[GraphStep]) -> list[GraphStep]:
94
+ """Sort the steps so nothing precedes what it waits for, keeping the written order otherwise."""
95
+ known = {step.name for step in steps}
96
+ waiting = list(steps)
97
+ placed: list[GraphStep] = []
98
+ settled: set[str] = set()
99
+ while waiting:
100
+ ready = [step for step in waiting if all(name in settled or name not in known for name in step.depends_on)]
101
+ if not ready:
102
+ # A cycle, which validation refuses: draw the rest as written rather than hiding it.
103
+ placed.extend(waiting)
104
+ break
105
+ placed.extend(ready)
106
+ settled.update(step.name for step in ready)
107
+ names = {step.name for step in ready}
108
+ waiting = [step for step in waiting if step.name not in names]
109
+ return placed