codeterrain 0.3.0__py3-none-any.whl

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.
codeterrain/config.py ADDED
@@ -0,0 +1,331 @@
1
+ """Configuration: ``--config``, then ``[tool.codeterrain]`` in pyproject.toml, then defaults.
2
+
3
+ The target repository's pyproject is not the user's opt-in: the keys that widen what the sandboxed
4
+ child gets (``USER_ONLY_KEYS``) are honoured only from ``--config`` or the per-user config file, and
5
+ are dropped (and reported in ``Config.ignored``) when the repository sets them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import os
12
+ import tomllib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ DEFAULT_MANIFEST_KEYS = {
18
+ "rows": "routes",
19
+ "method": "method",
20
+ "path": "path",
21
+ "access": "access",
22
+ "apps": "apps",
23
+ }
24
+ _KNOWN_KEYS = {
25
+ "apps",
26
+ "import_env",
27
+ "test_paths",
28
+ "region",
29
+ "route_manifest",
30
+ "manifest_keys",
31
+ "plugins",
32
+ "moto_mock",
33
+ "pytest_args",
34
+ "pass_env",
35
+ "respect_addopts",
36
+ "client_paths",
37
+ "path_prefix",
38
+ "client_repos",
39
+ }
40
+ #: Keys that hand the child more of the user's environment, let it write files, or make
41
+ #: codeterrain read other checkouts.
42
+ USER_ONLY_KEYS = ("pass_env", "pytest_args", "respect_addopts", "client_repos")
43
+ USER_CONFIG_ENV = "CODETERRAIN_USER_CONFIG"
44
+ #: The repository's own config file, for repositories without a ``pyproject.toml`` (front ends).
45
+ REPO_CONFIG = "codeterrain.toml"
46
+
47
+
48
+ class ConfigError(ValueError):
49
+ """The configuration is malformed; the message names the offending key."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ClientRepo:
54
+ """A front-end checkout for the routes join. ``path_prefix`` and ``client_paths`` set here
55
+ win over the checkout's own config, so a read-only checkout needs no file of its own."""
56
+
57
+ root: Path
58
+ path_prefix: str | None = None
59
+ client_paths: tuple[str, ...] | None = None
60
+
61
+ def as_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "path": self.root.as_posix(),
64
+ "path_prefix": self.path_prefix,
65
+ "client_paths": None if self.client_paths is None else list(self.client_paths),
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Config:
71
+ root: Path
72
+ source: str = "defaults"
73
+ apps: tuple[str, ...] = ()
74
+ import_env: dict[str, str] = field(default_factory=dict)
75
+ test_paths: tuple[str, ...] = ("tests",)
76
+ region: str = "us-east-1"
77
+ route_manifest: Path | None = None
78
+ manifest_keys: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_MANIFEST_KEYS))
79
+ plugins: tuple[str, ...] = ()
80
+ moto_mock: bool = False
81
+ pytest_args: tuple[str, ...] = ()
82
+ pass_env: tuple[str, ...] = ()
83
+ respect_addopts: bool = False
84
+ #: Where ``terrain calls`` looks for JavaScript and TypeScript; empty means the whole checkout.
85
+ client_paths: tuple[str, ...] = ()
86
+ #: Put in front of every frontend call path before it is matched to a backend route.
87
+ path_prefix: str = ""
88
+ #: Front-end checkouts whose calls ``terrain callers`` joins to this repository's routes.
89
+ client_repos: tuple[ClientRepo, ...] = ()
90
+ #: User-only keys the repository's pyproject set and that were therefore dropped.
91
+ ignored: tuple[str, ...] = ()
92
+ #: The per-user config file that supplied values, if any.
93
+ user_config: str | None = None
94
+
95
+ def as_dict(self) -> dict[str, Any]:
96
+ return {
97
+ "root": self.root.as_posix(),
98
+ "source": self.source,
99
+ "apps": list(self.apps),
100
+ "import_env": dict(self.import_env),
101
+ "test_paths": list(self.test_paths),
102
+ "region": self.region,
103
+ "route_manifest": self.route_manifest.as_posix() if self.route_manifest else None,
104
+ "manifest_keys": dict(self.manifest_keys),
105
+ "plugins": list(self.plugins),
106
+ "moto_mock": self.moto_mock,
107
+ "pytest_args": list(self.pytest_args),
108
+ "pass_env": list(self.pass_env),
109
+ "respect_addopts": self.respect_addopts,
110
+ "client_paths": list(self.client_paths),
111
+ "path_prefix": self.path_prefix,
112
+ "client_repos": [c.as_dict() for c in self.client_repos],
113
+ }
114
+
115
+
116
+ def resolve_root(root: str | os.PathLike[str] | None) -> Path:
117
+ """The target repository root, symlinks and junctions resolved."""
118
+ return Path(os.path.realpath(root if root else os.getcwd()))
119
+
120
+
121
+ def _table_from_toml(path: Path, *, nested_only: bool) -> dict[str, Any] | None:
122
+ try:
123
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
124
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
125
+ raise ConfigError(f"{path.as_posix()}: invalid TOML: {exc}") from exc
126
+ tool = data.get("tool", {})
127
+ if not isinstance(tool, dict):
128
+ raise ConfigError(f"{path.as_posix()}: [tool] must be a table")
129
+ nested = tool.get("codeterrain")
130
+ if nested is not None:
131
+ if not isinstance(nested, dict):
132
+ raise ConfigError(f"{path.as_posix()}: [tool.codeterrain] must be a table")
133
+ return nested
134
+ return None if nested_only else data
135
+
136
+
137
+ def _str_list(table: dict[str, Any], key: str) -> tuple[str, ...]:
138
+ value = table[key]
139
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
140
+ raise ConfigError(f"{key}: expected a list of strings")
141
+ return tuple(value)
142
+
143
+
144
+ def _under_root(root: Path, key: str, value: str) -> Path:
145
+ """``root / value``, symlinks and junctions resolved; refused unless it stays in ``root``."""
146
+ if Path(value).is_absolute() or Path(value).drive:
147
+ raise ConfigError(f"{key}: {value!r} must be relative to the repository root")
148
+ resolved = Path(os.path.realpath(root / value))
149
+ try:
150
+ resolved.relative_to(os.path.realpath(root))
151
+ except ValueError:
152
+ raise ConfigError(f"{key}: {value!r} resolves outside the repository root") from None
153
+ return resolved
154
+
155
+
156
+ def _path_prefix(value: Any, key: str) -> str:
157
+ if not isinstance(value, str) or (value and not value.startswith("/")):
158
+ raise ConfigError(f"{key}: expected a path starting with '/' or ''")
159
+ return value.rstrip("/")
160
+
161
+
162
+ def _client_repos(root: Path, value: Any) -> tuple[ClientRepo, ...]:
163
+ """``["../web", {path = "../admin", path_prefix = "/v1", client_paths = ["src"]}]``. Relative
164
+ paths are read from the repository root; the checkouts are outside it by design, which is why
165
+ the key is user only."""
166
+ if not isinstance(value, list):
167
+ raise ConfigError("client_repos: expected a list of paths or tables")
168
+ repos: list[ClientRepo] = []
169
+ for entry in value:
170
+ if isinstance(entry, str):
171
+ entry = {"path": entry}
172
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
173
+ raise ConfigError("client_repos: each entry is a path or a table with a path")
174
+ extra = sorted(set(entry) - {"path", "path_prefix", "client_paths"})
175
+ if extra:
176
+ raise ConfigError(f"client_repos: unknown key(s): {', '.join(extra)}")
177
+ repos.append(
178
+ ClientRepo(
179
+ Path(os.path.realpath(root / entry["path"])),
180
+ _path_prefix(entry["path_prefix"], "client_repos.path_prefix")
181
+ if "path_prefix" in entry
182
+ else None,
183
+ _str_list(entry, "client_paths") if "client_paths" in entry else None,
184
+ )
185
+ )
186
+ return tuple(repos)
187
+
188
+
189
+ def client_config(repo: ClientRepo) -> Config:
190
+ """The front end's own config with the overrides from its ``client_repos`` entry; an
191
+ overriding ``client_paths`` is confined to that checkout like its own would be."""
192
+ config = load_config(repo.root)
193
+ changes: dict[str, Any] = {}
194
+ if repo.path_prefix is not None:
195
+ changes["path_prefix"] = repo.path_prefix
196
+ if repo.client_paths is not None:
197
+ for entry in repo.client_paths:
198
+ _under_root(repo.root, "client_repos.client_paths", entry)
199
+ changes["client_paths"] = repo.client_paths
200
+ return dataclasses.replace(config, **changes)
201
+
202
+
203
+ def _build(root: Path, table: dict[str, Any], source: str) -> Config:
204
+ unknown = sorted(set(table) - _KNOWN_KEYS)
205
+ if unknown:
206
+ raise ConfigError(f"unknown config key(s): {', '.join(unknown)}")
207
+ values: dict[str, Any] = {"root": root, "source": source}
208
+ if "apps" in table:
209
+ apps = _str_list(table, "apps")
210
+ bad = [a for a in apps if a.count(":") != 1 or not all(a.split(":"))]
211
+ if bad:
212
+ raise ConfigError(f"apps: expected 'module:attr', got {bad[0]!r}")
213
+ values["apps"] = apps
214
+ if "import_env" in table:
215
+ env = table["import_env"]
216
+ if not isinstance(env, dict):
217
+ raise ConfigError("import_env: expected a table")
218
+ values["import_env"] = {str(k): str(v) for k, v in env.items()}
219
+ if "test_paths" in table:
220
+ values["test_paths"] = _str_list(table, "test_paths")
221
+ if "region" in table:
222
+ if not isinstance(table["region"], str) or not table["region"]:
223
+ raise ConfigError("region: expected a non-empty string")
224
+ values["region"] = table["region"]
225
+ if "route_manifest" in table:
226
+ if not isinstance(table["route_manifest"], str):
227
+ raise ConfigError("route_manifest: expected a path string")
228
+ values["route_manifest"] = _under_root(root, "route_manifest", table["route_manifest"])
229
+ if "manifest_keys" in table:
230
+ keys = table["manifest_keys"]
231
+ if not isinstance(keys, dict) or not all(isinstance(v, str) for v in keys.values()):
232
+ raise ConfigError("manifest_keys: expected a table of strings")
233
+ extra = sorted(set(keys) - set(DEFAULT_MANIFEST_KEYS))
234
+ if extra:
235
+ raise ConfigError(f"manifest_keys: unknown key(s): {', '.join(extra)}")
236
+ values["manifest_keys"] = {**DEFAULT_MANIFEST_KEYS, **keys}
237
+ if "plugins" in table:
238
+ values["plugins"] = _str_list(table, "plugins")
239
+ if "pytest_args" in table:
240
+ values["pytest_args"] = _str_list(table, "pytest_args")
241
+ if "moto_mock" in table:
242
+ if not isinstance(table["moto_mock"], bool):
243
+ raise ConfigError("moto_mock: expected true or false")
244
+ values["moto_mock"] = table["moto_mock"]
245
+ if "pass_env" in table:
246
+ values["pass_env"] = _str_list(table, "pass_env")
247
+ if "respect_addopts" in table:
248
+ if not isinstance(table["respect_addopts"], bool):
249
+ raise ConfigError("respect_addopts: expected true or false")
250
+ values["respect_addopts"] = table["respect_addopts"]
251
+ if "client_paths" in table:
252
+ values["client_paths"] = _str_list(table, "client_paths")
253
+ if "path_prefix" in table:
254
+ values["path_prefix"] = _path_prefix(table["path_prefix"], "path_prefix")
255
+ if "client_repos" in table:
256
+ values["client_repos"] = _client_repos(root, table["client_repos"])
257
+ config = Config(**values)
258
+ # The default ``tests`` too: a junction or a cloned symlink there must not lead out.
259
+ for entry in config.test_paths:
260
+ _under_root(root, "test_paths", entry)
261
+ for entry in config.client_paths:
262
+ _under_root(root, "client_paths", entry)
263
+ return config
264
+
265
+
266
+ def user_config_path() -> Path:
267
+ """``$CODETERRAIN_USER_CONFIG``, else ``config.toml`` in the platform's per-user config dir:
268
+ ``%APPDATA%\\codeterrain`` on Windows, ``$XDG_CONFIG_HOME/codeterrain`` (default
269
+ ``~/.config/codeterrain``) elsewhere."""
270
+ override = os.environ.get(USER_CONFIG_ENV)
271
+ if override:
272
+ return Path(override)
273
+ if os.name == "nt":
274
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
275
+ else:
276
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
277
+ return Path(base) / "codeterrain" / "config.toml"
278
+
279
+
280
+ def _user_table() -> tuple[dict[str, Any], str | None]:
281
+ path = user_config_path()
282
+ if not path.is_file():
283
+ return {}, None
284
+ table = _table_from_toml(path, nested_only=False) or {}
285
+ other = sorted(set(table) - set(USER_ONLY_KEYS))
286
+ if other:
287
+ raise ConfigError(
288
+ f"{path.as_posix()}: the user config takes only {', '.join(USER_ONLY_KEYS)}; "
289
+ f"move {', '.join(other)} to --config or the repository's pyproject"
290
+ )
291
+ return table, path.as_posix()
292
+
293
+
294
+ def load_config(root: Path, config_path: str | os.PathLike[str] | None = None) -> Config:
295
+ """Resolve the configuration for ``root``.
296
+
297
+ Order: (a) ``config_path``, a TOML file whose ``[tool.codeterrain]`` table (or, failing that,
298
+ top-level keys) is used; (b) ``root/codeterrain.toml``, read the same way; (c)
299
+ ``[tool.codeterrain]`` in ``root/pyproject.toml``; (d) defaults. The user config
300
+ (``user_config_path``) supplies ``USER_ONLY_KEYS`` that (a) does not set; the same keys in (b)
301
+ or (c) are ignored and listed in ``Config.ignored``.
302
+ """
303
+ user, user_source = _user_table()
304
+ table: dict[str, Any] = {}
305
+ ignored: tuple[str, ...] = ()
306
+ if config_path is not None:
307
+ path = Path(config_path)
308
+ if not path.is_file():
309
+ raise ConfigError(f"--config file not found: {path.as_posix()}")
310
+ table = _table_from_toml(path, nested_only=False) or {}
311
+ source = f"--config {path.resolve().as_posix()}"
312
+ else:
313
+ source = "defaults"
314
+ repo_config = root / REPO_CONFIG
315
+ pyproject = root / "pyproject.toml"
316
+ if repo_config.is_file():
317
+ # A link out of the checkout is refused like any other repository path.
318
+ _under_root(root, REPO_CONFIG, REPO_CONFIG)
319
+ nested = _table_from_toml(repo_config, nested_only=False) or {}
320
+ source = REPO_CONFIG
321
+ elif pyproject.is_file():
322
+ nested = _table_from_toml(pyproject, nested_only=True)
323
+ source = "pyproject.toml [tool.codeterrain]" if nested is not None else source
324
+ else:
325
+ nested = None
326
+ if nested is not None:
327
+ ignored = tuple(k for k in USER_ONLY_KEYS if k in nested)
328
+ table = {k: v for k, v in nested.items() if k not in USER_ONLY_KEYS}
329
+ used_user = user_source if set(user) - set(table) else None
330
+ config = _build(root, {**user, **table}, source)
331
+ return dataclasses.replace(config, ignored=ignored, user_config=used_user)
@@ -0,0 +1,130 @@
1
+ """Resolve a program name to an absolute path without ever looking in the current directory.
2
+
3
+ On Windows, ``CreateProcess`` with a bare name searches the current directory before ``PATH``
4
+ (unless ``NoDefaultCurrentDirectoryInExePath`` is set), and ``shutil.which`` puts ``.`` first. The
5
+ documented way to run codeterrain is from the target repository's root, so a bare ``git`` or
6
+ ``--python python3`` would run a program the repository ships. Every subprocess codeterrain starts
7
+ gets an absolute path from here instead.
8
+
9
+ Only ``.exe`` and ``.com`` files are run on Windows, as ``CreateProcess`` itself does: a ``.bat``
10
+ or ``.cmd`` file runs through ``cmd.exe``, which would read ``&`` or ``%VAR%`` in an argument (a
11
+ checkout path, a configured app name) as commands.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ from collections.abc import Iterable
19
+ from pathlib import Path
20
+
21
+ WINDOWS = os.name == "nt"
22
+ #: The program types run directly on Windows, in ``PATHEXT`` order.
23
+ RUNNABLE = (".COM", ".EXE")
24
+ BATCH = (".BAT", ".CMD")
25
+
26
+ #: Checkouts every lookup refuses programs from, besides the one it names: the target and every
27
+ #: other checkout the command reads (``client_repos``). Set once per command by ``distrust``.
28
+ _distrusted: tuple[Path, ...] = ()
29
+
30
+
31
+ def distrust(roots: Iterable[Path]) -> None:
32
+ """Refuse programs inside ``roots`` in every later lookup, replacing the previous set: a
33
+ command that reads several checkouts must not run a program one of them ships while it asks
34
+ about another."""
35
+ global _distrusted
36
+ _distrusted = tuple(Path(root) for root in roots)
37
+
38
+
39
+ def _has_separator(name: str) -> bool:
40
+ return any(sep and sep in name for sep in (os.sep, os.altsep, "/"))
41
+
42
+
43
+ def _candidates(name: str) -> list[str]:
44
+ if not WINDOWS:
45
+ return [name]
46
+ if os.path.splitext(name)[1].upper() in RUNNABLE:
47
+ return [name]
48
+ return [name + ext for ext in RUNNABLE]
49
+
50
+
51
+ def _absolute_entry(entry: str) -> bool:
52
+ """An absolute ``PATH`` entry; on Windows it needs a drive or a UNC share, since a rooted
53
+ ``\\tools`` resolves against the current drive (the checkout's)."""
54
+ return os.path.isabs(entry) and (not WINDOWS or bool(Path(entry).drive))
55
+
56
+
57
+ def _inside(root: Path, path: Path) -> bool:
58
+ try:
59
+ Path(os.path.realpath(path)).relative_to(os.path.realpath(root))
60
+ except ValueError:
61
+ return False
62
+ return True
63
+
64
+
65
+ #: The folder terrain's own package is imported from (``src`` of a checkout, or site-packages).
66
+ _OWN_CODE = Path(__file__).resolve().parent.parent
67
+
68
+
69
+ def _runs_terrain(root: Path) -> list[Path]:
70
+ """What terrain itself runs from inside ``root``: its environment (a virtual environment in
71
+ the checkout) and the folder its own code is imported from, when either lies inside it. A
72
+ checkout inside the environment gains nothing from this."""
73
+ return [place for place in (Path(sys.prefix), _OWN_CODE) if _inside(root, place)]
74
+
75
+
76
+ def distrusted(path: Path) -> bool:
77
+ """Whether ``path`` lies inside a checkout given to ``distrust`` and outside what terrain
78
+ itself runs from there, which is trusted with terrain already."""
79
+ return any(
80
+ _inside(root, path) and not any(_inside(place, path) for place in _runs_terrain(root))
81
+ for root in _distrusted
82
+ )
83
+
84
+
85
+ def drop_distrusted_imports() -> None:
86
+ """Remove from ``sys.path`` every entry a checkout given to ``distrust`` could control: one
87
+ inside it, and an empty or relative entry (the current directory). Every later import, a
88
+ namespace package's parts and a pack's own lazy imports included, then resolves outside it."""
89
+ sys.path[:] = [
90
+ entry for entry in sys.path
91
+ if entry and os.path.isabs(entry) and not distrusted(Path(entry))
92
+ ]
93
+ sys.path_importer_cache.clear()
94
+
95
+
96
+ def resolve_executable(name: str | os.PathLike[str], untrusted: Path | None = None) -> str:
97
+ """An absolute path for ``name``.
98
+
99
+ A name with a path separator is the caller's explicit choice and is only made absolute. A bare
100
+ name is looked up in the ``PATH`` entries that are absolute (empty, ``.``, relative and, on
101
+ Windows, driveless entries are skipped), trying ``.com`` and ``.exe`` on Windows. Raises
102
+ ``FileNotFoundError`` when nothing matches, for a ``.bat`` or ``.cmd`` file, and when the
103
+ program a bare name finds lies inside ``untrusted`` (the target repository) or any checkout
104
+ given to ``distrust``: an absolute ``PATH`` entry such as ``<checkout>/bin`` would otherwise
105
+ run a program a repository ships.
106
+ """
107
+ text = os.fspath(name)
108
+ if WINDOWS and os.path.splitext(text)[1].upper() in BATCH:
109
+ raise FileNotFoundError(
110
+ f"{text}: a .bat or .cmd file runs through cmd.exe, which would interpret its "
111
+ "arguments; pass the .exe it starts"
112
+ )
113
+ if _has_separator(text):
114
+ return os.path.abspath(text)
115
+ roots = [*_distrusted, *([Path(untrusted)] if untrusted is not None else [])]
116
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
117
+ entry = entry.strip('"')
118
+ if not entry or not _absolute_entry(entry):
119
+ continue
120
+ for candidate in _candidates(text):
121
+ path = Path(entry) / candidate
122
+ if path.is_file() and (WINDOWS or os.access(path, os.X_OK)):
123
+ if any(_inside(root, path) for root in roots):
124
+ raise FileNotFoundError(
125
+ f"{text} on PATH is {path}, inside a target repository, which could "
126
+ "have planted it; put a trusted directory first on PATH, or give the "
127
+ "full path if you trust it"
128
+ )
129
+ return str(path)
130
+ raise FileNotFoundError(f"{text} not found on PATH")
@@ -0,0 +1,77 @@
1
+ """The ``order`` view: fixture setup order per test, extracted by pytest inside the sandbox.
2
+
3
+ The core runs ``python -m pytest --collect-only`` with the dump plugin in the sandbox and reads the
4
+ ``codeterrain.order/1`` document it leaves behind (docs/extractor-protocol.md). It never imports
5
+ pytest or the plugin itself. The project's ``addopts`` are switched off (``-o addopts=``) so a
6
+ reporting option cannot write into the target; ``respect_addopts = true`` opts back in. An ini
7
+ ``log_file`` is always blanked (``-o log_file=``): it would write into the target on any run.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from codeterrain import protocol
16
+ from codeterrain.config import Config
17
+ from codeterrain.sandbox import redact_document, redactor, run_python
18
+
19
+ VIEW = "order"
20
+ SCOPE_LETTER = {"session": "S", "package": "P", "module": "M", "class": "C", "function": "F"}
21
+
22
+
23
+ class ExtractError(RuntimeError):
24
+ """The extractor ran but produced no usable document."""
25
+
26
+
27
+ def extract(config: Config, cache_dir: Path, python: str) -> dict[str, Any]:
28
+ args = [
29
+ "-m",
30
+ "pytest",
31
+ "--collect-only",
32
+ "-q",
33
+ "-p",
34
+ "codeterrain._pytest_dump_plugin",
35
+ "-p",
36
+ "no:cacheprovider",
37
+ "-o",
38
+ "log_cli=false",
39
+ "-o",
40
+ "log_file=",
41
+ *([] if config.respect_addopts else ["-o", "addopts="]),
42
+ *config.pytest_args,
43
+ *config.test_paths,
44
+ ]
45
+ result = run_python(python, args, config, cache_dir)
46
+ if result.result is None:
47
+ detail = (result.stderr.strip() or result.stdout.strip())[-1200:]
48
+ raise ExtractError(f"pytest left no order document (exit {result.returncode}): {detail}")
49
+ # Node ids, fixture fields and any extra field are the child's text (a parametrize id can be
50
+ # built from an environment value): redact them before they are cached. A redacted id still
51
+ # matches a prefix query.
52
+ return protocol.check(redact_document(result.result, redactor(config)), protocol.ORDER_SCHEMA)
53
+
54
+
55
+ def match_nodes(doc: dict[str, Any], query: str) -> list[str]:
56
+ """Node ids for ``query``: exact, else every id below it (parametrized, class, file)."""
57
+ wanted = query.replace("\\", "/")
58
+ nodes = doc["nodes"]
59
+ if wanted in nodes:
60
+ return [wanted]
61
+ prefixes = (f"{wanted}[", f"{wanted}::", f"{wanted}/")
62
+ return sorted(n for n in nodes if n.startswith(prefixes))
63
+
64
+
65
+ def groups(doc: dict[str, Any], matched: list[str]) -> list[dict[str, Any]]:
66
+ """Matched node ids grouped by shared closure, each with its ordered fixtures."""
67
+ by_closure: dict[int, list[str]] = {}
68
+ for node in matched:
69
+ by_closure.setdefault(doc["nodes"][node], []).append(node)
70
+ return [
71
+ {"nodes": nodes, "fixtures": doc["closures"][index]}
72
+ for index, nodes in sorted(by_closure.items(), key=lambda kv: kv[1][0])
73
+ ]
74
+
75
+
76
+ def scope_letter(scope: str) -> str:
77
+ return SCOPE_LETTER.get(scope, "?")