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/context.py
ADDED
|
@@ -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
|