dirigent-cli 0.16.7__tar.gz → 0.17.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 (36) hide show
  1. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/PKG-INFO +9 -8
  2. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/pyproject.toml +12 -8
  3. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/pyproject.toml.orig +10 -8
  4. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/commands.py +134 -67
  5. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/context.py +6 -3
  6. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/health.py +77 -34
  7. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/main.py +59 -36
  8. dirigent_cli-0.17.0/src/dirigent_cli/messages.py +302 -0
  9. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/output.py +21 -4
  10. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/project.py +2 -2
  11. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/reaper.py +3 -3
  12. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/seeding.py +1 -1
  13. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/summaries.py +9 -5
  14. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/triggers.py +15 -7
  15. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/LICENSE +0 -0
  16. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/README.md +0 -0
  17. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/__init__.py +0 -0
  18. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/aliases.py +0 -0
  19. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/formatters.py +0 -0
  20. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/graph.py +0 -0
  21. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/init_form.py +0 -0
  22. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/local.py +0 -0
  23. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/params.py +0 -0
  24. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/profiles.py +0 -0
  25. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/py.typed +0 -0
  26. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/scaffold.py +0 -0
  27. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/schemas.py +0 -0
  28. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/sources.py +0 -0
  29. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/starters.py +0 -0
  30. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/stream.py +0 -0
  31. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  32. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  33. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  34. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  35. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  36. {dirigent_cli-0.16.7 → dirigent_cli-0.17.0}/src/dirigent_cli/timing.py +0 -0
@@ -1,18 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.16.7
3
+ Version: 0.17.0
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-blocks==0.16.7
10
- Requires-Dist: dirigent-client==0.16.7
11
- Requires-Dist: dirigent-common==0.16.7
12
- Requires-Dist: dirigent-core==0.16.7
13
- Requires-Dist: dirigent-examples==0.16.7
14
- Requires-Dist: dirigent-plugin==0.16.7
15
- Requires-Dist: dirigent-server==0.16.7
9
+ Requires-Dist: dirigent-block-execute==0.17.0
10
+ Requires-Dist: dirigent-blocks==0.17.0
11
+ Requires-Dist: dirigent-client==0.17.0
12
+ Requires-Dist: dirigent-common==0.17.0
13
+ Requires-Dist: dirigent-core==0.17.0
14
+ Requires-Dist: dirigent-examples==0.17.0
15
+ Requires-Dist: dirigent-plugin==0.17.0
16
+ Requires-Dist: dirigent-server==0.17.0
16
17
  Requires-Dist: httpx2>=2.12.0
17
18
  Requires-Dist: python-dotenv>=1.1.0
18
19
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.16.7"
3
+ version = "0.17.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,13 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-blocks==0.16.7",
15
- "dirigent-client==0.16.7",
16
- "dirigent-common==0.16.7",
17
- "dirigent-core==0.16.7",
18
- "dirigent-examples==0.16.7",
19
- "dirigent-plugin==0.16.7",
20
- "dirigent-server==0.16.7",
14
+ "dirigent-block-execute==0.17.0",
15
+ "dirigent-blocks==0.17.0",
16
+ "dirigent-client==0.17.0",
17
+ "dirigent-common==0.17.0",
18
+ "dirigent-core==0.17.0",
19
+ "dirigent-examples==0.17.0",
20
+ "dirigent-plugin==0.17.0",
21
+ "dirigent-server==0.17.0",
21
22
  "httpx2>=2.12.0",
22
23
  "python-dotenv>=1.1.0",
23
24
  "pyyaml>=6.0.3",
@@ -36,6 +37,9 @@ dg = "dirigent_cli.main:run"
36
37
  requires = ["uv_build>=0.12.0,<0.13.0"]
37
38
  build-backend = "uv_build"
38
39
 
40
+ [tool.uv.sources.dirigent-block-execute]
41
+ workspace = true
42
+
39
43
  [tool.uv.sources.dirigent-blocks]
40
44
  workspace = true
41
45
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.16.7"
3
+ version = "0.17.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,13 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-blocks==0.16.7",
15
- "dirigent-client==0.16.7",
16
- "dirigent-common==0.16.7",
17
- "dirigent-core==0.16.7",
18
- "dirigent-examples==0.16.7",
19
- "dirigent-plugin==0.16.7",
20
- "dirigent-server==0.16.7",
14
+ "dirigent-block-execute==0.17.0",
15
+ "dirigent-blocks==0.17.0",
16
+ "dirigent-client==0.17.0",
17
+ "dirigent-common==0.17.0",
18
+ "dirigent-core==0.17.0",
19
+ "dirigent-examples==0.17.0",
20
+ "dirigent-plugin==0.17.0",
21
+ "dirigent-server==0.17.0",
21
22
  "httpx2>=2.12.0",
22
23
  "python-dotenv>=1.1.0",
23
24
  "pyyaml>=6.0.3",
@@ -37,6 +38,7 @@ requires = ["uv_build>=0.12.0,<0.13.0"]
37
38
  build-backend = "uv_build"
38
39
 
39
40
  [tool.uv.sources]
41
+ dirigent-block-execute = { workspace = true }
40
42
  dirigent-blocks = { workspace = true }
41
43
  dirigent-client = { workspace = true }
42
44
  dirigent-core = { workspace = true }
@@ -29,6 +29,60 @@ from dirigent_cli.local import (
29
29
  load_schema_specs,
30
30
  run_document,
31
31
  )
32
+ from dirigent_cli.messages import (
33
+ ADMIN_IS_FIXED_IN_A_STACK,
34
+ ALL_TAKES_NO_CODE,
35
+ ALREADY_THERE,
36
+ AS_IS_ONE_DOCUMENT,
37
+ CANNOT_PROMPT,
38
+ CANNOT_PROMPT_SET,
39
+ CONCURRENCY_REFUSED,
40
+ DOCUMENT_INVALID,
41
+ GUARD_REFUSED,
42
+ INIT_CANCELLED,
43
+ INIT_REFUSED,
44
+ INIT_WAY,
45
+ INSTANCE_ALREADY_HERE,
46
+ KEEP_IS_LOCAL_ONLY,
47
+ LOCAL_RUN_HAD_NO_OUTCOME,
48
+ LOCAL_TAKES_A_DOCUMENT,
49
+ LOCAL_TAKES_NO_SERVER,
50
+ LOG_LEVEL_NO_PATTERN,
51
+ LOG_LEVEL_NOT_A_LEVEL,
52
+ NAME_A_PIPELINE,
53
+ NAME_ANOTHER_CODE,
54
+ NO_DOCUMENT_AND_NO_PROJECT,
55
+ NO_FAILURE_TO_RETRY,
56
+ NO_FIRST_PASSWORD,
57
+ NO_REPORT_DOCUMENT,
58
+ NO_SUCH_EXAMPLE,
59
+ NOT_A_BLOCK_KIND,
60
+ NOT_A_PRIORITY,
61
+ NOT_A_RUN_STATUS,
62
+ NOT_A_STARTER,
63
+ NOT_A_WINDOW,
64
+ NOT_AUTHENTICATED,
65
+ OR_A_PROFILE_TOKEN,
66
+ OR_DG_AUTH_LOGIN,
67
+ PARAMS_REFUSED,
68
+ PRIORITY_IS_NOT_LOCAL,
69
+ PROJECT_IS_EMPTY,
70
+ PRUNE_IS_PROJECT_WIDE,
71
+ ROOT_IS_LOCAL_ONLY,
72
+ RUN_SKIPPED,
73
+ SCAFFOLD_REFUSED,
74
+ SCHEMA_NOT_AN_OBJECT,
75
+ SCHEMA_UNREADABLE,
76
+ SET_DG_TOKEN,
77
+ SOURCE_REFUSED,
78
+ STARTER_TAG_NEEDED,
79
+ STRICT_NEEDS_WATCH,
80
+ TRIGGERS_DOCUMENT_NOT_RUNNABLE,
81
+ UNKNOWN_CONNECTION_KIND,
82
+ UNSAFE_FOR_THE_INSTANCE,
83
+ UNSAFE_FOR_THIS_RUN,
84
+ )
85
+ from dirigent_cli.messages import WINDOW_GRAMMAR as WINDOW_GRAMMAR_MESSAGE
32
86
  from dirigent_cli.output import (
33
87
  Detail,
34
88
  age,
@@ -100,7 +154,7 @@ from dirigent_client import (
100
154
  RunStatus,
101
155
  ValidationIssue,
102
156
  )
103
- from dirigent_common import JsonMap
157
+ from dirigent_common import Issue, JsonMap, Message
104
158
  from dirigent_core import migrations
105
159
  from dirigent_core.config import STATE_DIR, Settings
106
160
  from dirigent_core.documents import (
@@ -164,9 +218,9 @@ def paged[T](fetch: Callable[[str | None, int], Page[T]], limit: int | None) ->
164
218
  after = page.next
165
219
 
166
220
 
167
- def fail(message: str) -> NoReturn:
221
+ def fail(message: Message, /, **params: Any) -> NoReturn:
168
222
  """Write a refusal the CLI decided on as a record, and exit non-zero."""
169
- refuse(message)
223
+ refuse(message, **params)
170
224
  raise typer.Exit(code=1)
171
225
 
172
226
 
@@ -182,7 +236,7 @@ def ask(label: str, *, hide: bool = False) -> str:
182
236
  invocation fails with a JSON object instead of blocking on a terminal that is a pipe.
183
237
  """
184
238
  if json_mode():
185
- fail(f"--json cannot prompt for {label}; pass it as an option")
239
+ fail(CANNOT_PROMPT, label=label)
186
240
  return str(typer.prompt(label, hide_input=hide))
187
241
 
188
242
 
@@ -199,7 +253,7 @@ def _apply_one(
199
253
  try:
200
254
  body = parse_document(document)
201
255
  except SourceError as error:
202
- fail(str(error))
256
+ fail(SOURCE_REFUSED, detail=str(error))
203
257
  return dg.call(
204
258
  dg.pipelines.apply(
205
259
  body,
@@ -271,7 +325,7 @@ def _runnable(text: str) -> PipelineDefinition:
271
325
  """Read a document a local run is about to execute, refusing the kind that cannot be run."""
272
326
  definition = load_text(text)
273
327
  if not isinstance(definition, PipelineDefinition):
274
- fail("a triggers document declares clocks for a pipeline and cannot be run; run the pipeline it names")
328
+ fail(TRIGGERS_DOCUMENT_NOT_RUNNABLE)
275
329
  return definition
276
330
 
277
331
 
@@ -312,7 +366,7 @@ def apply_command(
312
366
  """
313
367
  state = state_of(ctx)
314
368
  if prune and reference is not None:
315
- fail("--prune reconciles a whole project, so it cannot be used with a single document")
369
+ fail(PRUNE_IS_PROJECT_WIDE)
316
370
  documents = _documents_to_apply(reference, as_code)
317
371
  source = ProvenanceSource.DIRECTORY if prune else None
318
372
  with client_for(state) as dg:
@@ -356,18 +410,18 @@ def _documents_to_apply(reference: str | None, as_code: str | None) -> list[Docu
356
410
  try:
357
411
  return [read_document(reference)]
358
412
  except SourceError as error:
359
- fail(str(error))
413
+ fail(SOURCE_REFUSED, detail=str(error))
360
414
  project = find_project()
361
415
  if project is None:
362
- fail("no document was named and this directory is not a project; run dg init, or name a file")
416
+ fail(NO_DOCUMENT_AND_NO_PROJECT)
363
417
  if as_code is not None:
364
- fail("--as recodes one document, so it cannot be used when applying a whole project")
418
+ fail(AS_IS_ONE_DOCUMENT)
365
419
  try:
366
420
  paths = cast("Any", project).documents()
367
421
  except ProjectError as error:
368
- fail(str(error))
422
+ fail(SOURCE_REFUSED, detail=str(error))
369
423
  if not paths:
370
- fail(f"{cast('Any', project).pipelines_dir} holds no documents")
424
+ fail(PROJECT_IS_EMPTY, directory=cast("Any", project).pipelines_dir)
371
425
  # Pipelines go before triggers documents: one that names a pipeline the instance does not
372
426
  # hold yet is refused, and applying the pipeline first is what makes it present.
373
427
  return sorted((read_path(path) for path in paths), key=_declares_triggers)
@@ -432,7 +486,7 @@ def validate_command(
432
486
  level="error",
433
487
  message="invalid",
434
488
  document=document.label,
435
- problems=list(error.problems),
489
+ problems=[issue.model_dump(mode="json") for issue in error.problems],
436
490
  )
437
491
  failures += 1
438
492
  catalog: Catalog | None = None
@@ -466,7 +520,7 @@ def validate_command(
466
520
  message="invalid",
467
521
  code=definition.code,
468
522
  document=label,
469
- problems=[str(issue) for issue in issues],
523
+ problems=[issue.issue().model_dump(mode="json") for issue in issues],
470
524
  )
471
525
  failures += 1
472
526
  continue
@@ -562,7 +616,7 @@ def init_command(
562
616
  root, version=version, admin=admin, password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV, "")
563
617
  )
564
618
  if chosen is None:
565
- _init_fail("cancelled; nothing was written")
619
+ _init_fail(INIT_CANCELLED)
566
620
  choices = chosen
567
621
  else:
568
622
  choices = InitChoices(
@@ -577,17 +631,17 @@ def init_command(
577
631
  try:
578
632
  check_choices(choices)
579
633
  except ProjectError as error:
580
- _init_fail(str(error))
634
+ _init_fail(INIT_REFUSED, detail=str(error))
581
635
  if choices.stack and admin != "admin":
582
- _init_fail("--admin does not apply to the compose template; the stack's first admin is named admin")
636
+ _init_fail(ADMIN_IS_FIXED_IN_A_STACK)
583
637
  if choices.template != "documents" and not choices.password:
584
638
  choices = choices.model_copy(update={"password": _prompt_for_a_password(stack=choices.stack)})
585
639
  if choices.template != "documents" and len(choices.password) < MIN_PASSWORD_LENGTH:
586
- _init_fail(str(WeakPassword()))
640
+ _init_fail(INIT_REFUSED, detail=str(WeakPassword()))
587
641
  try:
588
642
  made = scaffold(directory, choices, version=version)
589
643
  except ProjectError as error:
590
- _init_fail(str(error))
644
+ _init_fail(INIT_REFUSED, detail=str(error))
591
645
  left = [_within(path, directory) for path in made.skipped]
592
646
  described = {
593
647
  "template": choices.template,
@@ -649,16 +703,16 @@ def instance_settings(root: Path) -> Settings:
649
703
  )
650
704
 
651
705
 
652
- def _init_fail(message: str, *, ways: Sequence[tuple[str, str]] = ()) -> NoReturn:
706
+ def _init_fail(message: Message, /, *, ways: Sequence[tuple[str, str]] = (), **params: Any) -> NoReturn:
653
707
  """Refuse an init: a record where records were asked for, plain sentences otherwise.
654
708
 
655
709
  Each way out is a command and what it does; the record carries them as one sentence
656
710
  each, and the rendering sets the commands in a column of their own.
657
711
  """
658
712
  if json_mode():
659
- refuse(message, problems=[f"{command} {what}" for command, what in ways])
713
+ refuse(message, problems=[Issue.of(INIT_WAY, command=command, what=what) for command, what in ways], **params)
660
714
  else:
661
- error_console.print(f"[red]{escape(message)}[/]", highlight=False)
715
+ error_console.print(f"[red]{escape(message.render(**params))}[/]", highlight=False)
662
716
  width = max((len(command) for command, _ in ways), default=0)
663
717
  for command, what in ways:
664
718
  error_console.print(f" [bold]{escape(command).ljust(width)}[/] [dim]{escape(what)}[/]", highlight=False)
@@ -674,7 +728,8 @@ def _refuse_an_existing_instance(root: Path) -> None:
674
728
  existing = instance_settings(root).sqlite_path
675
729
  if existing is not None and existing.exists():
676
730
  _init_fail(
677
- f"this directory holds an instance already: {existing}",
731
+ INSTANCE_ALREADY_HERE,
732
+ path=existing,
678
733
  ways=[
679
734
  ("dg dev", "starts it"),
680
735
  ("dg db upgrade", "brings its schema forward"),
@@ -686,7 +741,7 @@ def _refuse_an_existing_instance(root: Path) -> None:
686
741
  def _prompt_for_a_password(*, stack: bool = False) -> str:
687
742
  """Ask for the first admin's password, or say how to give it without a prompt."""
688
743
  if not sys.stdin.isatty():
689
- _init_fail(f"no password for the first admin: pass --password, or set {BOOTSTRAP_PASSWORD_ENV}")
744
+ _init_fail(NO_FIRST_PASSWORD, variable=BOOTSTRAP_PASSWORD_ENV)
690
745
  asked = "password for the stack's first admin" if stack else "password for the first admin"
691
746
  return str(typer.prompt(asked, hide_input=True, confirmation_prompt=True))
692
747
 
@@ -714,7 +769,7 @@ async def first_admin(settings: Settings, username: str, password: str) -> str:
714
769
  issued = await issue_token(session, user, name="init")
715
770
  return issued.secret.get_secret_value()
716
771
  except AuthError as error:
717
- _init_fail(str(error))
772
+ _init_fail(INIT_REFUSED, detail=str(error))
718
773
  finally:
719
774
  await engine.dispose()
720
775
 
@@ -827,9 +882,9 @@ def pipeline_validate(
827
882
  """
828
883
  state = state_of(ctx)
829
884
  if every and (code is not None or version is not None):
830
- fail("--all checks every pipeline, so it takes neither a code nor a version")
885
+ fail(ALL_TAKES_NO_CODE)
831
886
  if not every and code is None:
832
- fail("name a pipeline, or pass --all to check every one of them")
887
+ fail(NAME_A_PIPELINE)
833
888
  with client_for(state) as dg:
834
889
  codes = (
835
890
  [row.code for row in paged(lambda after, size: dg.call(dg.pipelines.list(after=after, limit=size)), None)]
@@ -899,7 +954,7 @@ def parse_params(
899
954
  try:
900
955
  built = build_params(schema or {}, pairs=pairs or [], files=files or [])
901
956
  except ParamError as error:
902
- fail(str(error))
957
+ fail(PARAMS_REFUSED, detail=str(error))
903
958
  properties = cast("dict[str, Any]", (schema or {}).get("properties") or {})
904
959
  return {
905
960
  name: value
@@ -912,7 +967,7 @@ def parse_params(
912
967
  WINDOW_SEPARATOR: Final = ".."
913
968
 
914
969
  #: The grammar, said once, so every refusal says the same thing.
915
- WINDOW_GRAMMAR: Final = "--window takes START..END: two ISO 8601 instants separated by '..'"
970
+ WINDOW_GRAMMAR: Final = WINDOW_GRAMMAR_MESSAGE.render()
916
971
 
917
972
 
918
973
  def read_instant(value: str, flag: str) -> datetime:
@@ -940,7 +995,7 @@ def read_window(value: str) -> tuple[datetime, datetime]:
940
995
  """Read a ``START..END`` window, or say what the grammar is."""
941
996
  start_text, separator, end_text = value.partition(WINDOW_SEPARATOR)
942
997
  if not separator:
943
- raise ParamError(f"{WINDOW_GRAMMAR}, not {value!r}")
998
+ raise ParamError(NOT_A_WINDOW.render(grammar=WINDOW_GRAMMAR, value=repr(value)))
944
999
  start = read_instant(start_text, "--window")
945
1000
  end = read_instant(end_text, "--window")
946
1001
  check_interval(start, end)
@@ -954,7 +1009,7 @@ def parse_window(value: str | None) -> tuple[datetime, datetime] | None:
954
1009
  try:
955
1010
  return read_window(value)
956
1011
  except ParamError as error:
957
- fail(str(error))
1012
+ fail(PARAMS_REFUSED, detail=str(error))
958
1013
 
959
1014
 
960
1015
  def parse_log_levels(values: list[str] | None) -> dict[str, LogLevel] | None:
@@ -971,12 +1026,12 @@ def parse_log_levels(values: list[str] | None) -> dict[str, LogLevel] | None:
971
1026
  if not named:
972
1027
  pattern, named = "*", value
973
1028
  if not pattern:
974
- fail(f"--log-level {value!r} names no pattern before the =")
1029
+ fail(LOG_LEVEL_NO_PATTERN, value=repr(value))
975
1030
  try:
976
1031
  levels[pattern] = LogLevel(named.lower())
977
1032
  except ValueError:
978
1033
  allowed = ", ".join(level.value for level in LogLevel)
979
- fail(f"--log-level {value!r}: {named!r} is not a level ({allowed})")
1034
+ fail(LOG_LEVEL_NOT_A_LEVEL, value=repr(value), named=repr(named), allowed=allowed)
980
1035
  return levels
981
1036
 
982
1037
 
@@ -987,7 +1042,11 @@ def parse_priority(value: str | None) -> RunPriority | None:
987
1042
  try:
988
1043
  return RunPriority(value.lower())
989
1044
  except ValueError:
990
- fail(f"--priority {value!r} is not a priority ({', '.join(priority.value for priority in RunPriority)})")
1045
+ fail(
1046
+ NOT_A_PRIORITY,
1047
+ value=repr(value),
1048
+ allowed=", ".join(priority.value for priority in RunPriority),
1049
+ )
991
1050
 
992
1051
 
993
1052
  def run_command(
@@ -1068,16 +1127,16 @@ def run_command(
1068
1127
  """
1069
1128
  state = state_of(ctx)
1070
1129
  if strict and not (watch or local):
1071
- fail("--strict decides on a run's outcome, so it needs --watch or --local")
1130
+ fail(STRICT_NEEDS_WATCH)
1072
1131
  if keep and not local:
1073
- fail("--keep leaves a --local run's throwaway instance behind; a real instance keeps its own")
1132
+ fail(KEEP_IS_LOCAL_ONLY)
1074
1133
  if root is not None and not local:
1075
- fail("--root holds a --local run's instance in a directory; a real instance has its own")
1134
+ fail(ROOT_IS_LOCAL_ONLY)
1076
1135
  covered = parse_window(window)
1077
1136
  levels = parse_log_levels(log_level)
1078
1137
  wanted = parse_priority(priority)
1079
1138
  if wanted is not None and local:
1080
- fail("--priority orders a run against the others queued, and a --local run has none")
1139
+ fail(PRIORITY_IS_NOT_LOCAL)
1081
1140
  if local:
1082
1141
  _run_locally(
1083
1142
  state,
@@ -1103,15 +1162,16 @@ def run_command(
1103
1162
  document = read_document(target)
1104
1163
  schema = dict(load_pipeline_text(document.text).params)
1105
1164
  except (SourceError, DocumentError) as error:
1106
- fail(str(error))
1165
+ fail(SOURCE_REFUSED, detail=str(error))
1107
1166
  result = _apply_one(dg, document, code=as_code, dry_run=False)
1108
1167
  if not _print_plan(result, document.label):
1109
1168
  if state.json_output:
1110
1169
  emit_problem(
1111
- f"{result.plan.code} does not validate",
1170
+ DOCUMENT_INVALID,
1112
1171
  status=422,
1113
1172
  title="Unprocessable Content",
1114
- problems=[f"{issue.location}: {issue.message}" for issue in result.plan.issues],
1173
+ problems=[issue.issue() for issue in result.plan.issues],
1174
+ code=result.plan.code,
1115
1175
  )
1116
1176
  raise typer.Exit(code=1)
1117
1177
  code = result.plan.code
@@ -1122,9 +1182,14 @@ def run_command(
1122
1182
  accepted = dg.call(dg.pipelines.run(code, params=params, window=covered, log_levels=levels, priority=wanted))
1123
1183
  if accepted.run_id is None:
1124
1184
  if state.json_output:
1125
- emit_problem(accepted.detail or "the concurrency policy refused this run", status=409, title="Skipped")
1185
+ emit_problem(
1186
+ RUN_SKIPPED,
1187
+ status=409,
1188
+ title="Skipped",
1189
+ detail=accepted.detail or CONCURRENCY_REFUSED.render(),
1190
+ )
1126
1191
  return
1127
- console.print(f"[yellow]skipped[/] {accepted.detail or 'the concurrency policy refused this run'}")
1192
+ console.print(f"[yellow]skipped[/] {accepted.detail or CONCURRENCY_REFUSED.render()}")
1128
1193
  return
1129
1194
  sink(state).event(
1130
1195
  "run",
@@ -1170,7 +1235,7 @@ def backfill_command(
1170
1235
  end = read_instant(to, "--to")
1171
1236
  check_interval(start, end)
1172
1237
  except ParamError as error:
1173
- fail(str(error))
1238
+ fail(PARAMS_REFUSED, detail=str(error))
1174
1239
  with client_for(state) as dg:
1175
1240
  accepted = dg.call(dg.pipelines.backfill(pipeline, schedule=schedule, start=start, end=end, dry_run=dry_run))
1176
1241
  _report_backfill(accepted, state=state)
@@ -1208,9 +1273,9 @@ def _run_locally(
1208
1273
  ) -> None:
1209
1274
  """Apply and run a document in a throwaway instance, streaming what it logs."""
1210
1275
  if state.url is not None or state.profile is not None or state.token is not None:
1211
- fail("--local runs here with no server, so it cannot be combined with --url, --token, or --profile")
1276
+ fail(LOCAL_TAKES_NO_SERVER)
1212
1277
  if not looks_like_a_document(target):
1213
- fail(f"--local runs a document, and {target!r} is not a file, a URL, or '-'")
1278
+ fail(LOCAL_TAKES_A_DOCUMENT, target=repr(target))
1214
1279
  try:
1215
1280
  document = read_document(target)
1216
1281
  specs: list[ConnectionSpec] = load_connection_specs(connections) if connections else []
@@ -1218,7 +1283,7 @@ def _run_locally(
1218
1283
  supporting = [read_document(str(path)).text for path in also_apply]
1219
1284
  schema = dict(_runnable(document.text).params)
1220
1285
  except (SourceError, LocalError, DocumentError) as error:
1221
- fail(str(error))
1286
+ fail(SOURCE_REFUSED, detail=str(error))
1222
1287
  params = parse_params(pairs, schema=schema, files=params_files)
1223
1288
  settings = _local_settings(enable_unsafe)
1224
1289
  outcome = asyncio.run(
@@ -1237,7 +1302,7 @@ def _run_locally(
1237
1302
  )
1238
1303
  )
1239
1304
  if outcome is None: # pragma: no cover - the generator always ends with an outcome
1240
- fail("the local run produced no outcome")
1305
+ fail(LOCAL_RUN_HAD_NO_OUTCOME)
1241
1306
  _report_outcome(outcome, strict=strict, state=state)
1242
1307
 
1243
1308
 
@@ -1313,13 +1378,13 @@ async def _stream_local(
1313
1378
  except LocalError as error:
1314
1379
  remedies = (
1315
1380
  [
1316
- "for this run only: dg run --local ... --enable-unsafe shell.run",
1317
- "for the instance: export DIRIGENT_ENABLED_UNSAFE_BLOCKS='[\"shell.run\"]'",
1381
+ Issue.of(UNSAFE_FOR_THIS_RUN),
1382
+ Issue.of(UNSAFE_FOR_THE_INSTANCE),
1318
1383
  ]
1319
1384
  if "DIRIGENT_ENABLED_UNSAFE_BLOCKS" in str(error)
1320
1385
  else []
1321
1386
  )
1322
- refuse(str(error), status=GUARD_EXIT, title="Refused", problems=remedies)
1387
+ refuse(GUARD_REFUSED, status=GUARD_EXIT, title="Refused", problems=remedies, detail=str(error))
1323
1388
  raise typer.Exit(code=GUARD_EXIT) from error
1324
1389
  return outcome
1325
1390
 
@@ -1777,7 +1842,7 @@ def runs_list(
1777
1842
  ) -> None:
1778
1843
  """List runs, newest first, or the ones whose pipeline wears every tag named."""
1779
1844
  if status is not None and status not in set(RunStatus):
1780
- fail(f"{status!r} is not a run status ({', '.join(sorted(RunStatus))})")
1845
+ fail(NOT_A_RUN_STATUS, value=repr(status), allowed=", ".join(sorted(RunStatus)))
1781
1846
  tags = tag or []
1782
1847
  with client_for(state_of(ctx)) as dg:
1783
1848
  rows = list(
@@ -1936,7 +2001,7 @@ def runs_retry(
1936
2001
  with client_for(state_of(ctx)) as dg:
1937
2002
  candidates = [row for row in run_attempts(dg, run_id, step=step) if row.status in RETRYABLE]
1938
2003
  if not candidates:
1939
- fail(f"run {run_id} has no settled failure of step {step!r} to retry")
2004
+ fail(NO_FAILURE_TO_RETRY, run_id=run_id, step=repr(step))
1940
2005
  retried = candidates if failed_items else candidates[-1:]
1941
2006
  labels = item_labels(run_items(dg, run_id), retried)
1942
2007
  for attempt in retried:
@@ -2005,7 +2070,7 @@ def runs_report(
2005
2070
  if markdown:
2006
2071
  document = dg.call(dg.runs.report_document(run_id))
2007
2072
  if document is None:
2008
- fail(f"run {run_id} has no report document; declare `report:` in the pipeline document")
2073
+ fail(NO_REPORT_DOCUMENT, run_id=run_id)
2009
2074
  if state.json_output:
2010
2075
  return emit_fact("run.report_document", message="rendered", run_id=str(run_id), document=document)
2011
2076
  console.print(document, end="", highlight=False, markup=False)
@@ -2123,7 +2188,7 @@ def blocks_new(
2123
2188
  try:
2124
2189
  written = scaffold_pack(directory.resolve(), name)
2125
2190
  except ScaffoldError as error:
2126
- fail(str(error))
2191
+ fail(SCAFFOLD_REFUSED, detail=str(error))
2127
2192
  root = directory.resolve() / f"dirigent-{name}"
2128
2193
  if state_of(ctx).json_output:
2129
2194
  for path in written:
@@ -2143,7 +2208,7 @@ def blocks_list(
2143
2208
  ) -> None:
2144
2209
  """List the blocks this instance can run."""
2145
2210
  if kind is not None and kind not in set(BlockKind):
2146
- fail(f"{kind!r} is not a block kind ({', '.join(sorted(BlockKind))})")
2211
+ fail(NOT_A_BLOCK_KIND, kind=repr(kind), allowed=", ".join(sorted(BlockKind)))
2147
2212
  with client_for(state_of(ctx)) as dg:
2148
2213
  catalog = dg.call(dg.blocks.catalog(kind=BlockKind(kind) if kind else None))
2149
2214
  if state_of(ctx).json_output:
@@ -2260,7 +2325,7 @@ def connection_create(
2260
2325
  # refusing the invocation for declining it would make the bot-token form of a
2261
2326
  # slack connection unreachable from any script.
2262
2327
  if json_mode() and field in required:
2263
- fail(f"--json cannot prompt for {field}; pass it as --set {field}=...")
2328
+ fail(CANNOT_PROMPT_SET, field=field)
2264
2329
  if not interactive:
2265
2330
  continue
2266
2331
  if _is_secret_field(body):
@@ -2289,7 +2354,7 @@ def _connection_schema(catalog: Catalog, kind_id: str) -> dict[str, Any]:
2289
2354
  if entry.id == kind_id:
2290
2355
  return entry.config_schema
2291
2356
  known = ", ".join(entry.id for entry in catalog.connection_kinds) or "none"
2292
- fail(f"no connection kind {kind_id!r} is installed ({known})")
2357
+ fail(UNKNOWN_CONNECTION_KIND, kind=repr(kind_id), known=known)
2293
2358
 
2294
2359
 
2295
2360
  def _is_secret_field(body: dict[str, Any]) -> bool:
@@ -2378,13 +2443,13 @@ def schema_create(
2378
2443
  try:
2379
2444
  document = read_document(reference)
2380
2445
  except SourceError as error:
2381
- fail(str(error))
2446
+ fail(SOURCE_REFUSED, detail=str(error))
2382
2447
  try:
2383
2448
  body = yaml.safe_load(document.text)
2384
2449
  except yaml.YAMLError as error:
2385
- fail(f"{document.label} is not readable JSON or YAML: {error}")
2450
+ fail(SCHEMA_UNREADABLE, label=document.label, detail=str(error))
2386
2451
  if not isinstance(body, dict):
2387
- fail(f"{document.label} is not a JSON Schema: a schema is an object, and this is {type(body).__name__}")
2452
+ fail(SCHEMA_NOT_AN_OBJECT, label=document.label, kind=type(body).__name__)
2388
2453
  schema = cast("JsonMap", body)
2389
2454
  resolved = code
2390
2455
  if resolved is None and not (isinstance(schema.get("$id"), str) and code_from_id(cast("str", schema["$id"]))):
@@ -2652,9 +2717,10 @@ def auth_status(ctx: typer.Context) -> None:
2652
2717
  state = state_of(ctx)
2653
2718
  if state.resolved.token is None:
2654
2719
  refuse(
2655
- f"no token for {state.resolved.url}",
2720
+ NOT_AUTHENTICATED,
2656
2721
  title="Not authenticated",
2657
- problems=["set DG_TOKEN", "or add a token to a profile", "or mint one with dg auth login"],
2722
+ problems=[Issue.of(SET_DG_TOKEN), Issue.of(OR_A_PROFILE_TOKEN), Issue.of(OR_DG_AUTH_LOGIN)],
2723
+ url=state.resolved.url,
2658
2724
  )
2659
2725
  raise typer.Exit(code=1)
2660
2726
  with client_for(state) as dg:
@@ -2723,7 +2789,7 @@ def _example(state: CliState, code: str, *, local: bool) -> ExampleDetail:
2723
2789
  try:
2724
2790
  return as_detail(load_plugin_host().example(code))
2725
2791
  except UnknownExample as error:
2726
- refuse(str(error), title="No such example")
2792
+ refuse(NO_SUCH_EXAMPLE, title="No such example", detail=str(error))
2727
2793
  raise typer.Exit(code=1) from error
2728
2794
 
2729
2795
 
@@ -2814,15 +2880,16 @@ def pipeline_new(
2814
2880
  entry = _example(state_of(ctx), starter, local=local)
2815
2881
  if not entry.starter:
2816
2882
  refuse(
2817
- f"{entry.code!r} is an example, not a starter",
2883
+ NOT_A_STARTER,
2818
2884
  title="Not a starter",
2819
- problems=[f"a document is copyable only when it wears the {STARTER_TAG!r} tag"],
2885
+ problems=[Issue.of(STARTER_TAG_NEEDED, tag=repr(STARTER_TAG))],
2886
+ code=repr(entry.code),
2820
2887
  )
2821
2888
  raise typer.Exit(code=1)
2822
2889
  new_code = code or entry.code
2823
2890
  path = directory / f"{new_code}.yaml"
2824
2891
  if path.exists():
2825
- refuse(f"{path} is already there", title="Already there", problems=["name another code with --code"])
2892
+ refuse(ALREADY_THERE, title="Already there", problems=[Issue.of(NAME_ANOTHER_CODE)], path=path)
2826
2893
  raise typer.Exit(code=1)
2827
2894
  text = starters.instantiate(entry.source, new_code)
2828
2895
  directory.mkdir(parents=True, exist_ok=True)