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/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
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, "?")
|