dirigent-cli 0.13.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.
Files changed (34) hide show
  1. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/PKG-INFO +1 -1
  2. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/pyproject.toml +1 -1
  3. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/pyproject.toml.orig +1 -1
  4. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/main.py +88 -5
  5. dirigent_cli-0.14.0/src/dirigent_cli/seeding.py +198 -0
  6. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/LICENSE +0 -0
  7. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/README.md +0 -0
  8. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/__init__.py +0 -0
  9. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/aliases.py +0 -0
  10. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/commands.py +0 -0
  11. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/context.py +0 -0
  12. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/formatters.py +0 -0
  13. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/graph.py +0 -0
  14. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/health.py +0 -0
  15. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/init_form.py +0 -0
  16. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/local.py +0 -0
  17. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/output.py +0 -0
  18. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/params.py +0 -0
  19. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/profiles.py +0 -0
  20. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/project.py +0 -0
  21. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/py.typed +0 -0
  22. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/reaper.py +0 -0
  23. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/scaffold.py +0 -0
  24. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/schemas.py +0 -0
  25. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/sources.py +0 -0
  26. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/stream.py +0 -0
  27. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/summaries.py +0 -0
  28. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  29. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  30. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  31. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  32. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  33. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/timing.py +0 -0
  34. {dirigent_cli-0.13.0 → dirigent_cli-0.14.0}/src/dirigent_cli/triggers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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
- bound = f"http://{host or settings.host}:{port or settings.port}"
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
- asyncio.run(_dev(settings, host or settings.host, port or settings.port))
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 _dev(settings: Settings, host: str, port: int) -> None:
984
- """Run the API, the scheduler, and a worker as tasks in one event loop.
1004
+ async def seed_token(settings: Settings) -> str:
1005
+ """Mint the short-lived admin token the seeding authenticates with.
985
1006
 
986
- The scheduler is started by the application's own lifespan, not here.
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,
@@ -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
File without changes
File without changes