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/params.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Building a run's parameters from the command line, against the pipeline's own schema.
|
|
2
|
+
|
|
3
|
+
Precedence is schema defaults, then ``-P`` files in the order given, then ``-p`` flags in
|
|
4
|
+
the order given. Objects deep-merge; arrays and scalars replace, except that a bracketed
|
|
5
|
+
index in a ``-p`` key sets one element of an array in place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Final, cast
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from dirigent_common import JsonMap
|
|
16
|
+
from dirigent_core.documents import safe_load
|
|
17
|
+
|
|
18
|
+
TRUE_WORDS: Final = frozenset({"true", "yes", "y", "on", "1"})
|
|
19
|
+
FALSE_WORDS: Final = frozenset({"false", "no", "n", "off", "0"})
|
|
20
|
+
|
|
21
|
+
STRUCTURED: Final = frozenset({"object", "array"})
|
|
22
|
+
|
|
23
|
+
#: What ``[]`` addresses: one past the last element, wherever the array happens to end.
|
|
24
|
+
APPEND: Final = -1
|
|
25
|
+
|
|
26
|
+
#: One dotted segment: a name, then any number of ``[index]`` or ``[]`` brackets.
|
|
27
|
+
SEGMENT: Final = re.compile(r"(?P<name>[^\[\]]*)(?P<indices>(?:\[[^\[\]]*\])*)")
|
|
28
|
+
|
|
29
|
+
INDEX: Final = re.compile(r"\[([^\[\]]*)\]")
|
|
30
|
+
|
|
31
|
+
#: A step of a parsed key: an object key, an array index, or ``APPEND``.
|
|
32
|
+
type Step = str | int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ParamError(Exception):
|
|
36
|
+
"""A parameter could not be read, addressed, or coerced; the message says which."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_pair(pair: str) -> tuple[str, str]:
|
|
40
|
+
"""Split one ``key=value`` argument, refusing anything that is not one."""
|
|
41
|
+
if "=" not in pair:
|
|
42
|
+
raise ParamError(f"-p expects key=value, not {pair!r}")
|
|
43
|
+
key, _, value = pair.partition("=")
|
|
44
|
+
if not key:
|
|
45
|
+
raise ParamError(f"-p expects key=value, not {pair!r}")
|
|
46
|
+
return key, value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def split_key(key: str) -> list[Step]:
|
|
50
|
+
"""Split a key into its path of object keys and array indices."""
|
|
51
|
+
if not key:
|
|
52
|
+
raise ParamError(f"-p {key}: a dotted key names each level, so it may not have an empty segment")
|
|
53
|
+
path: list[Step] = []
|
|
54
|
+
for segment in key.split("."):
|
|
55
|
+
match = SEGMENT.fullmatch(segment)
|
|
56
|
+
if match is None:
|
|
57
|
+
raise ParamError(f"-p {key}: {segment!r} is not a name followed by [index] brackets")
|
|
58
|
+
name = match["name"]
|
|
59
|
+
if not name:
|
|
60
|
+
raise ParamError(f"-p {key}: a dotted key names each level, so it may not have an empty segment")
|
|
61
|
+
if name.isdigit():
|
|
62
|
+
raise ParamError(
|
|
63
|
+
f"-p {key}: a dotted path cannot tell the index {name!r} from an object key named {name!r}. "
|
|
64
|
+
f"Address an element with brackets instead ({path[-1] if path else 'name'}[{name}]=...)"
|
|
65
|
+
)
|
|
66
|
+
path.append(name)
|
|
67
|
+
path.extend(_index(raw, key) for raw in INDEX.findall(match["indices"]))
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _index(raw: str, key: str) -> int:
|
|
72
|
+
"""Read what one pair of brackets addresses: an element, or the end of the array."""
|
|
73
|
+
if not raw:
|
|
74
|
+
return APPEND
|
|
75
|
+
if not raw.isdigit():
|
|
76
|
+
if raw.lstrip("-").isdigit():
|
|
77
|
+
raise ParamError(f"-p {key}: an index counts from the start of the array, so {raw!r} is not one")
|
|
78
|
+
raise ParamError(f"-p {key}: an index is a whole number or nothing at all, not {raw!r}")
|
|
79
|
+
return int(raw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_path(path: Sequence[Step]) -> str:
|
|
83
|
+
"""Render a parsed path the way it is written on the command line."""
|
|
84
|
+
written = ""
|
|
85
|
+
for step in path:
|
|
86
|
+
if isinstance(step, str):
|
|
87
|
+
written = f"{written}.{step}" if written else step
|
|
88
|
+
else:
|
|
89
|
+
written += "[]" if step == APPEND else f"[{step}]"
|
|
90
|
+
return written
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _article(word: str) -> str:
|
|
94
|
+
"""Prefix a schema's word with the article that reads."""
|
|
95
|
+
return f"an {word}" if word[:1] in "aeiou" else f"a {word}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _holds(value: Any) -> str:
|
|
99
|
+
"""Name what a value already at a path is, for a message about addressing it wrongly."""
|
|
100
|
+
match value:
|
|
101
|
+
case bool():
|
|
102
|
+
return "a boolean"
|
|
103
|
+
case dict():
|
|
104
|
+
return "an object"
|
|
105
|
+
case list():
|
|
106
|
+
return "an array"
|
|
107
|
+
case int() | float():
|
|
108
|
+
return "a number"
|
|
109
|
+
case str():
|
|
110
|
+
return "a string"
|
|
111
|
+
case _:
|
|
112
|
+
return "a scalar"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_params_file(path: Path) -> JsonMap:
|
|
116
|
+
"""Read a whole parameter payload from a YAML or JSON file."""
|
|
117
|
+
try:
|
|
118
|
+
loaded = safe_load(path.read_text())
|
|
119
|
+
except (OSError, yaml.YAMLError) as error:
|
|
120
|
+
raise ParamError(f"{path} could not be read: {error}") from error
|
|
121
|
+
if loaded is None:
|
|
122
|
+
return {}
|
|
123
|
+
if not isinstance(loaded, dict):
|
|
124
|
+
raise ParamError(f"{path} should hold a mapping of parameters, not {type(loaded).__name__}")
|
|
125
|
+
return cast("JsonMap", loaded)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _schema_at(schema: JsonMap, path: Sequence[Step], where: str) -> JsonMap | None:
|
|
129
|
+
"""Resolve the subschema a path addresses, or say the path is not declared.
|
|
130
|
+
|
|
131
|
+
``where`` names the channel a value arrived on -- a flag or a file -- so both are refused
|
|
132
|
+
by this one walk rather than by two rules that can drift apart.
|
|
133
|
+
"""
|
|
134
|
+
current: JsonMap | None = schema
|
|
135
|
+
walked: list[Step] = []
|
|
136
|
+
for step in path:
|
|
137
|
+
if current is None:
|
|
138
|
+
return None
|
|
139
|
+
declared = _type_of(current)
|
|
140
|
+
if isinstance(step, int):
|
|
141
|
+
if declared is not None and declared != "array":
|
|
142
|
+
raise ParamError(
|
|
143
|
+
f"{where}: the schema calls {render_path(walked)!r} {_article(declared)}, "
|
|
144
|
+
f"so it takes a key, not an index"
|
|
145
|
+
)
|
|
146
|
+
items: object = current.get("items")
|
|
147
|
+
current = cast("JsonMap", items) if isinstance(items, dict) else None
|
|
148
|
+
walked.append(step)
|
|
149
|
+
continue
|
|
150
|
+
if declared == "array":
|
|
151
|
+
raise ParamError(
|
|
152
|
+
f"{where}: the schema calls {render_path(walked)!r} an array, "
|
|
153
|
+
f"so it takes an index, not the key {step!r}"
|
|
154
|
+
)
|
|
155
|
+
raw: object = current.get("properties")
|
|
156
|
+
properties = cast("JsonMap", raw) if isinstance(raw, dict) else None
|
|
157
|
+
found = properties.get(step) if properties is not None else None
|
|
158
|
+
if found is None:
|
|
159
|
+
if _is_open(current, properties):
|
|
160
|
+
return None
|
|
161
|
+
location = render_path([*walked, step])
|
|
162
|
+
known = _known(current)
|
|
163
|
+
raise ParamError(f"{where}: the pipeline declares no parameter {location!r} ({known})")
|
|
164
|
+
current = cast("JsonMap", found)
|
|
165
|
+
walked.append(step)
|
|
166
|
+
return current
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_open(schema: JsonMap, properties: JsonMap | None) -> bool:
|
|
170
|
+
"""Report whether a schema level accepts a name it does not list.
|
|
171
|
+
|
|
172
|
+
Stricter than JSON Schema, which allows extra properties unless told otherwise: a
|
|
173
|
+
level that lists any property is treated as closed unless it also says
|
|
174
|
+
``additionalProperties``.
|
|
175
|
+
"""
|
|
176
|
+
if not properties:
|
|
177
|
+
return True
|
|
178
|
+
extra = schema.get("additionalProperties")
|
|
179
|
+
return extra is True or isinstance(extra, dict)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _known(schema: JsonMap) -> str:
|
|
183
|
+
"""Render what a schema level does declare, for an error naming an unknown path."""
|
|
184
|
+
properties: object = schema.get("properties")
|
|
185
|
+
if not isinstance(properties, dict):
|
|
186
|
+
return "it declares no parameters"
|
|
187
|
+
names = ", ".join(sorted(cast("Mapping[str, object]", properties)))
|
|
188
|
+
return f"it declares {names}" if names else "it declares no parameters"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def coerce(value: str, schema: JsonMap | None, key: str) -> Any:
|
|
192
|
+
"""Turn one command-line string into the value the schema says belongs at that path."""
|
|
193
|
+
declared = _type_of(schema)
|
|
194
|
+
if declared in STRUCTURED:
|
|
195
|
+
return _structured(value, declared, key)
|
|
196
|
+
match declared:
|
|
197
|
+
case "integer":
|
|
198
|
+
return _integer(value, key)
|
|
199
|
+
case "number":
|
|
200
|
+
return _number(value, key)
|
|
201
|
+
case "boolean":
|
|
202
|
+
return _boolean(value, key)
|
|
203
|
+
case "string":
|
|
204
|
+
return _checked_enum(value, schema, key)
|
|
205
|
+
case _:
|
|
206
|
+
parsed: Any = safe_load(value) if value else value
|
|
207
|
+
return _checked_enum(parsed, schema, key)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _type_of(schema: JsonMap | None) -> str | None:
|
|
211
|
+
"""Read the type a subschema declares, tolerating the list form, ``anyOf`` and enum-only.
|
|
212
|
+
|
|
213
|
+
An optional field is spelled ``anyOf: [{type: string}, {type: null}]`` by the generator
|
|
214
|
+
every connection kind's schema comes from, and a branch left unread there falls through to
|
|
215
|
+
YAML, where ``#ops`` is a comment rather than a channel.
|
|
216
|
+
"""
|
|
217
|
+
if schema is None:
|
|
218
|
+
return None
|
|
219
|
+
declared = schema.get("type")
|
|
220
|
+
if isinstance(declared, str):
|
|
221
|
+
return declared
|
|
222
|
+
if isinstance(declared, list):
|
|
223
|
+
strings = [item for item in cast("list[object]", declared) if isinstance(item, str) and item != "null"]
|
|
224
|
+
return strings[0] if strings else None
|
|
225
|
+
if isinstance(schema.get("anyOf"), list):
|
|
226
|
+
for branch in cast("list[object]", schema["anyOf"]):
|
|
227
|
+
if not isinstance(branch, dict):
|
|
228
|
+
continue
|
|
229
|
+
found = _type_of(cast("JsonMap", branch))
|
|
230
|
+
if found is not None and found != "null":
|
|
231
|
+
return found
|
|
232
|
+
if isinstance(schema.get("enum"), list):
|
|
233
|
+
members = cast("list[object]", schema["enum"])
|
|
234
|
+
return "string" if all(isinstance(item, str) for item in members) else None
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _structured(value: str, declared: str, key: str) -> Any:
|
|
239
|
+
"""Read an inline object or array, in either JSON or YAML."""
|
|
240
|
+
try:
|
|
241
|
+
parsed = safe_load(value)
|
|
242
|
+
except yaml.YAMLError as error:
|
|
243
|
+
raise ParamError(f"-p {key}: {value!r} is not valid JSON or YAML ({error})") from error
|
|
244
|
+
if declared == "array" and not isinstance(parsed, list):
|
|
245
|
+
raise ParamError(f"-p {key}: this parameter is an array, and {value!r} is not one")
|
|
246
|
+
if declared == "object" and not isinstance(parsed, dict):
|
|
247
|
+
raise ParamError(f"-p {key}: this parameter is an object, and {value!r} is not one")
|
|
248
|
+
return cast("Any", parsed)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _integer(value: str, key: str) -> int:
|
|
252
|
+
"""Read an integer, refusing anything that only looks like one."""
|
|
253
|
+
try:
|
|
254
|
+
return int(value)
|
|
255
|
+
except ValueError as error:
|
|
256
|
+
raise ParamError(f"-p {key}: this parameter is an integer, and {value!r} is not one") from error
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _number(value: str, key: str) -> float:
|
|
260
|
+
"""Read a number."""
|
|
261
|
+
try:
|
|
262
|
+
return float(value)
|
|
263
|
+
except ValueError as error:
|
|
264
|
+
raise ParamError(f"-p {key}: this parameter is a number, and {value!r} is not one") from error
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _boolean(value: str, key: str) -> bool:
|
|
268
|
+
"""Read a boolean from the words a person actually types."""
|
|
269
|
+
lowered = value.strip().lower()
|
|
270
|
+
if lowered in TRUE_WORDS:
|
|
271
|
+
return True
|
|
272
|
+
if lowered in FALSE_WORDS:
|
|
273
|
+
return False
|
|
274
|
+
raise ParamError(f"-p {key}: this parameter is a boolean; write true or false, not {value!r}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _checked_enum(value: Any, schema: JsonMap | None, key: str) -> Any:
|
|
278
|
+
"""Refuse a value the schema's enum does not list, naming what it does list."""
|
|
279
|
+
if schema is None:
|
|
280
|
+
return value
|
|
281
|
+
members = schema.get("enum")
|
|
282
|
+
if isinstance(members, list) and value not in members:
|
|
283
|
+
allowed = ", ".join(repr(item) for item in cast("list[object]", members))
|
|
284
|
+
raise ParamError(f"-p {key}: {value!r} is not one of {allowed}")
|
|
285
|
+
return value
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def assign(target: JsonMap, path: Sequence[Step], value: Any, key: str) -> None:
|
|
289
|
+
"""Set a value at a parsed path, creating the objects and arrays it passes through."""
|
|
290
|
+
container: Any = target
|
|
291
|
+
for position, step in enumerate(path[:-1]):
|
|
292
|
+
container = _descend(
|
|
293
|
+
container, step, path[: position + 1], wants_list=isinstance(path[position + 1], int), key=key
|
|
294
|
+
)
|
|
295
|
+
_read(container, path[-1], path, key)
|
|
296
|
+
_put(container, path[-1], value)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _descend(container: Any, step: Step, walked: Sequence[Step], *, wants_list: bool, key: str) -> Any:
|
|
300
|
+
"""Read one step further in, creating the container there when nothing is yet."""
|
|
301
|
+
existing = _read(container, step, walked, key)
|
|
302
|
+
if existing is None:
|
|
303
|
+
fresh: Any = [] if wants_list else {}
|
|
304
|
+
_put(container, step, fresh)
|
|
305
|
+
return fresh
|
|
306
|
+
if not _fits(existing, wants_list=wants_list):
|
|
307
|
+
wanted = "an array" if wants_list else "an object"
|
|
308
|
+
raise ParamError(f"-p {key}: {render_path(walked)!r} holds {_holds(existing)}, not {wanted}")
|
|
309
|
+
return existing
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _fits(value: Any, *, wants_list: bool) -> bool:
|
|
313
|
+
"""Report whether a value already at a path is the container the next step needs."""
|
|
314
|
+
return isinstance(value, dict | list) and isinstance(value, list) == wants_list
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _read(container: Any, step: Step, walked: Sequence[Step], key: str) -> Any:
|
|
318
|
+
"""Read what is at one step, refusing a step the container cannot take."""
|
|
319
|
+
parent = render_path(walked[:-1])
|
|
320
|
+
if isinstance(step, str):
|
|
321
|
+
if isinstance(container, list):
|
|
322
|
+
raise ParamError(f"-p {key}: {parent!r} holds an array, so {step!r} is not a key it takes")
|
|
323
|
+
keys: JsonMap = container
|
|
324
|
+
return keys.get(step)
|
|
325
|
+
if isinstance(container, dict):
|
|
326
|
+
raise ParamError(f"-p {key}: {parent!r} holds an object, so [{step}] is not an element it takes")
|
|
327
|
+
elements: list[Any] = container
|
|
328
|
+
if step == APPEND or step == len(elements):
|
|
329
|
+
return None
|
|
330
|
+
if step > len(elements):
|
|
331
|
+
raise ParamError(
|
|
332
|
+
f"-p {key}: {parent!r} holds {_count(len(elements))}, so [{step}] would leave a gap; "
|
|
333
|
+
f"the next element is [{len(elements)}], which [] also writes"
|
|
334
|
+
)
|
|
335
|
+
return elements[step]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _count(total: int) -> str:
|
|
339
|
+
"""Say how many elements an array holds."""
|
|
340
|
+
return "1 element" if total == 1 else f"{total} elements"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _put(container: Any, step: Step, value: Any) -> None:
|
|
344
|
+
"""Write a value at one step, appending when the index is the end of the array."""
|
|
345
|
+
if isinstance(step, str):
|
|
346
|
+
cast("JsonMap", container)[step] = value
|
|
347
|
+
return
|
|
348
|
+
elements = cast("list[Any]", container)
|
|
349
|
+
if step == APPEND or step == len(elements):
|
|
350
|
+
elements.append(value)
|
|
351
|
+
else:
|
|
352
|
+
elements[step] = value
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def deep_merge(base: JsonMap, incoming: Mapping[str, Any]) -> JsonMap:
|
|
356
|
+
"""Merge one mapping into another: objects merge, everything else replaces."""
|
|
357
|
+
merged = dict(base)
|
|
358
|
+
for key, value in incoming.items():
|
|
359
|
+
existing = merged.get(key)
|
|
360
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
361
|
+
merged[key] = deep_merge(cast("JsonMap", existing), cast("Mapping[str, Any]", value))
|
|
362
|
+
else:
|
|
363
|
+
merged[key] = value
|
|
364
|
+
return merged
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def build_params(
|
|
368
|
+
schema: JsonMap,
|
|
369
|
+
*,
|
|
370
|
+
pairs: Sequence[str] = (),
|
|
371
|
+
files: Sequence[Path] = (),
|
|
372
|
+
) -> JsonMap:
|
|
373
|
+
"""Build one parameter object from files and flags, coerced against the pipeline's schema.
|
|
374
|
+
|
|
375
|
+
A name the schema does not declare is refused whichever channel it arrived on, so a typo
|
|
376
|
+
in a file fails here rather than being stored and ignored.
|
|
377
|
+
"""
|
|
378
|
+
params: JsonMap = {}
|
|
379
|
+
for path in files:
|
|
380
|
+
payload = read_params_file(path)
|
|
381
|
+
for name in payload:
|
|
382
|
+
_schema_at(schema, [name], f"params file {path}")
|
|
383
|
+
params = deep_merge(params, payload)
|
|
384
|
+
for pair in pairs:
|
|
385
|
+
key, raw = parse_pair(pair)
|
|
386
|
+
parts = split_key(key)
|
|
387
|
+
subschema = _schema_at(schema, parts, f"-p {key}")
|
|
388
|
+
assign(params, parts, coerce(raw, subschema, key), key)
|
|
389
|
+
return params
|
dirigent_cli/profiles.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Profiles: which server the CLI talks to, and how it gets a token for it.
|
|
2
|
+
|
|
3
|
+
A profile is client-side addressing only and must never hold a database URL: a CLI that
|
|
4
|
+
could reach the database would bypass authentication, attribution, and validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Final, cast
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
16
|
+
|
|
17
|
+
from dirigent_client import API_PREFIX
|
|
18
|
+
from dirigent_common import EntityName
|
|
19
|
+
|
|
20
|
+
PROJECT_PROFILES: Final = Path(".dirigent") / "profiles.yaml"
|
|
21
|
+
USER_PROFILES: Final = Path.home() / ".config" / "dirigent" / "profiles.yaml"
|
|
22
|
+
|
|
23
|
+
URL_ENV: Final = "DG_URL"
|
|
24
|
+
TOKEN_ENV: Final = "DG_TOKEN"
|
|
25
|
+
PROFILE_ENV: Final = "DG_PROFILE"
|
|
26
|
+
|
|
27
|
+
DEFAULT_URL: Final = "http://127.0.0.1:3333"
|
|
28
|
+
|
|
29
|
+
DATABASE_SCHEMES: Final = ("postgresql", "postgres", "sqlite", "mysql")
|
|
30
|
+
|
|
31
|
+
TOKEN_COMMAND_TIMEOUT: Final = 30.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProfileError(Exception):
|
|
35
|
+
"""A profile could not be read, found, or turned into a usable token."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Profile(BaseModel):
|
|
39
|
+
"""One named server and how to obtain a token for it."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
|
|
43
|
+
name: str = "local"
|
|
44
|
+
url: str = DEFAULT_URL
|
|
45
|
+
token: SecretStr | None = None
|
|
46
|
+
"""An inline token, which is fine for a local dev server and nowhere else."""
|
|
47
|
+
|
|
48
|
+
token_env: str | None = None
|
|
49
|
+
token_cmd: str | None = None
|
|
50
|
+
|
|
51
|
+
api_prefix: str = API_PREFIX
|
|
52
|
+
"""Where this instance serves its API, for one configured with a different prefix."""
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _refuse_a_database_url(self) -> "Profile":
|
|
56
|
+
"""Refuse a profile pointing at a database rather than at an API."""
|
|
57
|
+
scheme = self.url.split("://", 1)[0].split("+", 1)[0].lower()
|
|
58
|
+
if scheme in DATABASE_SCHEMES:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"profile {self.name!r} names a database URL. Profiles address a server over HTTP; "
|
|
61
|
+
f"a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it"
|
|
62
|
+
)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def resolve_token(self, environ: Mapping[str, str] | None = None) -> str | None:
|
|
66
|
+
"""Obtain the token this profile describes, by whichever of the three mechanisms it uses."""
|
|
67
|
+
if self.token is not None:
|
|
68
|
+
return self.token.get_secret_value()
|
|
69
|
+
if self.token_env:
|
|
70
|
+
value = (environ if environ is not None else os.environ).get(self.token_env)
|
|
71
|
+
if not value:
|
|
72
|
+
raise ProfileError(f"profile {self.name!r} reads its token from ${self.token_env}, which is not set")
|
|
73
|
+
return value
|
|
74
|
+
if self.token_cmd:
|
|
75
|
+
return self._run_token_command()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def _run_token_command(self) -> str:
|
|
79
|
+
"""Run the command that prints the token, and refuse anything that is not a token."""
|
|
80
|
+
command = self.token_cmd or ""
|
|
81
|
+
argv = command.split()
|
|
82
|
+
if not argv or shutil.which(argv[0]) is None:
|
|
83
|
+
raise ProfileError(f"profile {self.name!r} runs {command!r} for its token, which is not on PATH")
|
|
84
|
+
try:
|
|
85
|
+
finished = subprocess.run( # noqa: S603 - the command is the operator's own configuration
|
|
86
|
+
argv,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
timeout=TOKEN_COMMAND_TIMEOUT,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired as error:
|
|
93
|
+
raise ProfileError(f"profile {self.name!r}: {command!r} did not finish in time") from error
|
|
94
|
+
if finished.returncode != 0:
|
|
95
|
+
detail = finished.stderr.strip() or f"exit code {finished.returncode}"
|
|
96
|
+
raise ProfileError(f"profile {self.name!r}: {command!r} failed ({detail})")
|
|
97
|
+
token = finished.stdout.strip()
|
|
98
|
+
if not token:
|
|
99
|
+
raise ProfileError(f"profile {self.name!r}: {command!r} printed nothing")
|
|
100
|
+
return token
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ProfileStore(BaseModel):
|
|
104
|
+
"""A profiles file: the named servers, and which one is used when none is asked for."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(frozen=True)
|
|
107
|
+
|
|
108
|
+
default: str | None = None
|
|
109
|
+
profiles: dict[EntityName, Profile] = Field(default_factory=dict[str, Profile])
|
|
110
|
+
path: Path | None = None
|
|
111
|
+
|
|
112
|
+
def select(self, name: str | None) -> Profile | None:
|
|
113
|
+
"""Choose a profile by name, by the file's default, or by there being only one."""
|
|
114
|
+
wanted = name or self.default
|
|
115
|
+
if wanted is None:
|
|
116
|
+
return next(iter(self.profiles.values())) if len(self.profiles) == 1 else None
|
|
117
|
+
found = self.profiles.get(wanted)
|
|
118
|
+
if found is None:
|
|
119
|
+
known = ", ".join(sorted(self.profiles)) or "this file defines none"
|
|
120
|
+
where = f" in {self.path}" if self.path else ""
|
|
121
|
+
raise ProfileError(f"no profile named {wanted!r}{where} ({known})")
|
|
122
|
+
return found
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def candidate_paths(start: Path | None = None) -> list[Path]:
|
|
126
|
+
"""List the profiles files that apply here, most specific first."""
|
|
127
|
+
here = (start or Path.cwd()).resolve()
|
|
128
|
+
found = [directory / PROJECT_PROFILES for directory in [here, *here.parents]]
|
|
129
|
+
return [*found, USER_PROFILES]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_store(path: Path) -> ProfileStore:
|
|
133
|
+
"""Read one profiles file, naming the profile in every error it raises."""
|
|
134
|
+
try:
|
|
135
|
+
loaded: object = yaml.safe_load(path.read_text())
|
|
136
|
+
except (OSError, yaml.YAMLError) as error:
|
|
137
|
+
raise ProfileError(f"{path} could not be read: {error}") from error
|
|
138
|
+
if loaded is None:
|
|
139
|
+
loaded = {}
|
|
140
|
+
if not isinstance(loaded, dict):
|
|
141
|
+
raise ProfileError(f"{path} should hold a mapping with 'default' and 'profiles' keys")
|
|
142
|
+
raw = cast("dict[str, Any]", loaded)
|
|
143
|
+
declared = raw.get("profiles")
|
|
144
|
+
profiles: dict[str, Profile] = {}
|
|
145
|
+
if isinstance(declared, dict):
|
|
146
|
+
for name, body in cast("dict[str, Any]", declared).items():
|
|
147
|
+
if not isinstance(body, dict):
|
|
148
|
+
raise ProfileError(f"{path}: profile {name!r} is not a mapping")
|
|
149
|
+
try:
|
|
150
|
+
profiles[name] = Profile(name=name, **cast("dict[str, Any]", body))
|
|
151
|
+
except ValueError as error:
|
|
152
|
+
raise ProfileError(f"{path}: {error}") from error
|
|
153
|
+
default = raw.get("default")
|
|
154
|
+
return ProfileStore(
|
|
155
|
+
default=default if isinstance(default, str) else None,
|
|
156
|
+
profiles=profiles,
|
|
157
|
+
path=path,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def find_store(start: Path | None = None) -> ProfileStore:
|
|
162
|
+
"""Read the first profiles file that exists, or an empty store when none does."""
|
|
163
|
+
for path in candidate_paths(start):
|
|
164
|
+
if path.is_file():
|
|
165
|
+
return load_store(path)
|
|
166
|
+
return ProfileStore()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Endpoint(BaseModel):
|
|
170
|
+
"""The resolved answer to "which server, with what token"."""
|
|
171
|
+
|
|
172
|
+
model_config = ConfigDict(frozen=True)
|
|
173
|
+
|
|
174
|
+
url: str
|
|
175
|
+
token: str | None = None
|
|
176
|
+
profile: str | None = None
|
|
177
|
+
source: str = "default"
|
|
178
|
+
api_prefix: str = API_PREFIX
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resolve_endpoint(
|
|
182
|
+
*,
|
|
183
|
+
url: str | None = None,
|
|
184
|
+
token: str | None = None,
|
|
185
|
+
profile: str | None = None,
|
|
186
|
+
start: Path | None = None,
|
|
187
|
+
environ: dict[str, str] | None = None,
|
|
188
|
+
needs_token: bool = True,
|
|
189
|
+
) -> Endpoint:
|
|
190
|
+
"""Resolve the server and token: flags first, then ``DG_*``, then the selected profile.
|
|
191
|
+
|
|
192
|
+
With ``needs_token`` off, a profile whose token cannot be resolved yields no token rather
|
|
193
|
+
than refusing, which is what the command that mints one asks for.
|
|
194
|
+
"""
|
|
195
|
+
env = environ if environ is not None else dict(os.environ)
|
|
196
|
+
store = find_store(start)
|
|
197
|
+
chosen = store.select(profile or env.get(PROFILE_ENV))
|
|
198
|
+
|
|
199
|
+
if url is not None:
|
|
200
|
+
source = "flag"
|
|
201
|
+
elif env.get(URL_ENV):
|
|
202
|
+
source, url = "environment", env[URL_ENV]
|
|
203
|
+
elif chosen is not None:
|
|
204
|
+
source, url = f"profile {chosen.name}", chosen.url
|
|
205
|
+
else:
|
|
206
|
+
source, url = "default", DEFAULT_URL
|
|
207
|
+
|
|
208
|
+
resolved_token = token or env.get(TOKEN_ENV) or None
|
|
209
|
+
if resolved_token is None and chosen is not None:
|
|
210
|
+
try:
|
|
211
|
+
resolved_token = chosen.resolve_token(env)
|
|
212
|
+
except ProfileError:
|
|
213
|
+
if needs_token:
|
|
214
|
+
raise
|
|
215
|
+
return Endpoint(
|
|
216
|
+
url=url.rstrip("/"),
|
|
217
|
+
token=resolved_token,
|
|
218
|
+
profile=chosen.name if chosen else None,
|
|
219
|
+
source=source,
|
|
220
|
+
api_prefix=chosen.api_prefix if chosen else API_PREFIX,
|
|
221
|
+
)
|