dirigent-cli 0.21.0__tar.gz → 0.22.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 (37) hide show
  1. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/PKG-INFO +9 -9
  2. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/pyproject.toml +9 -9
  3. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/commands.py +3 -1
  5. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/context.py +8 -1
  6. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/explain.py +31 -11
  7. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/main.py +8 -3
  8. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/messages.py +10 -1
  9. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/output.py +39 -9
  10. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/profiles.py +27 -10
  11. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/schemas.py +4 -1
  12. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/sources.py +4 -1
  13. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/LICENSE +0 -0
  14. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/README.md +0 -0
  15. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/__init__.py +0 -0
  16. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/aliases.py +0 -0
  17. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/formatters.py +0 -0
  18. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/graph.py +0 -0
  19. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/health.py +0 -0
  20. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/init_form.py +0 -0
  21. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/local.py +0 -0
  22. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/params.py +0 -0
  23. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/project.py +0 -0
  24. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/py.typed +0 -0
  25. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/reaper.py +0 -0
  26. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/scaffold.py +0 -0
  27. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/seeding.py +0 -0
  28. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/starters.py +0 -0
  29. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/stream.py +0 -0
  30. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/summaries.py +0 -0
  31. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  32. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  33. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  34. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  35. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  36. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/timing.py +0 -0
  37. {dirigent_cli-0.21.0 → dirigent_cli-0.22.0}/src/dirigent_cli/triggers.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.21.0
3
+ Version: 0.22.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-block-execute==0.21.0
10
- Requires-Dist: dirigent-blocks==0.21.0
11
- Requires-Dist: dirigent-client==0.21.0
12
- Requires-Dist: dirigent-common==0.21.0
13
- Requires-Dist: dirigent-core==0.21.0
14
- Requires-Dist: dirigent-examples==0.21.0
15
- Requires-Dist: dirigent-plugin==0.21.0
16
- Requires-Dist: dirigent-server==0.21.0
9
+ Requires-Dist: dirigent-block-execute==0.22.0
10
+ Requires-Dist: dirigent-blocks==0.22.0
11
+ Requires-Dist: dirigent-client==0.22.0
12
+ Requires-Dist: dirigent-common==0.22.0
13
+ Requires-Dist: dirigent-core==0.22.0
14
+ Requires-Dist: dirigent-examples==0.22.0
15
+ Requires-Dist: dirigent-plugin==0.22.0
16
+ Requires-Dist: dirigent-server==0.22.0
17
17
  Requires-Dist: httpx2>=2.12.0
18
18
  Requires-Dist: python-dotenv>=1.1.0
19
19
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.21.0"
3
+ version = "0.22.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.21.0",
15
- "dirigent-blocks==0.21.0",
16
- "dirigent-client==0.21.0",
17
- "dirigent-common==0.21.0",
18
- "dirigent-core==0.21.0",
19
- "dirigent-examples==0.21.0",
20
- "dirigent-plugin==0.21.0",
21
- "dirigent-server==0.21.0",
14
+ "dirigent-block-execute==0.22.0",
15
+ "dirigent-blocks==0.22.0",
16
+ "dirigent-client==0.22.0",
17
+ "dirigent-common==0.22.0",
18
+ "dirigent-core==0.22.0",
19
+ "dirigent-examples==0.22.0",
20
+ "dirigent-plugin==0.22.0",
21
+ "dirigent-server==0.22.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.21.0"
3
+ version = "0.22.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.21.0",
15
- "dirigent-blocks==0.21.0",
16
- "dirigent-client==0.21.0",
17
- "dirigent-common==0.21.0",
18
- "dirigent-core==0.21.0",
19
- "dirigent-examples==0.21.0",
20
- "dirigent-plugin==0.21.0",
21
- "dirigent-server==0.21.0",
14
+ "dirigent-block-execute==0.22.0",
15
+ "dirigent-blocks==0.22.0",
16
+ "dirigent-client==0.22.0",
17
+ "dirigent-common==0.22.0",
18
+ "dirigent-core==0.22.0",
19
+ "dirigent-examples==0.22.0",
20
+ "dirigent-plugin==0.22.0",
21
+ "dirigent-server==0.22.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -86,8 +86,10 @@ from dirigent_cli.messages import (
86
86
  )
87
87
  from dirigent_cli.messages import WINDOW_GRAMMAR as WINDOW_GRAMMAR_MESSAGE
88
88
  from dirigent_cli.output import (
89
+ CONNECTION_HEALTH,
89
90
  Detail,
90
91
  age,
92
+ connection_health,
91
93
  console,
92
94
  elapsed,
93
95
  emit_event,
@@ -2435,7 +2437,7 @@ def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()])
2435
2437
  report = dg.call(dg.connections.check(code))
2436
2438
  emit_fact(
2437
2439
  "connection.checked",
2438
- message="not verified" if report.healthy is None else "healthy" if report.healthy else "unhealthy",
2440
+ message=CONNECTION_HEALTH[connection_health(report.healthy)],
2439
2441
  code=code,
2440
2442
  healthy=report.healthy,
2441
2443
  detail=report.detail,
@@ -143,4 +143,11 @@ class Session(BlockingDirigent):
143
143
  def client_for(state: CliState, *, needs_token: bool = True) -> Session:
144
144
  """Build the API client for a resolved endpoint."""
145
145
  endpoint = state.endpoint(needs_token=needs_token)
146
- return Session(url=endpoint.url, token=endpoint.token, api_prefix=endpoint.api_prefix)
146
+ return Session(
147
+ url=endpoint.url,
148
+ token=endpoint.token,
149
+ api_prefix=endpoint.api_prefix,
150
+ timeout=endpoint.timeout.total_seconds(),
151
+ connect_timeout=endpoint.connect_timeout.total_seconds(),
152
+ retries=endpoint.retries,
153
+ )
@@ -7,9 +7,10 @@ under a deadline. What only the run can decide -- a parameter supplied at the co
7
7
  a window's bounds -- is reported as unknown rather than guessed at, and a total that counted
8
8
  one of those says so.
9
9
 
10
- The cardinality rules are :func:`dirigent_core.engine.runs.resolve_fan_out`'s: a literal list
11
- is its own length, ``${steps.<name>.items}`` adopts another fan-out's grid, a reference that
12
- resolves to a list is that list, and anything else is only the run's to answer.
10
+ The cardinality rules are the engine's: a literal list is its own length,
11
+ ``${steps.<name>.items}`` adopts another fan-out's grid, a reference that resolves to a list is
12
+ that list, one that reads a step's output is as wide as that step makes it while the run
13
+ executes, and anything else is only the run's to answer.
13
14
  """
14
15
 
15
16
  from collections.abc import Mapping, Sequence
@@ -31,6 +32,9 @@ UNKNOWN: Final = "unknown"
31
32
  #: How a cardinality names the fan-out whose grid a step maps over instead of its own.
32
33
  ADOPTS: Final = "adopts {step}"
33
34
 
35
+ #: How a cardinality names the step whose output a late grid is expanded from.
36
+ LATE: Final = "from {step}"
37
+
34
38
 
35
39
  class DocumentShape(BaseModel):
36
40
  """What a whole document will cost: the steps, the totals over them, and the warnings."""
@@ -61,7 +65,7 @@ def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> D
61
65
  order = definition.topological_order()
62
66
  shapes: dict[str, StepShape] = {}
63
67
  for name in order:
64
- shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog)
68
+ shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog, definition.grid_source(name))
65
69
  rows = [shapes[name] for name in order]
66
70
  return DocumentShape(
67
71
  attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
@@ -90,9 +94,13 @@ def _step_shape(
90
94
  params: JsonMap,
91
95
  done: Mapping[str, StepShape],
92
96
  catalog: Catalog | None,
97
+ source: str | None,
93
98
  ) -> StepShape:
94
- """Read one step as the work it will become, against the steps already read."""
95
- cardinality, elements = _cardinality(step, params, done)
99
+ """Read one step as the work it will become, against the steps already read.
100
+
101
+ ``source`` is the step a late grid waits for, which the row names.
102
+ """
103
+ cardinality, elements = _cardinality(step, params, done, source)
96
104
  poll, deadline, from_block = _waits(step, _entry(step, catalog))
97
105
  return StepShape(
98
106
  name=name,
@@ -100,6 +108,7 @@ def _step_shape(
100
108
  depends_on=list(step.depends_on),
101
109
  cardinality=cardinality,
102
110
  elements=elements,
111
+ grid_source=source,
103
112
  reference=step.for_each if isinstance(step.for_each, str) else None,
104
113
  items=step.items.value,
105
114
  max_attempts=step.retry.max_attempts,
@@ -112,7 +121,9 @@ def _step_shape(
112
121
  )
113
122
 
114
123
 
115
- def _cardinality(step: StepDefinition, params: JsonMap, done: Mapping[str, StepShape]) -> tuple[int | str, int | None]:
124
+ def _cardinality(
125
+ step: StepDefinition, params: JsonMap, done: Mapping[str, StepShape], source: str | None
126
+ ) -> tuple[int | str, int | None]:
116
127
  """Say how many run items a step becomes, and the count behind that where there is one."""
117
128
  expression = step.for_each
118
129
  if expression is None:
@@ -123,10 +134,8 @@ def _cardinality(step: StepDefinition, params: JsonMap, done: Mapping[str, StepS
123
134
  if adopted is not None:
124
135
  upstream = done[adopted].elements if adopted in done else None
125
136
  return ADOPTS.format(step=adopted), upstream
126
- if "steps." in expression:
127
- # resolve_fan_out refuses a step's output here, because cardinality is fixed when the
128
- # run is created and no step has run by then.
129
- return UNKNOWN, None
137
+ if source is not None:
138
+ return LATE.format(step=source), None
130
139
  try:
131
140
  resolved = resolve(expression, ReferenceScope(params=params))
132
141
  except UnknownReference:
@@ -202,6 +211,17 @@ def _warnings(rows: Sequence[StepShape], catalog: Catalog | None) -> list[ShapeW
202
211
  ),
203
212
  )
204
213
  )
214
+ if row.grid_source is not None and row.cardinality == LATE.format(step=row.grid_source):
215
+ found.append(
216
+ ShapeWarning(
217
+ step=row.name,
218
+ cause="late-cardinality",
219
+ message=(
220
+ f"{row.name} fans out over {row.reference}, which {row.grid_source} produces while "
221
+ f"the run executes, so it is counted as one item here."
222
+ ),
223
+ )
224
+ )
205
225
  if row.retry_wait is not None and row.deadline is not None and row.retry_wait > row.deadline:
206
226
  found.append(
207
227
  ShapeWarning(
@@ -50,7 +50,7 @@ from dirigent_cli.messages import (
50
50
  )
51
51
  from dirigent_cli.output import configure, detail_mode, emit_fact, emit_problem, emit_rendered, refuse
52
52
  from dirigent_client.enums import UserRole
53
- from dirigent_common import Issue
53
+ from dirigent_common import Issue, raised_detail, validation_issues
54
54
  from dirigent_core import migrations
55
55
  from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
56
56
  from dirigent_core.logging import configure_logging, silence_stdout
@@ -402,7 +402,7 @@ async def _ensure_container(settings: Settings) -> "ContainerResult":
402
402
  except DomainError as error:
403
403
  commands.fail(STORE_UNCONFIGURED, root=settings.artifact_root, detail=str(error))
404
404
  except Exception as error:
405
- commands.fail(STORE_UNREACHABLE, root=settings.artifact_root, detail=f"{type(error).__name__}: {error}")
405
+ commands.fail(STORE_UNREACHABLE, root=settings.artifact_root, detail=raised_detail(error))
406
406
  finally:
407
407
  await engine.dispose()
408
408
 
@@ -443,7 +443,12 @@ def connection_ensure(
443
443
  except ValidationError as error:
444
444
  # include_input=False: the input here is a credential, and pydantic's default error
445
445
  # payload echoes the value that failed.
446
- commands.fail(CONNECTION_UNUSABLE, code=code, kind=kind_id, detail=str(error.errors(include_input=False)))
446
+ commands.fail(
447
+ CONNECTION_UNUSABLE,
448
+ code=code,
449
+ kind=kind_id,
450
+ problems=validation_issues(error.errors(include_input=False)),
451
+ )
447
452
  key = settings.secret_key.get_secret_value() if settings.secret_key else None
448
453
  try:
449
454
  public, envelope, key_id = SecretBox(key).encrypt_config(model, validated)
@@ -25,7 +25,7 @@ RUN_DB_UPGRADE = CLI.define("run_db_upgrade", "run dg db upgrade")
25
25
 
26
26
  UNKNOWN_CONNECTION_KIND = CLI.define("unknown_connection_kind", "no connection kind {kind} is installed ({known})")
27
27
 
28
- CONNECTION_UNUSABLE = CLI.define("connection_unusable", "{code} is not a usable {kind} connection: {detail}")
28
+ CONNECTION_UNUSABLE = CLI.define("connection_unusable", "{code} is not a usable {kind} connection")
29
29
 
30
30
  INVALID_AGE = CLI.define("invalid_age", "{detail}")
31
31
 
@@ -189,6 +189,15 @@ SCHEMA_NOT_AN_OBJECT = CLI.define(
189
189
  "{label} is not a JSON Schema: a schema is an object, and this is {kind}",
190
190
  )
191
191
 
192
+ # What a profile refuses at validation. Pydantic owns the code a validator's refusal reaches
193
+ # the wire under, so this is rendered into the ``ValueError`` it wraps.
194
+
195
+ PROFILE_NAMES_A_DATABASE = CLI.define(
196
+ "profile_names_a_database",
197
+ "profile {name} names a database URL. Profiles address a server over HTTP; "
198
+ "a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it",
199
+ )
200
+
192
201
  NOT_AUTHENTICATED = CLI.define("not_authenticated", "no token for {url}")
193
202
 
194
203
  SET_DG_TOKEN = CLI.define("set_dg_token", "set DG_TOKEN")
@@ -474,16 +474,24 @@ def prioritised(priority: object) -> str:
474
474
  return ""
475
475
 
476
476
 
477
+ #: The floor an alert rule fires at, said beside its scope. The web UI writes this half in the
478
+ #: same words out of its own catalogue, and `tests/test_shared_words.py` holds the two together.
479
+ IMPORTANCE_FLOOR = "{importance} and above"
480
+
481
+
477
482
  def watching(scope: object, importance: object) -> str:
478
483
  """What an alert rule watches, in one cell: its scope, and the floor it fires at.
479
484
 
480
485
  Almost every rule names no importance, so a column of its own would mostly be empty.
481
486
  """
482
- return f"{scope}, {importance} and above" if importance else str(scope)
487
+ if not importance:
488
+ return str(scope)
489
+ return f"{scope}, {IMPORTANCE_FLOOR.format(importance=importance)}"
483
490
 
484
491
 
485
492
  #: What each alert event is called in a rendering. The wire's word is what a rule is declared
486
- #: with; these are the words a reader is shown, here and on the web UI's own listing.
493
+ #: with; these are the words a reader is shown. The web UI draws the same four out of its own
494
+ #: catalogue, and `tests/test_shared_words.py` fails when the two tables stop agreeing.
487
495
  ALERT_EVENTS: Mapping[str, str] = {
488
496
  "run_failed": "Failed",
489
497
  "run_completed_with_errors": "Completed with errors",
@@ -529,17 +537,39 @@ def render_bool(value: object) -> str:
529
537
  return "[green]yes[/]" if value else "[dim]no[/]"
530
538
 
531
539
 
540
+ #: What a connection's last check is called, keyed by the state the web UI keys it by. A
541
+ #: credential's health is one vocabulary across the product, so these are the bundle's own four
542
+ #: words and `tests/test_shared_words.py` fails when the two tables stop agreeing.
543
+ CONNECTION_HEALTH: Mapping[str, str] = {
544
+ "unchecked": "never checked",
545
+ "unverified": "not verified",
546
+ "healthy": "healthy",
547
+ "failed": "failed",
548
+ }
549
+
550
+
551
+ def connection_health(healthy: object) -> str:
552
+ """Say which state a check that ran left a connection in.
553
+
554
+ Args:
555
+ healthy: What the check decided, or None where the kind publishes no check.
556
+
557
+ Returns:
558
+ The key into `CONNECTION_HEALTH`.
559
+ """
560
+ if healthy is None:
561
+ return "unverified"
562
+ return "healthy" if healthy else "failed"
563
+
564
+
532
565
  def render_check(last_check_at: object, healthy: object) -> str:
533
566
  """Render what a connection's last check said, across the four states a row can be in.
534
567
 
535
- A check answers yes, no, or that it could not decide; a row nothing has checked answers
536
- none of the three.
568
+ A check answers that the system is there, that it is not, or that the kind publishes no
569
+ check at all; a row nothing has ever asked is in none of the three.
537
570
  """
538
- if not last_check_at:
539
- return "-"
540
- if healthy is None:
541
- return "[dim]not verified[/]"
542
- return "[green]yes[/]" if healthy else "[dim]no[/]"
571
+ state = connection_health(healthy) if last_check_at else "unchecked"
572
+ return f"[{STATUS_STYLES.get(state, 'dim')}]{CONNECTION_HEALTH[state]}[/]"
543
573
 
544
574
 
545
575
  def moment(value: object) -> str:
@@ -1,13 +1,14 @@
1
- """Profiles: which server the CLI talks to, and how it gets a token for it.
1
+ """Profiles: which server the CLI talks to, how it gets a token for it, and how patient it is.
2
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.
3
+ A profile is client-side only and must never hold a database URL: a CLI that could reach the
4
+ database would bypass authentication, attribution, and validation.
5
5
  """
6
6
 
7
7
  import os
8
8
  import shutil
9
9
  import subprocess
10
10
  from collections.abc import Mapping
11
+ from datetime import timedelta
11
12
  from pathlib import Path
12
13
  from typing import Any, Final, cast
13
14
 
@@ -15,8 +16,9 @@ import yaml
15
16
  from dotenv import dotenv_values
16
17
  from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
17
18
 
18
- from dirigent_client import API_PREFIX
19
- from dirigent_common import EntityName
19
+ from dirigent_cli.messages import PROFILE_NAMES_A_DATABASE
20
+ from dirigent_client import API_PREFIX, DEFAULT_CONNECT_TIMEOUT, DEFAULT_RETRIES, DEFAULT_TIMEOUT
21
+ from dirigent_common import Duration, EntityName
20
22
 
21
23
  PROJECT_PROFILES: Final = Path(".dirigent") / "profiles.yaml"
22
24
  PROJECT_ENV_FILE: Final = ".env"
@@ -32,13 +34,16 @@ DATABASE_SCHEMES: Final = ("postgresql", "postgres", "sqlite", "mysql")
32
34
 
33
35
  TOKEN_COMMAND_TIMEOUT: Final = 30.0
34
36
 
37
+ DEFAULT_REQUEST_TIMEOUT: Final = timedelta(seconds=DEFAULT_TIMEOUT)
38
+ DEFAULT_CONNECT_BUDGET: Final = timedelta(seconds=DEFAULT_CONNECT_TIMEOUT)
39
+
35
40
 
36
41
  class ProfileError(Exception):
37
42
  """A profile could not be read, found, or turned into a usable token."""
38
43
 
39
44
 
40
45
  class Profile(BaseModel):
41
- """One named server and how to obtain a token for it."""
46
+ """One named server, how to obtain a token for it, and how long to wait on it."""
42
47
 
43
48
  model_config = ConfigDict(frozen=True)
44
49
 
@@ -53,15 +58,21 @@ class Profile(BaseModel):
53
58
  api_prefix: str = API_PREFIX
54
59
  """Where this instance serves its API, for one configured with a different prefix."""
55
60
 
61
+ timeout: Duration = DEFAULT_REQUEST_TIMEOUT
62
+ """How long a request to this instance may take to answer."""
63
+
64
+ connect_timeout: Duration = DEFAULT_CONNECT_BUDGET
65
+ """How long reaching this instance may take before it is called unreachable."""
66
+
67
+ retries: int = Field(default=DEFAULT_RETRIES, ge=0)
68
+ """How many further attempts a lost or refused-with-a-5xx request gets."""
69
+
56
70
  @model_validator(mode="after")
57
71
  def _refuse_a_database_url(self) -> "Profile":
58
72
  """Refuse a profile pointing at a database rather than at an API."""
59
73
  scheme = self.url.split("://", 1)[0].split("+", 1)[0].lower()
60
74
  if scheme in DATABASE_SCHEMES:
61
- raise ValueError(
62
- f"profile {self.name!r} names a database URL. Profiles address a server over HTTP; "
63
- f"a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it"
64
- )
75
+ raise ValueError(PROFILE_NAMES_A_DATABASE.render(name=repr(self.name)))
65
76
  return self
66
77
 
67
78
  def resolve_token(self, environ: Mapping[str, str] | None = None) -> str | None:
@@ -193,6 +204,9 @@ class Endpoint(BaseModel):
193
204
  profile: str | None = None
194
205
  source: str = "default"
195
206
  api_prefix: str = API_PREFIX
207
+ timeout: Duration = DEFAULT_REQUEST_TIMEOUT
208
+ connect_timeout: Duration = DEFAULT_CONNECT_BUDGET
209
+ retries: int = DEFAULT_RETRIES
196
210
 
197
211
 
198
212
  def resolve_endpoint(
@@ -236,4 +250,7 @@ def resolve_endpoint(
236
250
  profile=chosen.name if chosen else None,
237
251
  source=source,
238
252
  api_prefix=chosen.api_prefix if chosen else API_PREFIX,
253
+ timeout=chosen.timeout if chosen else DEFAULT_REQUEST_TIMEOUT,
254
+ connect_timeout=chosen.connect_timeout if chosen else DEFAULT_CONNECT_BUDGET,
255
+ retries=chosen.retries if chosen else DEFAULT_RETRIES,
239
256
  )
@@ -65,11 +65,14 @@ class StepShape(BaseModel):
65
65
  depends_on: list[str] = Field(default_factory=list[str])
66
66
 
67
67
  cardinality: int | str = 1
68
- """How many run items this step becomes: a count, ``adopts <step>``, or ``unknown``."""
68
+ """How many run items this step becomes: a count, ``adopts <step>``, ``from <step>``, or ``unknown``."""
69
69
 
70
70
  elements: int | None = 1
71
71
  """The count behind the cardinality, which an adoption takes from the grid it adopts."""
72
72
 
73
+ grid_source: str | None = None
74
+ """The step a late grid waits for before it expands, or null for a grid fixed at creation."""
75
+
73
76
  reference: str | None = None
74
77
  """The ``for_each`` this step maps over, where it names one rather than listing it."""
75
78
 
@@ -8,10 +8,13 @@ import httpx2
8
8
  import yaml
9
9
  from pydantic import BaseModel, ConfigDict
10
10
 
11
+ from dirigent_client import DEFAULT_CONNECT_TIMEOUT
11
12
  from dirigent_client.enums import ProvenanceSource
12
13
 
13
14
  STDIN: Final = "-"
14
- FETCH_TIMEOUT: Final = 30.0
15
+
16
+ #: The body may take thirty seconds to arrive, and reaching the host that serves it two.
17
+ FETCH_TIMEOUT: Final = httpx2.Timeout(30.0, connect=DEFAULT_CONNECT_TIMEOUT)
15
18
 
16
19
 
17
20
  class SourceError(Exception):
File without changes
File without changes