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.
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/PKG-INFO +3 -3
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/pyproject.toml +3 -3
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/pyproject.toml.orig +3 -3
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/__init__.py +2 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/doubles.py +10 -3
- dirigent_testing-0.23.0/src/dirigent_testing/wording.py +317 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/LICENSE +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/README.md +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/conformance.py +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/environment.py +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/fixtures.py +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/messages.py +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/py.typed +0 -0
- {dirigent_testing-0.21.0 → dirigent_testing-0.23.0}/src/dirigent_testing/running.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-testing
|
|
3
|
-
Version: 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.
|
|
10
|
-
Requires-Dist: dirigent-plugin==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.
|
|
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.
|
|
15
|
-
"dirigent-plugin==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.
|
|
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.
|
|
15
|
-
"dirigent-plugin==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,
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|