dirigent-cli 0.12.0__tar.gz → 0.14.0__tar.gz
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-0.12.0 → dirigent_cli-0.14.0}/PKG-INFO +1 -1
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/pyproject.toml +1 -1
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/pyproject.toml.orig +1 -1
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/commands.py +19 -3
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/main.py +88 -5
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/project.py +7 -7
- dirigent_cli-0.14.0/src/dirigent_cli/seeding.py +198 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/triggers.py +14 -1
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/LICENSE +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/README.md +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/context.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/graph.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/init_form.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/local.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/output.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/profiles.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/schemas.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/sources.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/summaries.py +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/timing.py +0 -0
|
@@ -1930,11 +1930,27 @@ def _relay_log(entry: LogEntryOut, state: CliState) -> None:
|
|
|
1930
1930
|
def runs_report(
|
|
1931
1931
|
ctx: typer.Context,
|
|
1932
1932
|
run_id: Annotated[UUID, typer.Argument(help="The run to summarise.")],
|
|
1933
|
+
markdown: Annotated[
|
|
1934
|
+
bool, typer.Option("--markdown", help="Write the document the run rendered when it settled.")
|
|
1935
|
+
] = False,
|
|
1933
1936
|
) -> None:
|
|
1934
|
-
"""Summarise a run: what each step amounted to, and how long it took.
|
|
1935
|
-
|
|
1937
|
+
"""Summarise a run: what each step amounted to, and how long it took.
|
|
1938
|
+
|
|
1939
|
+
`--markdown` answers with the run's own report document instead, which a run renders only
|
|
1940
|
+
when its pipeline document declares a `report:` section.
|
|
1941
|
+
"""
|
|
1942
|
+
state = state_of(ctx)
|
|
1943
|
+
with client_for(state) as dg:
|
|
1944
|
+
if markdown:
|
|
1945
|
+
document = dg.call(dg.runs.report_document(run_id))
|
|
1946
|
+
if document is None:
|
|
1947
|
+
fail(f"run {run_id} has no report document; declare `report:` in the pipeline document")
|
|
1948
|
+
if state.json_output:
|
|
1949
|
+
return emit_fact("run.report_document", message="rendered", run_id=str(run_id), document=document)
|
|
1950
|
+
console.print(document, end="", highlight=False, markup=False)
|
|
1951
|
+
return
|
|
1936
1952
|
report = dg.call(dg.runs.report(run_id))
|
|
1937
|
-
if
|
|
1953
|
+
if state.json_output:
|
|
1938
1954
|
return emit_one("run.report", report)
|
|
1939
1955
|
_print_report(report)
|
|
1940
1956
|
|
|
@@ -192,6 +192,10 @@ DEV_ADMIN = "dev"
|
|
|
192
192
|
DEV_PASSWORD = "dirigent-dev" # noqa: S105
|
|
193
193
|
DEV_TOKEN_NAME = "dev"
|
|
194
194
|
|
|
195
|
+
#: The credential `dg dev --seed` authenticates its own applies with, and how long it lasts.
|
|
196
|
+
SEED_TOKEN_NAME = "dev seed"
|
|
197
|
+
SEED_TOKEN_LIFETIME = timedelta(hours=1)
|
|
198
|
+
|
|
195
199
|
SCHEDULER_ENV = "DIRIGENT_SCHEDULER_ENABLED"
|
|
196
200
|
|
|
197
201
|
UI_ENV = "DIRIGENT_UI_ENABLED"
|
|
@@ -812,6 +816,14 @@ def dev(
|
|
|
812
816
|
help="Delete the state directory before starting, instead of running the instance that is there.",
|
|
813
817
|
),
|
|
814
818
|
] = False,
|
|
819
|
+
seed: Annotated[
|
|
820
|
+
list[Path] | None,
|
|
821
|
+
typer.Option(
|
|
822
|
+
"--seed",
|
|
823
|
+
help="Apply every dirigent/v1 document under this directory once the API answers, "
|
|
824
|
+
"with its schedules paused; name it more than once to seed one directory after another.",
|
|
825
|
+
),
|
|
826
|
+
] = None,
|
|
815
827
|
) -> None:
|
|
816
828
|
"""Run the API and an embedded worker in one process, on SQLite, with no dependencies.
|
|
817
829
|
|
|
@@ -819,6 +831,11 @@ def dev(
|
|
|
819
831
|
starting this somewhere else means a different instance, with none of the same runs.
|
|
820
832
|
An instance that is there, made by dg init or by an earlier start, is the one that runs;
|
|
821
833
|
--wipe-state deletes it first, and only a directory dirigent named itself is removed.
|
|
834
|
+
|
|
835
|
+
--seed fills the instance from a directory of documents the moment it answers: the
|
|
836
|
+
connections a file or a document declares are created first, then every document is
|
|
837
|
+
applied with its schedules paused. A document an instance will not store is reported
|
|
838
|
+
and passed over, because a corpus holds those on purpose.
|
|
822
839
|
"""
|
|
823
840
|
import asyncio
|
|
824
841
|
import os
|
|
@@ -848,9 +865,13 @@ def dev(
|
|
|
848
865
|
emit(state_cleared(cleared))
|
|
849
866
|
migrated = _migrate_quietly(settings)
|
|
850
867
|
admin, token = asyncio.run(dev_admin(settings))
|
|
851
|
-
|
|
868
|
+
address = host or settings.host
|
|
869
|
+
listening = port or settings.port
|
|
870
|
+
bound = f"http://{address}:{listening}"
|
|
852
871
|
emit(dev_started(settings, bound=bound, admin=admin, token=token, migrated=migrated))
|
|
853
|
-
|
|
872
|
+
directories = seed or []
|
|
873
|
+
bearer = token or (asyncio.run(seed_token(settings)) if directories else None)
|
|
874
|
+
asyncio.run(_dev(settings, address, listening, seed=directories, bearer=bearer))
|
|
854
875
|
|
|
855
876
|
|
|
856
877
|
def clear_state(settings: Settings) -> Path | None:
|
|
@@ -980,10 +1001,46 @@ async def dev_admin(settings: Settings) -> tuple[str | None, str | None]:
|
|
|
980
1001
|
await engine.dispose()
|
|
981
1002
|
|
|
982
1003
|
|
|
983
|
-
async def
|
|
984
|
-
"""
|
|
1004
|
+
async def seed_token(settings: Settings) -> str:
|
|
1005
|
+
"""Mint the short-lived admin token the seeding authenticates with.
|
|
985
1006
|
|
|
986
|
-
The
|
|
1007
|
+
The seeding goes through the API rather than the database, so it needs a bearer even
|
|
1008
|
+
though it runs inside the process that is serving. A --keep-state instance kept its own
|
|
1009
|
+
admin and handed its token over long ago, so one is minted here for the seeding alone.
|
|
1010
|
+
"""
|
|
1011
|
+
from dirigent_core.auth import issue_token, list_users
|
|
1012
|
+
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
1013
|
+
|
|
1014
|
+
engine = create_engine(settings)
|
|
1015
|
+
try:
|
|
1016
|
+
async with session_scope(create_session_factory(engine)) as session:
|
|
1017
|
+
admins = [user for user in await list_users(session) if user.role is UserRole.ADMIN and user.active]
|
|
1018
|
+
if not admins:
|
|
1019
|
+
refuse(
|
|
1020
|
+
"dg dev --seed has no admin account to seed as",
|
|
1021
|
+
status=commands.GUARD_EXIT,
|
|
1022
|
+
title="No admin",
|
|
1023
|
+
problems=["create one with dg admin user create --role admin"],
|
|
1024
|
+
)
|
|
1025
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1026
|
+
issued = await issue_token(session, admins[0], name=SEED_TOKEN_NAME, lifetime=SEED_TOKEN_LIFETIME)
|
|
1027
|
+
return issued.secret.get_secret_value()
|
|
1028
|
+
finally:
|
|
1029
|
+
await engine.dispose()
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
async def _dev(
|
|
1033
|
+
settings: Settings,
|
|
1034
|
+
host: str,
|
|
1035
|
+
port: int,
|
|
1036
|
+
*,
|
|
1037
|
+
seed: Sequence[Path] = (),
|
|
1038
|
+
bearer: str | None = None,
|
|
1039
|
+
) -> None:
|
|
1040
|
+
"""Run the API, the scheduler, a worker, and any seeding as tasks in one event loop.
|
|
1041
|
+
|
|
1042
|
+
The scheduler is started by the application's own lifespan, not here. The seeding is a
|
|
1043
|
+
task rather than a step before the loop, because it talks to the API this loop serves.
|
|
987
1044
|
"""
|
|
988
1045
|
import asyncio
|
|
989
1046
|
|
|
@@ -997,15 +1054,24 @@ async def _dev(settings: Settings, host: str, port: int) -> None:
|
|
|
997
1054
|
api = uvicorn.Server(server_config)
|
|
998
1055
|
worker_task = asyncio.create_task(worker.run())
|
|
999
1056
|
ready = asyncio.create_task(_announce_ready(api))
|
|
1057
|
+
seeding = asyncio.create_task(_seed(api, local_url(host, port), bearer, seed)) if seed else None
|
|
1000
1058
|
try:
|
|
1001
1059
|
await api.serve()
|
|
1002
1060
|
finally:
|
|
1003
1061
|
ready.cancel()
|
|
1062
|
+
if seeding is not None:
|
|
1063
|
+
seeding.cancel()
|
|
1004
1064
|
worker.request_stop()
|
|
1005
1065
|
await worker_task
|
|
1006
1066
|
await engine.dispose()
|
|
1007
1067
|
|
|
1008
1068
|
|
|
1069
|
+
def local_url(host: str, port: int) -> str:
|
|
1070
|
+
"""Address the instance this process serves, however widely it is bound."""
|
|
1071
|
+
wildcards = {"0.0.0.0", "::", ""} # noqa: S104 - naming the bind-all addresses, not binding one
|
|
1072
|
+
return f"http://{'127.0.0.1' if host in wildcards else host}:{port}"
|
|
1073
|
+
|
|
1074
|
+
|
|
1009
1075
|
async def _announce_ready(api: "uvicorn.Server") -> None:
|
|
1010
1076
|
"""Say ready once the port is actually accepting, and not a moment before."""
|
|
1011
1077
|
import asyncio
|
|
@@ -1015,6 +1081,23 @@ async def _announce_ready(api: "uvicorn.Server") -> None:
|
|
|
1015
1081
|
emit(make("process", at=datetime.now(UTC), message="ready", process="dev"))
|
|
1016
1082
|
|
|
1017
1083
|
|
|
1084
|
+
async def _seed(api: "uvicorn.Server", url: str, bearer: str | None, directories: Sequence[Path]) -> None:
|
|
1085
|
+
"""Apply what --seed named, once the port is accepting, and write a record for each."""
|
|
1086
|
+
import asyncio
|
|
1087
|
+
|
|
1088
|
+
from dirigent_cli.seeding import seed_directories
|
|
1089
|
+
from dirigent_client import Dirigent, DirigentError
|
|
1090
|
+
|
|
1091
|
+
while not api.started:
|
|
1092
|
+
await asyncio.sleep(0.05)
|
|
1093
|
+
try:
|
|
1094
|
+
async with Dirigent(url=url, token=bearer) as client:
|
|
1095
|
+
async for record in seed_directories(client, directories):
|
|
1096
|
+
emit(record)
|
|
1097
|
+
except DirigentError as error:
|
|
1098
|
+
emit(make("error", at=datetime.now(UTC), level="error", message=f"seeding stopped: {error.message}"))
|
|
1099
|
+
|
|
1100
|
+
|
|
1018
1101
|
@app.command(rich_help_panel=PROCESS_PANEL)
|
|
1019
1102
|
def worker(
|
|
1020
1103
|
ctx: typer.Context,
|
|
@@ -209,9 +209,9 @@ SERVICE_EXAMPLES: Final = {
|
|
|
209
209
|
"""\
|
|
210
210
|
# A greeting written to the stack's own bucket and read back, through the s3:// scheme.
|
|
211
211
|
#
|
|
212
|
-
# There is no S3 block: storage.
|
|
213
|
-
# service bootstraps the `artifacts` connection that serves
|
|
214
|
-
# URI, so this one is the stack's:
|
|
212
|
+
# There is no S3 block: storage.write puts text in an object, storage.copy moves bytes between
|
|
213
|
+
# schemes, and the stack's `migrate` service bootstraps the `artifacts` connection that serves
|
|
214
|
+
# s3://. The bucket comes from the URI, so this one is the stack's:
|
|
215
215
|
#
|
|
216
216
|
# dg run s3-hello --watch
|
|
217
217
|
|
|
@@ -223,7 +223,7 @@ description: Write a greeting to the bucket and copy it back, through s3://.
|
|
|
223
223
|
|
|
224
224
|
requires:
|
|
225
225
|
blocks:
|
|
226
|
-
-
|
|
226
|
+
- storage.write
|
|
227
227
|
- storage.copy
|
|
228
228
|
connections:
|
|
229
229
|
- artifacts
|
|
@@ -232,10 +232,10 @@ requires:
|
|
|
232
232
|
|
|
233
233
|
steps:
|
|
234
234
|
greet:
|
|
235
|
-
block:
|
|
235
|
+
block: storage.write
|
|
236
236
|
config:
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
target: "${run.scratch}/hello.txt"
|
|
238
|
+
text: "hello from object storage"
|
|
239
239
|
|
|
240
240
|
upload:
|
|
241
241
|
block: storage.copy
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Filling a live instance from directories of documents, which ``dg dev --seed`` does at boot.
|
|
2
|
+
|
|
3
|
+
The seeding goes through the API, so every apply meets the same preflight a person's apply
|
|
4
|
+
meets, and a refusal is a record rather than a failure: a corpus is expected to hold
|
|
5
|
+
documents an instance will not store.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Final, cast
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from dirigent_cli.local import ConnectionSpec
|
|
16
|
+
from dirigent_client import Dirigent, DirigentError, PlanAction, ProvenanceSource
|
|
17
|
+
from dirigent_common import JsonMap
|
|
18
|
+
from dirigent_core.documents import safe_load
|
|
19
|
+
from dirigent_core.engine.definition import FORMAT_V1
|
|
20
|
+
from dirigent_core.protocol import Record, make
|
|
21
|
+
|
|
22
|
+
#: The file endings a seed reads. Anything else under a seed directory is passed over, and so
|
|
23
|
+
#: is a file of one of these that does not parse as a mapping.
|
|
24
|
+
SUFFIXES: Final = (".yaml", ".yml", ".json")
|
|
25
|
+
|
|
26
|
+
#: The sections a document may carry so that it runs alone under ``dg run --local``. An
|
|
27
|
+
#: instance refuses to store a document carrying one, so the seed creates what they declare
|
|
28
|
+
#: and applies the document without them.
|
|
29
|
+
CARRIED: Final = ("connections", "schemas")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def readable(directory: Path) -> list[tuple[Path, JsonMap]]:
|
|
33
|
+
"""Read every file under a directory that parses as a mapping, in a stable order."""
|
|
34
|
+
found: list[tuple[Path, JsonMap]] = []
|
|
35
|
+
for path in sorted(one for one in directory.rglob("*") if one.suffix in SUFFIXES and one.is_file()):
|
|
36
|
+
try:
|
|
37
|
+
parsed = safe_load(path.read_text())
|
|
38
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(parsed, dict):
|
|
41
|
+
found.append((path, cast("JsonMap", parsed)))
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_document(raw: JsonMap) -> bool:
|
|
46
|
+
"""Say whether a parsed file is a document this instance reads."""
|
|
47
|
+
return raw.get("format") == FORMAT_V1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def specs(declared: object) -> list[ConnectionSpec]:
|
|
51
|
+
"""Read a ``connections:`` section, of a file or of a document, into one spec per code."""
|
|
52
|
+
if not isinstance(declared, Mapping):
|
|
53
|
+
return []
|
|
54
|
+
read: list[ConnectionSpec] = []
|
|
55
|
+
for code, body in sorted(cast("Mapping[str, object]", declared).items()):
|
|
56
|
+
fields = cast("dict[str, Any]", body) if isinstance(body, Mapping) else {}
|
|
57
|
+
read.append(ConnectionSpec.model_validate({**fields, "code": code}))
|
|
58
|
+
return read
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def seed_directories(client: Dirigent, directories: Sequence[Path]) -> AsyncIterator[Record]:
|
|
62
|
+
"""Apply every document under each directory, paused, and create what they need first.
|
|
63
|
+
|
|
64
|
+
The closing record's counts are read off the records the seeding emitted, so a reader of
|
|
65
|
+
the stream and this summary never disagree.
|
|
66
|
+
"""
|
|
67
|
+
pipelines = 0
|
|
68
|
+
refused = 0
|
|
69
|
+
connections: set[str] = set()
|
|
70
|
+
async for record in _walk(client, directories):
|
|
71
|
+
kind = record["kind"]
|
|
72
|
+
if kind == "seed.applied":
|
|
73
|
+
pipelines += 1
|
|
74
|
+
elif kind == "seed.refused":
|
|
75
|
+
refused += 1
|
|
76
|
+
elif kind == "seed.connection":
|
|
77
|
+
connections.add(str(record["connection"]))
|
|
78
|
+
yield record
|
|
79
|
+
yield make(
|
|
80
|
+
"seed.done",
|
|
81
|
+
at=datetime.now(UTC),
|
|
82
|
+
message="seeded",
|
|
83
|
+
directories=[str(one) for one in directories],
|
|
84
|
+
pipelines=pipelines,
|
|
85
|
+
refused=refused,
|
|
86
|
+
connections=sorted(connections),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _walk(client: Dirigent, directories: Sequence[Path]) -> AsyncIterator[Record]:
|
|
91
|
+
"""Seed each directory in the order given: the connections it declares, then its documents.
|
|
92
|
+
|
|
93
|
+
A directory's own connection files go in before any of its documents, so a document that
|
|
94
|
+
names one of them passes the preflight that reads it.
|
|
95
|
+
"""
|
|
96
|
+
for directory in directories:
|
|
97
|
+
files = readable(directory)
|
|
98
|
+
for path, raw in files:
|
|
99
|
+
if not is_document(raw):
|
|
100
|
+
async for record in _connections(client, specs(raw.get("connections")), str(path)):
|
|
101
|
+
yield record
|
|
102
|
+
for path, raw in files:
|
|
103
|
+
if is_document(raw):
|
|
104
|
+
async for record in _document(client, raw, str(path)):
|
|
105
|
+
yield record
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _document(client: Dirigent, raw: JsonMap, origin: str) -> AsyncIterator[Record]:
|
|
109
|
+
"""Create what one document carries, then apply the document without those sections."""
|
|
110
|
+
async for record in _connections(client, specs(raw.get("connections")), origin):
|
|
111
|
+
yield record
|
|
112
|
+
for code, body in _schemas(raw).items():
|
|
113
|
+
try:
|
|
114
|
+
await _schema(client, code, body)
|
|
115
|
+
except DirigentError as error:
|
|
116
|
+
yield _refused(origin, f"schema {code}: {_reason(error)}")
|
|
117
|
+
yield await _apply(client, raw, origin)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _schemas(raw: JsonMap) -> dict[str, JsonMap]:
|
|
121
|
+
"""Read a document's ``schemas:`` section as one body per code."""
|
|
122
|
+
declared = raw.get("schemas")
|
|
123
|
+
if not isinstance(declared, Mapping):
|
|
124
|
+
return {}
|
|
125
|
+
read = cast("Mapping[str, object]", declared)
|
|
126
|
+
return {code: cast("JsonMap", body) for code, body in sorted(read.items()) if isinstance(body, Mapping)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _schema(client: Dirigent, code: str, body: JsonMap) -> None:
|
|
130
|
+
"""Store one named schema, replacing the body of one this instance already holds."""
|
|
131
|
+
try:
|
|
132
|
+
await client.schemas.create(body, code=code)
|
|
133
|
+
except DirigentError:
|
|
134
|
+
await client.schemas.update(code, body=body)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _connections(client: Dirigent, declared: Sequence[ConnectionSpec], origin: str) -> AsyncIterator[Record]:
|
|
138
|
+
"""Create each declared connection, or replace the config of one that is already there."""
|
|
139
|
+
for spec in declared:
|
|
140
|
+
try:
|
|
141
|
+
action = await _connection(client, spec)
|
|
142
|
+
except DirigentError as error:
|
|
143
|
+
yield _refused(origin, f"connection {spec.code}: {_reason(error)}")
|
|
144
|
+
continue
|
|
145
|
+
yield make(
|
|
146
|
+
"seed.connection",
|
|
147
|
+
at=datetime.now(UTC),
|
|
148
|
+
message="connection",
|
|
149
|
+
connection=spec.code,
|
|
150
|
+
connection_kind=spec.kind,
|
|
151
|
+
action=action,
|
|
152
|
+
origin=origin,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _connection(client: Dirigent, spec: ConnectionSpec) -> str:
|
|
157
|
+
"""Store one connection and say whether it was new, letting a refusal through."""
|
|
158
|
+
try:
|
|
159
|
+
await client.connections.create(spec.code, kind=spec.kind, config=spec.config, name=spec.name)
|
|
160
|
+
except DirigentError:
|
|
161
|
+
await client.connections.update(spec.code, config=spec.config)
|
|
162
|
+
return "updated"
|
|
163
|
+
return "created"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _apply(client: Dirigent, raw: JsonMap, origin: str) -> Record:
|
|
167
|
+
"""Apply one document with its schedules paused, without the sections it carries."""
|
|
168
|
+
document = {name: value for name, value in raw.items() if name not in CARRIED}
|
|
169
|
+
try:
|
|
170
|
+
result = await client.pipelines.apply(
|
|
171
|
+
document,
|
|
172
|
+
source=ProvenanceSource.FILE,
|
|
173
|
+
source_ref=origin,
|
|
174
|
+
pause_schedules=True,
|
|
175
|
+
)
|
|
176
|
+
except DirigentError as error:
|
|
177
|
+
return _refused(origin, _reason(error))
|
|
178
|
+
if result.plan.action is PlanAction.INVALID:
|
|
179
|
+
return _refused(origin, "; ".join(str(issue) for issue in result.plan.issues))
|
|
180
|
+
return make(
|
|
181
|
+
"seed.applied",
|
|
182
|
+
at=datetime.now(UTC),
|
|
183
|
+
message="applied",
|
|
184
|
+
document=origin,
|
|
185
|
+
pipeline=result.plan.code,
|
|
186
|
+
action=result.plan.action.value,
|
|
187
|
+
schedules_paused=result.triggers.schedules_created,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _refused(origin: str, reason: str) -> Record:
|
|
192
|
+
"""Build the record saying what the instance would not take, and why."""
|
|
193
|
+
return make("seed.refused", at=datetime.now(UTC), message="refused", document=origin, reason=reason)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _reason(error: DirigentError) -> str:
|
|
197
|
+
"""Read a refusal as the one line a record carries."""
|
|
198
|
+
return "; ".join(error.problems) or error.message
|
|
@@ -441,12 +441,22 @@ def alerts_rules_create(
|
|
|
441
441
|
description: Annotated[str | None, typer.Option("--description", help="What this rule is for.")] = None,
|
|
442
442
|
pipeline: Annotated[str | None, typer.Option("--pipeline", help="Watch one pipeline instead of all.")] = None,
|
|
443
443
|
connection: Annotated[str | None, typer.Option("--connection", help="The credential the channel uses.")] = None,
|
|
444
|
-
template: Annotated[
|
|
444
|
+
template: Annotated[
|
|
445
|
+
str | None, typer.Option("--template", help="Subject, a Jinja template over the run's facts.")
|
|
446
|
+
] = None,
|
|
447
|
+
body: Annotated[str | None, typer.Option("--body", help="Body, a Jinja template over the run's facts.")] = None,
|
|
448
|
+
body_file: Annotated[
|
|
449
|
+
Path | None, typer.Option("--body-file", help="Read the body template from a file instead.")
|
|
450
|
+
] = None,
|
|
445
451
|
throttle: Annotated[str, typer.Option("--throttle", help="At most one message per window, e.g. 15m.")] = "0s",
|
|
446
452
|
) -> None:
|
|
447
453
|
"""Declare an alert rule binding an event at a scope to a channel."""
|
|
448
454
|
if event not in set(AlertEvent):
|
|
449
455
|
_fail(f"{event!r} is not an alert event ({', '.join(sorted(AlertEvent))})")
|
|
456
|
+
if body is not None and body_file is not None:
|
|
457
|
+
_fail("a rule takes one body: --body or --body-file, not both")
|
|
458
|
+
if body_file is not None:
|
|
459
|
+
body = body_file.read_text()
|
|
450
460
|
with client_for(state_of(ctx)) as dg:
|
|
451
461
|
created = dg.call(
|
|
452
462
|
dg.alerts.create_rule(
|
|
@@ -459,6 +469,7 @@ def alerts_rules_create(
|
|
|
459
469
|
pipeline=pipeline,
|
|
460
470
|
connection=connection,
|
|
461
471
|
template=template,
|
|
472
|
+
body=body,
|
|
462
473
|
throttle=throttle,
|
|
463
474
|
)
|
|
464
475
|
)
|
|
@@ -471,6 +482,8 @@ def alerts_rules_create(
|
|
|
471
482
|
scope=created.pipeline or created.scope.value,
|
|
472
483
|
notifier=created.notifier,
|
|
473
484
|
connection=created.connection,
|
|
485
|
+
template=created.template is not None,
|
|
486
|
+
body=created.body is not None,
|
|
474
487
|
throttle=created.throttle,
|
|
475
488
|
)
|
|
476
489
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/operator.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.12.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|