dirigent-cli 0.17.2__tar.gz → 0.18.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.17.2 → dirigent_cli-0.18.0}/PKG-INFO +9 -9
  2. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/pyproject.toml +9 -9
  3. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/commands.py +5 -4
  5. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/init_form.py +1 -1
  6. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/main.py +6 -5
  7. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/project.py +1 -1
  8. dirigent_cli-0.18.0/src/dirigent_cli/starters.py +293 -0
  9. dirigent_cli-0.17.2/src/dirigent_cli/starters.py +0 -121
  10. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/LICENSE +0 -0
  11. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/README.md +0 -0
  12. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/__init__.py +0 -0
  13. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/aliases.py +0 -0
  14. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/context.py +0 -0
  15. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/formatters.py +0 -0
  16. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/graph.py +0 -0
  17. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/health.py +0 -0
  18. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/local.py +0 -0
  19. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/messages.py +0 -0
  20. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/output.py +0 -0
  21. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/params.py +0 -0
  22. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/profiles.py +0 -0
  23. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/py.typed +0 -0
  24. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/reaper.py +0 -0
  25. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/scaffold.py +0 -0
  26. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/schemas.py +0 -0
  27. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/seeding.py +0 -0
  28. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/sources.py +0 -0
  29. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/stream.py +0 -0
  30. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/summaries.py +0 -0
  31. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  32. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  33. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  34. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  35. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  36. {dirigent_cli-0.17.2 → dirigent_cli-0.18.0}/src/dirigent_cli/timing.py +0 -0
  37. {dirigent_cli-0.17.2 → dirigent_cli-0.18.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.17.2
3
+ Version: 0.18.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.17.2
10
- Requires-Dist: dirigent-blocks==0.17.2
11
- Requires-Dist: dirigent-client==0.17.2
12
- Requires-Dist: dirigent-common==0.17.2
13
- Requires-Dist: dirigent-core==0.17.2
14
- Requires-Dist: dirigent-examples==0.17.2
15
- Requires-Dist: dirigent-plugin==0.17.2
16
- Requires-Dist: dirigent-server==0.17.2
9
+ Requires-Dist: dirigent-block-execute==0.18.0
10
+ Requires-Dist: dirigent-blocks==0.18.0
11
+ Requires-Dist: dirigent-client==0.18.0
12
+ Requires-Dist: dirigent-common==0.18.0
13
+ Requires-Dist: dirigent-core==0.18.0
14
+ Requires-Dist: dirigent-examples==0.18.0
15
+ Requires-Dist: dirigent-plugin==0.18.0
16
+ Requires-Dist: dirigent-server==0.18.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.17.2"
3
+ version = "0.18.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.17.2",
15
- "dirigent-blocks==0.17.2",
16
- "dirigent-client==0.17.2",
17
- "dirigent-common==0.17.2",
18
- "dirigent-core==0.17.2",
19
- "dirigent-examples==0.17.2",
20
- "dirigent-plugin==0.17.2",
21
- "dirigent-server==0.17.2",
14
+ "dirigent-block-execute==0.18.0",
15
+ "dirigent-blocks==0.18.0",
16
+ "dirigent-client==0.18.0",
17
+ "dirigent-common==0.18.0",
18
+ "dirigent-core==0.18.0",
19
+ "dirigent-examples==0.18.0",
20
+ "dirigent-plugin==0.18.0",
21
+ "dirigent-server==0.18.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.17.2"
3
+ version = "0.18.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.17.2",
15
- "dirigent-blocks==0.17.2",
16
- "dirigent-client==0.17.2",
17
- "dirigent-common==0.17.2",
18
- "dirigent-core==0.17.2",
19
- "dirigent-examples==0.17.2",
20
- "dirigent-plugin==0.17.2",
21
- "dirigent-server==0.17.2",
14
+ "dirigent-block-execute==0.18.0",
15
+ "dirigent-blocks==0.18.0",
16
+ "dirigent-client==0.18.0",
17
+ "dirigent-common==0.18.0",
18
+ "dirigent-core==0.18.0",
19
+ "dirigent-examples==0.18.0",
20
+ "dirigent-plugin==0.18.0",
21
+ "dirigent-server==0.18.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -1838,7 +1838,7 @@ def runs_list(
1838
1838
  list[str] | None,
1839
1839
  typer.Option("--tag", help="Only runs whose pipeline wears this tag; repeat it to name more."),
1840
1840
  ] = None,
1841
- limit: Annotated[int, typer.Option(help="How many runs at most; the server decides when omitted.")] = RUNS_PAGE,
1841
+ limit: Annotated[int, typer.Option(help=f"How many runs at most; {RUNS_PAGE} when omitted.")] = RUNS_PAGE,
1842
1842
  ) -> None:
1843
1843
  """List runs, newest first, or the ones whose pipeline wears every tag named."""
1844
1844
  if status is not None and status not in set(RunStatus):
@@ -2873,8 +2873,9 @@ def pipeline_new(
2873
2873
  ) -> None:
2874
2874
  """Copy a starter into this project as a pipeline of its own.
2875
2875
 
2876
- The copy is the starter's text verbatim, with the top-level `code:` rewritten and the
2877
- `starter` tag dropped, so every teaching comment in it survives. What it needs from the
2876
+ The copy is the starter's text verbatim, with the top-level `code:` rewritten, the
2877
+ `starter` tag dropped, and the connections and schemas the original carried named under
2878
+ `requires:` instead, so every teaching comment in it survives. What it needs from the
2878
2879
  instance is the copy's own `requires`, which this prints as the list to work through.
2879
2880
  """
2880
2881
  entry = _example(state_of(ctx), starter, local=local)
@@ -2902,5 +2903,5 @@ def pipeline_new(
2902
2903
  code=new_code,
2903
2904
  starter=entry.code,
2904
2905
  requires=copied.model_dump(mode="json"),
2905
- preflight=starters.preflight(copied),
2906
+ preflight=starters.preflight(copied, starters.carried_kinds(entry.source)),
2906
2907
  )
@@ -244,7 +244,7 @@ class InitForm(App[InitChoices | None]):
244
244
  elif choices.instance:
245
245
  files += [".env", ".dirigent/state/"]
246
246
  if choices.workflow:
247
- files.append(".github/workflows/dirigent.yml")
247
+ files.append(".github/workflows/dirigent.yaml")
248
248
  self.query_one("#files", Label).update("Will write: " + ", ".join(files))
249
249
 
250
250
  def on_button_pressed(self, event: Button.Pressed) -> None:
@@ -51,7 +51,7 @@ from dirigent_client.enums import UserRole
51
51
  from dirigent_common import Issue
52
52
  from dirigent_core import migrations
53
53
  from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
54
- from dirigent_core.logging import LOG_FORMAT_ENV, configure_logging, silence_stdout
54
+ from dirigent_core.logging import configure_logging, silence_stdout
55
55
  from dirigent_core.protocol import FORMATS, Format, Record, make
56
56
  from dirigent_core.telemetry import configure_telemetry
57
57
 
@@ -194,14 +194,15 @@ def main_callback(
194
194
 
195
195
 
196
196
  def resolve_output(named: str | None, *, json_output: bool) -> Format:
197
- """Resolve the output: the flag, then the environment, then the terminal.
197
+ """Resolve the output: the flag, then the ``log_format`` setting, then the terminal.
198
198
 
199
- ``--json`` is the same request as ``--output json``, and the environment is where a
200
- container says it once. Unasked, a terminal gets the rendering and anything else gets
199
+ ``--json`` is the same request as ``--output json``. ``log_format`` is where a container,
200
+ a project's ``.env`` or its ``dirigent.yaml`` says it once, read through the same layers
201
+ every other setting is. Unasked, a terminal gets the rendering and anything else gets
201
202
  NDJSON: a pipe, a container's log, an agent's shell and CI are never terminals, so a
202
203
  script reads records without asking, and a person reads lines without asking.
203
204
  """
204
- chosen = named or ("json" if json_output else None) or os.environ.get(LOG_FORMAT_ENV)
205
+ chosen = named or ("json" if json_output else None) or get_settings().log_format
205
206
  if chosen is None:
206
207
  return "console" if sys.stdout.isatty() else "json"
207
208
  resolved = chosen.lower()
@@ -919,7 +919,7 @@ def scaffold(directory: Path, choices: InitChoices, *, version: str = "0.0.0") -
919
919
  _record(directory / "README.md", readme, written, skipped)
920
920
  _merge_ignore(directory / ".gitignore", ROOT_IGNORE_TEMPLATE, written, skipped)
921
921
  if choices.workflow:
922
- written.append(_write(directory / ".github" / "workflows" / "dirigent.yml", WORKFLOW_TEMPLATE))
922
+ written.append(_write(directory / ".github" / "workflows" / "dirigent.yaml", WORKFLOW_TEMPLATE))
923
923
  if choices.stack:
924
924
  written.append(_write(directory / "compose.yaml", compose_document(choices, version)))
925
925
  written.append(_write(directory / "Dockerfile", dockerfile_document(choices, version)))
@@ -0,0 +1,293 @@
1
+ """Copying a starter: the lines a copy rewrites, and nothing else.
2
+
3
+ A starter is a plain ``dirigent/v1`` document, not a template, so instantiating one is a
4
+ verbatim copy of its text with the top-level ``code:`` changed, ``starter`` taken off the
5
+ top-level ``tags:``, and every section the document carried named under ``requires:``
6
+ instead. A document carries its connections and its schemas so that it runs alone under
7
+ ``dg run --local``, and an instance refuses to store one that does, so a copy names them.
8
+ The edit is at text level rather than through a parser because the teaching comments, the
9
+ blank lines and the quoting are the point of copying a document instead of generating one.
10
+ """
11
+
12
+ import re
13
+ from collections.abc import Mapping, Sequence
14
+ from typing import Final, cast
15
+
16
+ from dirigent_client import Requirements
17
+ from dirigent_common import JsonMap
18
+ from dirigent_core.documents import CARRIED, safe_load
19
+ from dirigent_core.examples import STARTER_TAG
20
+
21
+ #: The top-level ``code:`` line: no indentation, so a step's own ``code`` is never touched.
22
+ _CODE = re.compile(r"^code:[^\S\n]*(.*)$", re.MULTILINE)
23
+
24
+ #: The top-level ``tags:`` line, and whatever it carries on the same line.
25
+ _TAGS = re.compile(r"^tags:[^\S\n]*(.*)$", re.MULTILINE)
26
+
27
+ #: One entry of a block list under ``tags:``: two spaces, a dash, the value.
28
+ _TAG_ITEM = re.compile(r"^[^\S\n]*-[^\S\n]*(\S.*?)[^\S\n]*$")
29
+
30
+ #: A mapping key on its own line: its indentation, its name, and what follows the colon.
31
+ _KEY = re.compile(r"^([^\S\n]*)([^\s#][^:]*):[^\S\n]*(.*)$")
32
+
33
+ #: One entry of an indented block list: its indentation, and the value.
34
+ _ITEM = re.compile(r"^([^\S\n]+)-[^\S\n]+(\S.*?)[^\S\n]*$")
35
+
36
+ #: Where a ``requires:`` section goes in a document that has none: after the first of these.
37
+ _ANCHORS: Final = ("tags", "description", "code")
38
+
39
+ _FLOW: Final = ("[", "]")
40
+
41
+
42
+ def instantiate(source: str, code: str) -> str:
43
+ """Copy a starter's text under a new code, naming what the original carried."""
44
+ return _uncarry(_retag(_recode(source, code)))
45
+
46
+
47
+ def _recode(source: str, code: str) -> str:
48
+ """Rewrite the one top-level ``code:`` line, leaving every other line alone."""
49
+ return _CODE.sub(lambda _: f"code: {code}", source, count=1)
50
+
51
+
52
+ def _retag(source: str) -> str:
53
+ """Take ``starter`` off the top-level ``tags:``, whichever shape the list is written in.
54
+
55
+ A document whose only tag was ``starter`` loses the whole ``tags:`` entry: an empty list
56
+ says less than no list at all.
57
+ """
58
+ found = _TAGS.search(source)
59
+ if found is None:
60
+ return source
61
+ rest = found.group(1).strip()
62
+ if rest.startswith(_FLOW[0]):
63
+ return _rewrite_flow(source, found.start(), found.end(), rest)
64
+ if rest:
65
+ return source
66
+ return _rewrite_block(source, found.start(), found.end())
67
+
68
+
69
+ def _rewrite_flow(source: str, start: int, end: int, rest: str) -> str:
70
+ """Rewrite ``tags: [a, b, starter]``, dropping the entry when nothing is left."""
71
+ inner = rest.removeprefix(_FLOW[0]).removesuffix(_FLOW[1])
72
+ kept = [tag for tag in (one.strip() for one in inner.split(",")) if tag and tag != STARTER_TAG]
73
+ if not kept:
74
+ return _drop_line(source, start, end)
75
+ return source[:start] + f"tags: [{', '.join(kept)}]" + source[end:]
76
+
77
+
78
+ def _rewrite_block(source: str, start: int, end: int) -> str:
79
+ """Rewrite a block list under ``tags:``, dropping the whole entry when nothing is left."""
80
+ lines = source[end:].split("\n")
81
+ items: list[tuple[int, str]] = []
82
+ for index, line in enumerate(lines):
83
+ if index == 0 and not line.strip():
84
+ continue
85
+ matched = _TAG_ITEM.match(line)
86
+ if matched is None:
87
+ break
88
+ items.append((index, matched.group(1)))
89
+ if not items:
90
+ return source
91
+ dropped = [index for index, tag in items if tag == STARTER_TAG]
92
+ if not dropped:
93
+ return source
94
+ if len(dropped) == len(items):
95
+ return _drop_line(source, start, end + len("\n".join(lines[: items[-1][0] + 1])))
96
+ kept = [line for index, line in enumerate(lines) if index not in dropped]
97
+ return source[:end] + "\n".join(kept)
98
+
99
+
100
+ def _drop_line(source: str, start: int, end: int) -> str:
101
+ """Remove a whole entry, including the newline that ended it."""
102
+ tail = source[end:]
103
+ return source[:start] + tail.removeprefix("\n")
104
+
105
+
106
+ def _uncarry(source: str) -> str:
107
+ """Take each carried section out and name the codes it held under ``requires:``."""
108
+ for section in CARRIED:
109
+ codes = _carried(source, section)
110
+ if not codes:
111
+ continue
112
+ source = _require(_strip(source, section), section, codes)
113
+ return source
114
+
115
+
116
+ def _top(lines: list[str], name: str) -> int | None:
117
+ """Where a top-level key sits, or ``None`` when the document has no such section."""
118
+ for index, line in enumerate(lines):
119
+ matched = _KEY.match(line)
120
+ if matched is not None and not matched.group(1) and matched.group(2) == name:
121
+ return index
122
+ return None
123
+
124
+
125
+ def _extent(lines: list[str], at: int) -> tuple[int, int, int]:
126
+ """What a top-level key owns: its comment run, its last line, and where it stops.
127
+
128
+ The first index is the comment run written immediately above the key, the second is the
129
+ key's last indented line, and the third is the first line it does not own -- the next
130
+ key, or the comment run written above that key.
131
+ """
132
+ start = at
133
+ while start > 0 and lines[start - 1].startswith("#"):
134
+ start -= 1
135
+ last = at
136
+ end = at + 1
137
+ while end < len(lines) and (not lines[end].strip() or lines[end][:1].isspace()):
138
+ if lines[end].strip():
139
+ last = end
140
+ end += 1
141
+ return start, last, end
142
+
143
+
144
+ def _carried(source: str, section: str) -> list[str]:
145
+ """The codes a carried section holds, read from the keys one level under it."""
146
+ lines = source.split("\n")
147
+ at = _top(lines, section)
148
+ if at is None:
149
+ return []
150
+ _, last, _ = _extent(lines, at)
151
+ codes: list[str] = []
152
+ indent: str | None = None
153
+ for line in lines[at + 1 : last + 1]:
154
+ matched = _KEY.match(line)
155
+ if matched is None:
156
+ continue
157
+ if indent is None:
158
+ indent = matched.group(1)
159
+ if matched.group(1) == indent:
160
+ codes.append(matched.group(2).strip())
161
+ return codes
162
+
163
+
164
+ def _strip(source: str, section: str) -> str:
165
+ """Remove a whole top-level section, the comment lines written above it included."""
166
+ lines = source.split("\n")
167
+ at = _top(lines, section)
168
+ if at is None:
169
+ return source
170
+ start, _, end = _extent(lines, at)
171
+ return "\n".join(lines[:start] + lines[end:])
172
+
173
+
174
+ def _require(source: str, section: str, codes: Sequence[str]) -> str:
175
+ """Name each code under ``requires:``, extending the list there or writing the section."""
176
+ lines = source.split("\n")
177
+ at = _top(lines, "requires")
178
+ if at is None:
179
+ return _write_requires(lines, section, codes)
180
+ _, last, _ = _extent(lines, at)
181
+ indent = _indent(lines, at, last)
182
+ for index in range(at + 1, last + 1):
183
+ matched = _KEY.match(lines[index])
184
+ if matched is not None and matched.group(1) == indent and matched.group(2).strip() == section:
185
+ return _extend(lines, index, section, codes)
186
+ return "\n".join(lines[: last + 1] + _entry(indent, section, codes) + lines[last + 1 :])
187
+
188
+
189
+ def _indent(lines: list[str], at: int, last: int) -> str:
190
+ """The indentation the keys under a section are written at, two spaces when it has none."""
191
+ for line in lines[at + 1 : last + 1]:
192
+ matched = _KEY.match(line)
193
+ if matched is not None:
194
+ return matched.group(1)
195
+ return " "
196
+
197
+
198
+ def _entry(indent: str, section: str, codes: Sequence[str]) -> list[str]:
199
+ """A section under ``requires:``, written as a block list of the codes it names."""
200
+ return [f"{indent}{section}:", *(f"{indent}{indent}- {code}" for code in codes)]
201
+
202
+
203
+ def _extend(lines: list[str], at: int, section: str, codes: Sequence[str]) -> str:
204
+ """Add every code that is not already there to a list under ``requires:``."""
205
+ matched = _KEY.match(lines[at])
206
+ indent = matched.group(1) if matched is not None else " "
207
+ rest = matched.group(3).strip() if matched is not None else ""
208
+ if rest.startswith(_FLOW[0]):
209
+ inner = rest.removeprefix(_FLOW[0]).removesuffix(_FLOW[1])
210
+ held = [one.strip() for one in inner.split(",") if one.strip()]
211
+ listed = held + [code for code in codes if code not in held]
212
+ lines[at] = f"{indent}{section}: [{', '.join(listed)}]"
213
+ return "\n".join(lines)
214
+ if rest:
215
+ return "\n".join(lines)
216
+ items: list[tuple[int, str, str]] = []
217
+ for index in range(at + 1, len(lines)):
218
+ found = _ITEM.match(lines[index])
219
+ if found is None:
220
+ break
221
+ items.append((index, found.group(1), found.group(2)))
222
+ held = [value for _, _, value in items]
223
+ item_indent = items[0][1] if items else indent + indent
224
+ written = [f"{item_indent}- {code}" for code in codes if code not in held]
225
+ after = items[-1][0] + 1 if items else at + 1
226
+ return "\n".join(lines[:after] + written + lines[after:])
227
+
228
+
229
+ def _write_requires(lines: list[str], section: str, codes: Sequence[str]) -> str:
230
+ """Write the ``requires:`` a document has none of, under the header it follows."""
231
+ for name in _ANCHORS:
232
+ at = _top(lines, name)
233
+ if at is None:
234
+ continue
235
+ _, _, end = _extent(lines, at)
236
+ before = [] if end == 0 or not lines[end - 1].strip() else [""]
237
+ block = ["requires:", *_entry(" ", section, codes)]
238
+ return "\n".join(lines[:end] + before + block + [""] + lines[end:])
239
+ return "\n".join(lines)
240
+
241
+
242
+ def carried_kinds(source: str) -> dict[str, str]:
243
+ """The kind each connection a document carries declares, keyed by its code."""
244
+ parsed = safe_load(source)
245
+ if not isinstance(parsed, dict):
246
+ return {}
247
+ carried = cast("JsonMap", parsed).get("connections")
248
+ if not isinstance(carried, dict):
249
+ return {}
250
+ kinds: dict[str, str] = {}
251
+ for code, definition in cast("JsonMap", carried).items():
252
+ if not isinstance(definition, dict):
253
+ continue
254
+ kind = cast("JsonMap", definition).get("kind")
255
+ if isinstance(kind, str):
256
+ kinds[code] = kind
257
+ return kinds
258
+
259
+
260
+ #: How a requirement's field is named when a summary counts it, singular and plural.
261
+ _COUNTED: Final = (
262
+ ("connections", "connection", "connections"),
263
+ ("schemas", "schema", "schemas"),
264
+ ("pipelines", "pipeline", "pipelines"),
265
+ ("storage", "storage scheme", "storage schemes"),
266
+ ("blocks", "block", "blocks"),
267
+ ("workers", "worker tag", "worker tags"),
268
+ )
269
+
270
+
271
+ def summary(requires: Requirements) -> str:
272
+ """Say what a document needs in one line: the counts, or nothing when it needs nothing."""
273
+ counted = [
274
+ f"{len(held)} {one if len(held) == 1 else many}"
275
+ for name, one, many in _COUNTED
276
+ if (held := getattr(requires, name))
277
+ ]
278
+ return ", ".join(counted) or "-"
279
+
280
+
281
+ def preflight(requires: Requirements, kinds: Mapping[str, str]) -> list[str]:
282
+ """List what has to exist on the instance before a copy of this document will apply.
283
+
284
+ ``kinds`` names the kind of each connection the source carried, which is the one thing
285
+ a ``dg connection create`` line cannot be written without.
286
+ """
287
+ steps = [f"dg connection create {kinds.get(code, 'KIND')} {code}" for code in requires.connections]
288
+ steps += [f"dg schema create {code}.json --code {code}" for code in requires.schemas]
289
+ steps += [f"apply the pipeline {code} it starts" for code in requires.pipelines]
290
+ steps += [f"a storage backend claiming {scheme}://" for scheme in requires.storage]
291
+ steps += [f"a pack contributing {block}" for block in requires.blocks]
292
+ steps += [f"a worker carrying the {tag} tag" for tag in requires.workers]
293
+ return steps
@@ -1,121 +0,0 @@
1
- """Copying a starter: the two lines a copy rewrites, and nothing else.
2
-
3
- A starter is a plain ``dirigent/v1`` document, not a template, so instantiating one is a
4
- verbatim copy of its text with the top-level ``code:`` changed and ``starter`` taken off the
5
- top-level ``tags:``. The edit is at text level rather than through a parser because the
6
- teaching comments, the blank lines and the quoting are the point of copying a document
7
- instead of generating one.
8
- """
9
-
10
- import re
11
- from typing import Final
12
-
13
- from dirigent_client import Requirements
14
- from dirigent_core.examples import STARTER_TAG
15
-
16
- #: The top-level ``code:`` line: no indentation, so a step's own ``code`` is never touched.
17
- _CODE = re.compile(r"^code:[^\S\n]*(.*)$", re.MULTILINE)
18
-
19
- #: The top-level ``tags:`` line, and whatever it carries on the same line.
20
- _TAGS = re.compile(r"^tags:[^\S\n]*(.*)$", re.MULTILINE)
21
-
22
- #: One entry of a block list under ``tags:``: two spaces, a dash, the value.
23
- _TAG_ITEM = re.compile(r"^[^\S\n]*-[^\S\n]*(\S.*?)[^\S\n]*$")
24
-
25
- _FLOW: Final = ("[", "]")
26
-
27
-
28
- def instantiate(source: str, code: str) -> str:
29
- """Copy a starter's text under a new code, with the ``starter`` tag dropped."""
30
- return _retag(_recode(source, code))
31
-
32
-
33
- def _recode(source: str, code: str) -> str:
34
- """Rewrite the one top-level ``code:`` line, leaving every other line alone."""
35
- return _CODE.sub(lambda _: f"code: {code}", source, count=1)
36
-
37
-
38
- def _retag(source: str) -> str:
39
- """Take ``starter`` off the top-level ``tags:``, whichever shape the list is written in.
40
-
41
- A document whose only tag was ``starter`` loses the whole ``tags:`` entry: an empty list
42
- says less than no list at all.
43
- """
44
- found = _TAGS.search(source)
45
- if found is None:
46
- return source
47
- rest = found.group(1).strip()
48
- if rest.startswith(_FLOW[0]):
49
- return _rewrite_flow(source, found.start(), found.end(), rest)
50
- if rest:
51
- return source
52
- return _rewrite_block(source, found.start(), found.end())
53
-
54
-
55
- def _rewrite_flow(source: str, start: int, end: int, rest: str) -> str:
56
- """Rewrite ``tags: [a, b, starter]``, dropping the entry when nothing is left."""
57
- inner = rest.removeprefix(_FLOW[0]).removesuffix(_FLOW[1])
58
- kept = [tag for tag in (one.strip() for one in inner.split(",")) if tag and tag != STARTER_TAG]
59
- if not kept:
60
- return _drop_line(source, start, end)
61
- return source[:start] + f"tags: [{', '.join(kept)}]" + source[end:]
62
-
63
-
64
- def _rewrite_block(source: str, start: int, end: int) -> str:
65
- """Rewrite a block list under ``tags:``, dropping the whole entry when nothing is left."""
66
- lines = source[end:].split("\n")
67
- items: list[tuple[int, str]] = []
68
- for index, line in enumerate(lines):
69
- if index == 0 and not line.strip():
70
- continue
71
- matched = _TAG_ITEM.match(line)
72
- if matched is None:
73
- break
74
- items.append((index, matched.group(1)))
75
- if not items:
76
- return source
77
- dropped = [index for index, tag in items if tag == STARTER_TAG]
78
- if not dropped:
79
- return source
80
- if len(dropped) == len(items):
81
- return _drop_line(source, start, end + len("\n".join(lines[: items[-1][0] + 1])))
82
- kept = [line for index, line in enumerate(lines) if index not in dropped]
83
- return source[:end] + "\n".join(kept)
84
-
85
-
86
- def _drop_line(source: str, start: int, end: int) -> str:
87
- """Remove a whole entry, including the newline that ended it."""
88
- tail = source[end:]
89
- return source[:start] + tail.removeprefix("\n")
90
-
91
-
92
- #: How a requirement's field is named when a summary counts it, singular and plural.
93
- _COUNTED: Final = (
94
- ("connections", "connection", "connections"),
95
- ("schemas", "schema", "schemas"),
96
- ("pipelines", "pipeline", "pipelines"),
97
- ("storage", "storage scheme", "storage schemes"),
98
- ("blocks", "block", "blocks"),
99
- ("workers", "worker tag", "worker tags"),
100
- )
101
-
102
-
103
- def summary(requires: Requirements) -> str:
104
- """Say what a document needs in one line: the counts, or nothing when it needs nothing."""
105
- counted = [
106
- f"{len(held)} {one if len(held) == 1 else many}"
107
- for name, one, many in _COUNTED
108
- if (held := getattr(requires, name))
109
- ]
110
- return ", ".join(counted) or "-"
111
-
112
-
113
- def preflight(requires: Requirements) -> list[str]:
114
- """List what has to exist on the instance before a copy of this document will apply."""
115
- steps = [f"dg connection create KIND {code}" for code in requires.connections]
116
- steps += [f"dg schema create {code}.json --code {code}" for code in requires.schemas]
117
- steps += [f"apply the pipeline {code} it starts" for code in requires.pipelines]
118
- steps += [f"a storage backend claiming {scheme}://" for scheme in requires.storage]
119
- steps += [f"a pack contributing {block}" for block in requires.blocks]
120
- steps += [f"a worker carrying the {tag} tag" for tag in requires.workers]
121
- return steps
File without changes
File without changes