dirigent-testing 0.21.0__tar.gz → 0.23.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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-testing
3
- Version: 0.21.0
3
+ Version: 0.23.0
4
4
  Summary: Test doubles and pytest fixtures for writing and testing dirigent blocks.
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-common==0.21.0
10
- Requires-Dist: dirigent-plugin==0.21.0
9
+ Requires-Dist: dirigent-common==0.23.0
10
+ Requires-Dist: dirigent-plugin==0.23.0
11
11
  Requires-Dist: httpx2>=2.12.0
12
12
  Requires-Dist: pydantic>=2.13.5
13
13
  Requires-Dist: pytest>=9.1.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.21.0"
3
+ version = "0.23.0"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.21.0",
15
- "dirigent-plugin==0.21.0",
14
+ "dirigent-common==0.23.0",
15
+ "dirigent-plugin==0.23.0",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.21.0"
3
+ version = "0.23.0"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.21.0",
15
- "dirigent-plugin==0.21.0",
14
+ "dirigent-common==0.23.0",
15
+ "dirigent-plugin==0.23.0",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -5,6 +5,7 @@ from dirigent_testing.doubles import FakeCapture, FakeContext, FakeRuns, FakeSin
5
5
  from dirigent_testing.environment import CONFIGURING_PREFIXES, TEST_WIDTH, pin_terminal, scrub_configuration
6
6
  from dirigent_testing.fixtures import no_connection_outlives_its_loop
7
7
  from dirigent_testing.running import call_block, carry_cursor
8
+ from dirigent_testing.wording import check_pack_messages
8
9
 
9
10
  __all__ = [
10
11
  "CONFIGURING_PREFIXES",
@@ -19,6 +20,7 @@ __all__ = [
19
20
  "call_block",
20
21
  "carry_cursor",
21
22
  "check_pack_examples",
23
+ "check_pack_messages",
22
24
  "no_connection_outlives_its_loop",
23
25
  "pin_terminal",
24
26
  "scrub_configuration",
@@ -12,7 +12,7 @@ import httpx2
12
12
  from jsonschema import FormatChecker
13
13
  from pydantic import BaseModel, JsonValue
14
14
 
15
- from dirigent_common import format_checker_with
15
+ from dirigent_common import Message, format_checker_with
16
16
  from dirigent_plugin import (
17
17
  ByteSink,
18
18
  Capture,
@@ -204,13 +204,20 @@ class FakeRuns:
204
204
  """Describe a run this fake instance holds."""
205
205
  return self.snapshots.get(run_id)
206
206
 
207
- async def cancel(self, run_id: UUID, *, reason: str) -> bool:
207
+ async def cancel(self, run_id: UUID, reason: Message, /, **params: Any) -> bool:
208
208
  """Cancel a run, unless it had already settled or was never here."""
209
209
  self.cancelled.append(run_id)
210
210
  held = self.snapshots.get(run_id)
211
211
  if held is None or held.state.settled:
212
212
  return False
213
- self.snapshots[run_id] = held.model_copy(update={"state": RunState.CANCELLED, "error": reason})
213
+ self.snapshots[run_id] = held.model_copy(
214
+ update={
215
+ "state": RunState.CANCELLED,
216
+ "error": reason.render(**params),
217
+ "error_code": reason.code,
218
+ "error_params": params,
219
+ }
220
+ )
214
221
  return True
215
222
 
216
223
 
@@ -0,0 +1,317 @@
1
+ """Checking that no refusal a package makes reaches a person under no code at all.
2
+
3
+ WHY THIS EXISTS. ``dirigent_common.Catalogue`` is the one place a refusal's wording lives,
4
+ under a code that identifies it wherever it appears. That is only true while it stays true:
5
+ one sentence written at a raise site is one phrase nobody reviewing the catalogue will ever
6
+ see, and one phrase a second language cannot reach. The frontend holds the same rule with
7
+ ``scripts/check_ui_labels.py``, which lives in the engine's repository and so cannot be run
8
+ by a pack. This ships from ``dirigent-testing``, which every pack already installs, so a pack
9
+ holds its own wording to the rule its blocks are held to.
10
+
11
+ Nothing here is autouse. A distributed pytest plugin does not mutate a consumer's run unasked,
12
+ so this is one call from a test a pack writes:
13
+
14
+ .. code-block:: python
15
+
16
+ def test_every_refusal_the_pack_makes_carries_a_code():
17
+ assert check_pack_messages(Path(dirigent_acme.__file__).parent) == []
18
+
19
+ WHAT IT REFUSES.
20
+
21
+ * **A refusal raised under no code inside a pydantic validator.** ``raise ValueError("...")``
22
+ in a ``@field_validator`` or ``@model_validator`` is not a private exception: pydantic wraps
23
+ it, and the sentence reaches the wire as the ``msg`` param of a ``validation.*`` issue,
24
+ rendered whole. It is a refusal a person reads, so it is minted and rendered:
25
+ ``raise ValueError(NO_CREDENTIAL.render())``. Inside a validator there is no ambiguity to
26
+ resolve -- the argument of a raise is always a message, never a wire value -- so this is
27
+ judged by *where* the literal is written rather than by what it looks like.
28
+ * **A coded constructor handed a literal.** ``BlockFailure``, ``Failure.rejected``,
29
+ ``Issue.of`` and the CLI's ``refuse`` all take a ``Message`` positionally, so a type checker
30
+ already refuses a string there. This repeats the rule for a pack whose CI runs no type
31
+ checker, and it is what makes the failure say *why* rather than say ``str``.
32
+ * **A catalogue entry nothing reads.** A message minted and never named is a phrase the
33
+ product no longer says: a reviewer reads it and a translator translates it for nothing.
34
+ * **A code two catalogues both define.** A code is stable API, so a prefix has exactly one
35
+ owner. This walks ``Catalogue.all``, which is every catalogue the process has imported --
36
+ for a pack's suite, the engine's and its own -- so a pack learns at once that its prefix
37
+ collides with something installed beside it.
38
+
39
+ WHAT IT DELIBERATELY DOES NOT JUDGE, each for a reason somebody could argue with.
40
+
41
+ * **A block's ``summary`` and its docstring.** ``OperatorSpec(summary=...)`` and a config
42
+ field's docstring are English a person reads on the Blocks screen, and they are not in a
43
+ catalogue anywhere -- by convention, not by oversight. ``docs/conventions.md`` puts
44
+ documentation beside the value it documents, so that one sentence travels to the block
45
+ catalog, the generated reference and the generated config form from one place. Whether a
46
+ pack should instead *contribute* those as labels is an open question about a second
47
+ extension point, not a fault in a raise site, and guessing at it here would make this check
48
+ something people argue with rather than something they fix.
49
+ * **A connection's ``HealthReport.detail``.** A sentence a person reads on the Connections
50
+ screen, and the same open question -- but its sites are a mix of prose, ``str(error)`` and
51
+ a scrubbed process tail, and the ones that are not prose cannot be coded at all.
52
+ * **An exception a package raises for itself and catches before it answers.** The CLI's
53
+ ``ParamError`` and ``SourceError``, a transform engine's ``TransformError``: by
54
+ ``docs/conventions.md`` those are not refusals until something catches them and refuses
55
+ under a code, and telling the one that escapes from the one that is caught needs a walk of
56
+ the whole program. Review covers that.
57
+ * **Log lines.** ``ctx.log.info``, a heartbeat, a ``process`` record: events, not refusals.
58
+ They carry no code and nothing here applies to them.
59
+ """
60
+
61
+ import ast
62
+ import re
63
+ from pathlib import Path
64
+ from typing import Final
65
+
66
+ from dirigent_common import Catalogue
67
+
68
+ #: Directory names never descended into.
69
+ PRUNE: Final = frozenset({"__pycache__", ".venv", "node_modules", "site", "build", "dist"})
70
+
71
+ #: A decorator that makes a function pydantic's, so what it raises reaches the wire.
72
+ VALIDATORS: Final = frozenset({"field_validator", "model_validator", "validator", "root_validator"})
73
+
74
+ #: The constructors that take a ``Message`` and render it, named so a literal given to one
75
+ #: fails saying which rule it broke rather than saying ``str``.
76
+ CODED: Final = frozenset({"BlockFailure", "Failure.rejected", "Issue.of", "refuse"})
77
+
78
+ #: A word of prose: letters, and the apostrophes and hyphens that hold one together.
79
+ PROSE_WORD: Final = re.compile(r"^[A-Za-z][A-Za-z'’-]*$")
80
+
81
+
82
+ def _named(name: str) -> re.Pattern[str]:
83
+ """Build the pattern that finds one name read as a name.
84
+
85
+ Whole-word, so ``NO_PROGRAM`` is not found inside ``NO_PROGRAM_CODE``.
86
+
87
+ Args:
88
+ name: The constant to look for.
89
+
90
+ Returns:
91
+ The pattern that matches it and nothing longer.
92
+ """
93
+ return re.compile(rf"\b{re.escape(name)}\b")
94
+
95
+
96
+ UNCODED_FIX: Final = (
97
+ "a refusal a person reads is minted in a Catalogue and rendered from it, so it carries a "
98
+ "code a reader selects by and a translation can replace. See docs/plugins.md."
99
+ )
100
+
101
+ DEAD_FIX: Final = (
102
+ "no source reads this message. A phrase the product no longer says is a phrase a reviewer "
103
+ "reads and a translator translates for nothing: take it out of the catalogue."
104
+ )
105
+
106
+
107
+ def is_prose(text: str) -> bool:
108
+ """Say whether a literal is a sentence somebody reads rather than a value.
109
+
110
+ Args:
111
+ text: The literal's text, with each interpolation already written as a hole.
112
+
113
+ Returns:
114
+ Whether it holds at least two words of prose.
115
+ """
116
+ stripped = text.strip()
117
+ if " " not in stripped:
118
+ return False
119
+ words = (word.strip(".,;:!?()[]{}\"'") for word in stripped.split())
120
+ return sum(1 for word in words if PROSE_WORD.match(word)) >= 2
121
+
122
+
123
+ def literal_of(node: ast.expr) -> str | None:
124
+ """Read one string literal, eliding the holes an f-string interpolates.
125
+
126
+ What matters about ``f"task {task} disappeared"`` is that the words beside the hole are
127
+ words, so the value it interpolates is read as one hole rather than followed.
128
+
129
+ Args:
130
+ node: The expression to read.
131
+
132
+ Returns:
133
+ The literal's text, or ``None`` when the expression is not a string literal.
134
+ """
135
+ if isinstance(node, ast.Constant):
136
+ return node.value if isinstance(node.value, str) else None
137
+ if isinstance(node, ast.JoinedStr):
138
+ parts = (
139
+ part.value if isinstance(part, ast.Constant) and isinstance(part.value, str) else "{}"
140
+ for part in node.values
141
+ )
142
+ return "".join(parts)
143
+ return None
144
+
145
+
146
+ def source_files(source: Path) -> list[Path]:
147
+ """Every checkable module under a package, in a stable order.
148
+
149
+ Args:
150
+ source: The package directory to walk.
151
+
152
+ Returns:
153
+ The ``.py`` files to read, the pruned directories and the test files excluded.
154
+ """
155
+ found = [
156
+ path
157
+ for path in source.rglob("*.py")
158
+ if not (PRUNE & set(path.relative_to(source).parts)) and not path.name.startswith("test_")
159
+ ]
160
+ return sorted(found)
161
+
162
+
163
+ class Reading:
164
+ """One module read for the refusals it makes and the messages it mints."""
165
+
166
+ def __init__(self, path: Path, label: str) -> None:
167
+ """Parse the module, holding what to prefix each finding with."""
168
+ self.path = path
169
+ self.label = label
170
+ self.tree = ast.parse(path.read_text(encoding="utf-8"))
171
+
172
+ def uncoded(self) -> list[str]:
173
+ """List every refusal this module makes that carries no code."""
174
+ found: list[str] = []
175
+ for node in ast.walk(self.tree):
176
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and self.validates(node):
177
+ found.extend(self.raised_in(node))
178
+ if isinstance(node, ast.Call) and ast.unparse(node.func) in CODED:
179
+ text = literal_of(node.args[0]) if node.args else None
180
+ if text is not None:
181
+ found.append(
182
+ f"{self.label}:{node.lineno}: {ast.unparse(node.func)} was given {text!r} -- {UNCODED_FIX}"
183
+ )
184
+ return found
185
+
186
+ def validates(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
187
+ """Say whether a function is one pydantic calls, so what it raises reaches the wire.
188
+
189
+ Args:
190
+ node: The function to judge.
191
+
192
+ Returns:
193
+ Whether any decorator on it is a pydantic validator.
194
+ """
195
+ return any(any(name in ast.unparse(decorator) for name in VALIDATORS) for decorator in node.decorator_list)
196
+
197
+ def raised_in(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
198
+ """List every prose literal this validator raises.
199
+
200
+ Args:
201
+ node: A function pydantic calls.
202
+
203
+ Returns:
204
+ One finding per raise whose argument is a sentence rather than a value.
205
+ """
206
+ found: list[str] = []
207
+ for raised in ast.walk(node):
208
+ if not isinstance(raised, ast.Raise) or not isinstance(raised.exc, ast.Call):
209
+ continue
210
+ for argument in raised.exc.args:
211
+ text = literal_of(argument)
212
+ if text is not None and is_prose(text):
213
+ where = f"{self.label}:{raised.lineno}"
214
+ found.append(f"{where}: validator {node.name} raises {text!r} -- {UNCODED_FIX}")
215
+ break
216
+ return found
217
+
218
+ def minted(self) -> dict[str, str]:
219
+ """Name every message this module mints, as ``constant -> the name it was minted under``.
220
+
221
+ A message minted into no constant cannot be named at a raise site, so it is not one
222
+ this can tell is dead; ``dirigent_common``'s validation catalogue mints on first sight
223
+ and has none.
224
+
225
+ Returns:
226
+ Each module-level constant that holds a message, against the message's own name.
227
+ """
228
+ found: dict[str, str] = {}
229
+ for node in self.tree.body:
230
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
231
+ continue
232
+ target, value = node.targets[0], node.value
233
+ if not isinstance(target, ast.Name) or not isinstance(value, ast.Call):
234
+ continue
235
+ if not (isinstance(value.func, ast.Attribute) and value.func.attr == "define"):
236
+ continue
237
+ name = literal_of(value.args[0]) if value.args else None
238
+ if name is not None:
239
+ found[target.id] = name
240
+ return found
241
+
242
+
243
+ def check_pack_messages(source: Path) -> list[str]:
244
+ """List every way a package's refusals depart from the catalogue they are supposed to live in.
245
+
246
+ Reads every module under ``source`` and reports a refusal raised under no code, a message
247
+ minted and never read, and a code two imported catalogues both define. Every issue is a
248
+ human-readable line prefixed by where it was found; an empty list means the package's
249
+ wording conforms. The module docstring says what this deliberately does not judge, and
250
+ why.
251
+
252
+ Args:
253
+ source: The package directory to hold to the rule -- a pack's own ``src/<package>``.
254
+
255
+ Returns:
256
+ Everything wrong, in a stable order. Empty when the package conforms.
257
+ """
258
+ if not source.is_dir():
259
+ return [f"{source} is not a directory, so there is no package to check"]
260
+ readings = [Reading(path, str(path.relative_to(source))) for path in source_files(source)]
261
+ issues = [found for reading in readings for found in reading.uncoded()]
262
+ issues.extend(dead(readings))
263
+ issues.extend(colliding())
264
+ return issues
265
+
266
+
267
+ def dead(readings: list["Reading"]) -> list[str]:
268
+ """Find every message minted under a constant that nothing in the package reads.
269
+
270
+ A constant is read when its name appears anywhere outside the module that minted it, or
271
+ more than once inside it. Reading the sources as text is what keeps a message re-exported
272
+ through an ``__init__`` or named in a table from reading as dead.
273
+
274
+ Args:
275
+ readings: Every module read, minting modules included.
276
+
277
+ Returns:
278
+ One finding per message nothing names, in the order the modules declare them.
279
+ """
280
+ texts = {reading.label: reading.path.read_text(encoding="utf-8") for reading in readings}
281
+ issues: list[str] = []
282
+ for reading in readings:
283
+ for constant, name in reading.minted().items():
284
+ pattern = _named(constant)
285
+ if any(pattern.search(text) for label, text in texts.items() if label != reading.label):
286
+ continue
287
+ if len(pattern.findall(texts[reading.label])) > 1:
288
+ continue
289
+ issues.append(f"{reading.label}: {constant} mints {name!r} -- {DEAD_FIX}")
290
+ return issues
291
+
292
+
293
+ def colliding() -> list[str]:
294
+ """Find every code more than one imported catalogue defines.
295
+
296
+ Walks ``Catalogue.all``, which holds every catalogue the process has imported: in a pack's
297
+ suite that is the engine's catalogues and the pack's own, so a prefix a pack has taken
298
+ from something installed beside it is named here rather than at a customer's.
299
+
300
+ A catalogue registers itself when it is constructed and cannot be unregistered, so a test
301
+ that mints one leaves it in the walk for every test after it. A ``test_`` prefix is the
302
+ workspace's own spelling for a catalogue built inside a test, and is passed over here for
303
+ that reason.
304
+
305
+ Returns:
306
+ One finding per colliding code, in the order the catalogues were imported.
307
+ """
308
+ seen: dict[str, Catalogue] = {}
309
+ issues: list[str] = []
310
+ for catalogue in Catalogue.all:
311
+ if catalogue.prefix.startswith("test_"):
312
+ continue
313
+ for message in catalogue.messages.values():
314
+ owner = seen.setdefault(message.code, catalogue)
315
+ if owner is not catalogue:
316
+ issues.append(f"{message.code} is defined by two catalogues: a code has exactly one owner")
317
+ return issues