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.
@@ -0,0 +1,3 @@
1
+ """codeterrain: answers about your code in under a second, from a local cache (Python first)."""
2
+
3
+ __version__ = "0.3.0"
codeterrain/_guard.py ADDED
@@ -0,0 +1,243 @@
1
+ """Best-effort network guard for code under analysis: loopback-only sockets.
2
+
3
+ Installed inside the sandboxed subprocess by ``sandbox.BOOTSTRAP`` (and by the ``sitecustomize`` the
4
+ sandbox writes, so child interpreters inherit it). It is a guard against accidental network calls
5
+ from the target's code, not a security boundary against hostile code: references to socket
6
+ functions taken before ``install()`` ran, C extensions that open sockets themselves, uvloop and
7
+ aiodns are not covered. Stdlib only; importing this module changes nothing until ``install()``.
8
+
9
+ ``install()`` also replaces ``_socket.socket`` (and the ``SocketType`` aliases) with a guarded
10
+ subclass, so ``isinstance(sock, _socket.socket)`` is False for sockets made by ``socket.socket``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import _socket
16
+ import ipaddress
17
+ import socket
18
+ import sys
19
+ import warnings
20
+
21
+ _LOOPBACK_NAMES = {"localhost", "localhost.localdomain", "ip6-localhost", ""}
22
+ _INET_FAMILIES = {socket.AF_INET, socket.AF_INET6}
23
+
24
+ #: socket.socket methods that can originate traffic to an address; each is replaced.
25
+ GUARDED_METHODS = ("connect", "connect_ex", "sendto", "sendmsg")
26
+ #: module-level functions in socket (and _socket) that resolve or reach a name; each is replaced.
27
+ LOOKUP_FUNCTIONS = (
28
+ "getaddrinfo",
29
+ "gethostbyname",
30
+ "gethostbyname_ex",
31
+ "gethostbyaddr",
32
+ "getnameinfo",
33
+ )
34
+ GUARDED_FUNCTIONS = (*LOOKUP_FUNCTIONS, "create_connection")
35
+
36
+
37
+ class SandboxNetworkBlocked(OSError):
38
+ pass
39
+
40
+
41
+ def _is_loopback(host):
42
+ if host is None:
43
+ return True
44
+ if isinstance(host, (bytes, bytearray)):
45
+ host = bytes(host).decode("ascii", "replace")
46
+ host = str(host)
47
+ if host.lower() in _LOOPBACK_NAMES:
48
+ return True
49
+ try:
50
+ addr = ipaddress.ip_address(host.split("%", 1)[0])
51
+ except ValueError:
52
+ return False
53
+ mapped = getattr(addr, "ipv4_mapped", None)
54
+ return (mapped or addr).is_loopback
55
+
56
+
57
+ def _refuse(host, port):
58
+ raise SandboxNetworkBlocked(
59
+ f"codeterrain sandbox: non-loopback network access is blocked ({host}:{port})"
60
+ )
61
+
62
+
63
+ def _check_host(host, port=""):
64
+ if not _is_loopback(host):
65
+ _refuse(host, port)
66
+
67
+
68
+ def _check_address(family, address):
69
+ if family in _INET_FAMILIES and isinstance(address, tuple) and address:
70
+ _check_host(address[0], address[1] if len(address) > 1 else "")
71
+ return
72
+ _refuse(address, "")
73
+
74
+
75
+ def _name_of(sockaddr):
76
+ return sockaddr[0] if isinstance(sockaddr, tuple) and sockaddr else sockaddr
77
+
78
+
79
+ def _build_methods():
80
+ connect_o, connect_ex_o = socket.socket.connect, socket.socket.connect_ex
81
+ sendto_o, sendmsg_o = socket.socket.sendto, getattr(socket.socket, "sendmsg", None)
82
+
83
+ def connect(self, address):
84
+ _check_address(self.family, address)
85
+ return connect_o(self, address)
86
+
87
+ def connect_ex(self, address):
88
+ _check_address(self.family, address)
89
+ return connect_ex_o(self, address)
90
+
91
+ def sendto(self, data, *args):
92
+ _check_address(self.family, args[-1])
93
+ return sendto_o(self, data, *args)
94
+
95
+ def sendmsg(self, buffers, *args, **kwargs):
96
+ address = args[2] if len(args) > 2 else kwargs.get("address")
97
+ if address is not None:
98
+ _check_address(self.family, address)
99
+ return sendmsg_o(self, buffers, *args, **kwargs)
100
+
101
+ built = {"connect": connect, "connect_ex": connect_ex, "sendto": sendto}
102
+ if sendmsg_o is not None:
103
+ built["sendmsg"] = sendmsg
104
+ return built
105
+
106
+
107
+ def _build_lookups(module):
108
+ """Guarded wrappers for the name-resolution functions of ``module`` (socket or _socket)."""
109
+ found = {name: getattr(module, name) for name in LOOKUP_FUNCTIONS if hasattr(module, name)}
110
+
111
+ def getaddrinfo(host, *args, **kwargs):
112
+ _check_host(host, args[0] if args else kwargs.get("port", ""))
113
+ return found["getaddrinfo"](host, *args, **kwargs)
114
+
115
+ def gethostbyname(host):
116
+ _check_host(host)
117
+ return found["gethostbyname"](host)
118
+
119
+ def gethostbyname_ex(host):
120
+ _check_host(host)
121
+ return found["gethostbyname_ex"](host)
122
+
123
+ def gethostbyaddr(host):
124
+ _check_host(host)
125
+ return found["gethostbyaddr"](host)
126
+
127
+ def getnameinfo(sockaddr, flags):
128
+ _check_host(_name_of(sockaddr))
129
+ return found["getnameinfo"](sockaddr, flags)
130
+
131
+ wrappers = {
132
+ "getaddrinfo": getaddrinfo,
133
+ "gethostbyname": gethostbyname,
134
+ "gethostbyname_ex": gethostbyname_ex,
135
+ "gethostbyaddr": gethostbyaddr,
136
+ "getnameinfo": getnameinfo,
137
+ }
138
+ return {name: wrappers[name] for name in found}
139
+
140
+
141
+ def _build_create_connection():
142
+ original = socket.create_connection
143
+
144
+ def create_connection(address, *args, **kwargs):
145
+ _check_host(_name_of(address), address[1] if len(address) > 1 else "")
146
+ return original(address, *args, **kwargs)
147
+
148
+ return create_connection
149
+
150
+
151
+ _methods = _build_methods()
152
+ _lookups = _build_lookups(socket)
153
+ _raw_lookups = _build_lookups(_socket)
154
+ create_connection = _build_create_connection()
155
+
156
+ connect = _methods["connect"]
157
+ connect_ex = _methods["connect_ex"]
158
+ sendto = _methods["sendto"]
159
+ sendmsg = _methods.get("sendmsg")
160
+ getaddrinfo = _lookups["getaddrinfo"]
161
+
162
+
163
+ def _guarded_raw_socket():
164
+ base = _socket.socket
165
+ return type("socket", (base,), dict(_methods))
166
+
167
+
168
+ def _wrap_proactor():
169
+ """Windows' proactor loop connects and sends through overlapped I/O, past ``socket.connect``."""
170
+ if sys.platform != "win32":
171
+ return
172
+ try:
173
+ from asyncio import windows_events
174
+ except ImportError:
175
+ return
176
+ proactor = windows_events.IocpProactor
177
+ if getattr(proactor.connect, "_codeterrain_guard", False):
178
+ return
179
+ connect_o, sendto_o = proactor.connect, proactor.sendto
180
+ sendto_addr_o = getattr(proactor, "sendto_addr", None)
181
+
182
+ def connect(self, conn, address):
183
+ _check_address(conn.family, address)
184
+ return connect_o(self, conn, address)
185
+
186
+ def sendto(self, conn, buf, flags=0, addr=None):
187
+ if addr is not None:
188
+ _check_address(conn.family, addr)
189
+ return sendto_o(self, conn, buf, flags, addr)
190
+
191
+ def sendto_addr(self, conn, buf, addr, *args, **kwargs):
192
+ _check_address(conn.family, addr)
193
+ return sendto_addr_o(self, conn, buf, addr, *args, **kwargs)
194
+
195
+ wrapped = {"connect": connect, "sendto": sendto}
196
+ if sendto_addr_o is not None:
197
+ wrapped["sendto_addr"] = sendto_addr
198
+ for name, fn in wrapped.items():
199
+ fn._codeterrain_guard = True
200
+ setattr(proactor, name, fn)
201
+
202
+
203
+ def proactor_guarded():
204
+ """True when the asyncio proactor's connect and sendto are wrapped (always True off Windows)."""
205
+ if sys.platform != "win32":
206
+ return True
207
+ try:
208
+ from asyncio import windows_events
209
+ except ImportError:
210
+ return True
211
+ proactor = windows_events.IocpProactor
212
+ return all(
213
+ getattr(getattr(proactor, name), "_codeterrain_guard", False)
214
+ for name in ("connect", "sendto")
215
+ )
216
+
217
+
218
+ def install():
219
+ """Replace the socket entry points. Idempotent; call once, before the target is imported."""
220
+ if getattr(socket.socket, "_codeterrain_guard", False):
221
+ return
222
+ for name, fn in _methods.items():
223
+ setattr(socket.socket, name, fn)
224
+ socket.socket._codeterrain_guard = True
225
+ for name, fn in _lookups.items():
226
+ setattr(socket, name, fn)
227
+ for name, fn in _raw_lookups.items():
228
+ setattr(_socket, name, fn)
229
+ socket.create_connection = create_connection
230
+ guarded = _guarded_raw_socket()
231
+ _socket.socket = _socket.SocketType = socket.SocketType = guarded
232
+ _wrap_proactor()
233
+
234
+
235
+ def use_selector_event_loop():
236
+ """Prefer the selector loop on Windows; the proactor is guarded too, this is a nicety."""
237
+ import asyncio
238
+
239
+ policy = getattr(asyncio, "WindowsSelectorEventLoopPolicy", None)
240
+ if policy is not None:
241
+ with warnings.catch_warnings():
242
+ warnings.simplefilter("ignore", DeprecationWarning)
243
+ asyncio.set_event_loop_policy(policy())
@@ -0,0 +1,253 @@
1
+ """pytest plugin, loaded into the TARGET's pytest run: dumps each test's fixture setup order.
2
+
3
+ Run as ``python -m pytest --collect-only -q -p codeterrain._pytest_dump_plugin``; it writes a
4
+ ``codeterrain.order/1`` document (docs/extractor-protocol.md) to ``$CODETERRAIN_RESULT``. Items that
5
+ share a fixture closure share one entry; ``nodes`` maps every node id to its closure.
6
+
7
+ Only stdlib and pytest are imported, so any pytest 7+ environment can host it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import json
14
+ import os
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ SCHEMA = "codeterrain.order/1"
20
+
21
+
22
+ def closure_of(item: Any) -> tuple[list[str], dict[str, Any]]:
23
+ """(names_closure, name -> tuple of FixtureDef) for ``item``.
24
+
25
+ Private API, isolated here: ``item._fixtureinfo``. Public fallback when it is absent:
26
+ ``item.fixturenames`` plus the fixture manager's ``getfixturedefs``.
27
+ """
28
+ info = getattr(item, "_fixtureinfo", None)
29
+ if info is not None and hasattr(info, "names_closure") and hasattr(info, "name2fixturedefs"):
30
+ return list(info.names_closure), dict(info.name2fixturedefs)
31
+ names = list(item.fixturenames)
32
+ manager = item.session._fixturemanager
33
+ defs: dict[str, Any] = {}
34
+ for name in names:
35
+ found = manager.getfixturedefs(name, item)
36
+ if found:
37
+ defs[name] = tuple(found)
38
+ return names, defs
39
+
40
+
41
+ def lookup_fixturedefs(item: Any, name: str) -> tuple[Any, ...]:
42
+ """The fixture definitions visible to ``item`` under ``name`` (public manager API)."""
43
+ manager = getattr(getattr(item, "session", None), "_fixturemanager", None)
44
+ if manager is None:
45
+ return ()
46
+ try:
47
+ found = manager.getfixturedefs(name, item)
48
+ except (TypeError, AttributeError):
49
+ found = manager.getfixturedefs(name, item.nodeid)
50
+ return tuple(found or ())
51
+
52
+
53
+ def setup_order(
54
+ names: list[str],
55
+ defs: dict[str, Any],
56
+ resolve: Callable[[str], tuple[Any, ...]] | None = None,
57
+ ) -> list[Any]:
58
+ """FixtureDefs in the order pytest sets them up: each requested name in closure order,
59
+ dependencies first.
60
+
61
+ Overrides resolve as pytest does: a request for ``name`` takes the definition one level up for
62
+ every fixture of that name still being set up in the chain of requests, however many other
63
+ fixtures sit between (an override requesting a helper that requests the same name gets the
64
+ parent). A name whose fixture already finished is not resolved again.
65
+
66
+ ``resolve`` finds a name the closure does not list: pytest 8 leaves out what only an
67
+ overridden parent definition requests, and resolves it lazily when the parent is set up."""
68
+ order: list[Any] = []
69
+ finished: set[str] = set()
70
+ chain: list[str] = []
71
+
72
+ def visit(name: str) -> None:
73
+ if name in finished:
74
+ return
75
+ candidates = defs.get(name)
76
+ if not candidates and resolve is not None:
77
+ candidates = defs[name] = resolve(name)
78
+ index = -1 - chain.count(name)
79
+ if not candidates or -index > len(candidates):
80
+ return
81
+ fixturedef = candidates[index]
82
+ chain.append(name)
83
+ for dep in fixturedef.argnames:
84
+ if dep != "request":
85
+ visit(dep)
86
+ chain.pop()
87
+ order.append(fixturedef)
88
+ finished.add(name)
89
+
90
+ for name in names:
91
+ visit(name)
92
+ return order
93
+
94
+
95
+ def _location(func: Any, root: Path) -> tuple[str, int]:
96
+ try:
97
+ code = inspect.unwrap(func).__code__
98
+ except (AttributeError, ValueError):
99
+ return "", 0
100
+ real = Path(os.path.realpath(code.co_filename))
101
+ try:
102
+ return real.relative_to(root).as_posix(), code.co_firstlineno
103
+ except ValueError:
104
+ return real.as_posix(), code.co_firstlineno
105
+
106
+
107
+ def param_scopes(item: Any) -> dict[str, str]:
108
+ """name -> the scope pytest runs a parametrized argument at, from the item's callspec.
109
+
110
+ ``parametrize(..., indirect=True, scope="module")`` runs a function-scoped fixture at module
111
+ scope. ``CallSpec2._arg2scope`` is private; without it the declared scope is reported.
112
+ """
113
+ raw = getattr(getattr(item, "callspec", None), "_arg2scope", None)
114
+ if not isinstance(raw, dict):
115
+ return {}
116
+ scopes = {name: getattr(scope, "value", scope) for name, scope in raw.items()}
117
+ return {name: scope for name, scope in scopes.items() if isinstance(scope, str)}
118
+
119
+
120
+ def _entry(fixturedef: Any, root: Path, scope: str | None = None) -> dict[str, Any]:
121
+ file, line = _location(fixturedef.func, root)
122
+ return {
123
+ "name": fixturedef.argname,
124
+ "scope": scope or fixturedef.scope,
125
+ "file": file,
126
+ "line": line,
127
+ "params": bool(getattr(fixturedef, "params", None)),
128
+ }
129
+
130
+
131
+ def build_document(
132
+ items: list[Any], root: Path, errors: int, pytest_version: str
133
+ ) -> dict[str, Any]:
134
+ closures: list[list[dict[str, Any]]] = []
135
+ index: dict[str, int] = {}
136
+ nodes: dict[str, int] = {}
137
+ for item in items:
138
+ names, defs = closure_of(item)
139
+ found = setup_order(names, defs, lambda name, item=item: lookup_fixturedefs(item, name))
140
+ # The parameter reaches the nearest definition of a name, the last one set up.
141
+ scopes = param_scopes(item)
142
+ last = {fd.argname: i for i, fd in enumerate(found)}
143
+ entries = [
144
+ _entry(fd, root, scopes.get(fd.argname) if last[fd.argname] == i else None)
145
+ for i, fd in enumerate(found)
146
+ ]
147
+ key = json.dumps(entries, sort_keys=True)
148
+ if key not in index:
149
+ index[key] = len(closures)
150
+ closures.append(entries)
151
+ nodes[item.nodeid] = index[key]
152
+ return {
153
+ "schema": SCHEMA,
154
+ "generator": {"name": "codeterrain-pytest-dump", "pytest": pytest_version},
155
+ "closures": closures,
156
+ "nodes": nodes,
157
+ "errors": errors,
158
+ }
159
+
160
+
161
+ #: (pytest option name, flag) pairs: options whose value makes pytest write a file when set.
162
+ REPORT_OPTIONS = (
163
+ ("xmlpath", "--junitxml"),
164
+ ("htmlpath", "--html"),
165
+ ("log_file", "--log-file"),
166
+ ("debug", "--debug"),
167
+ ("report_log", "--report-log"),
168
+ ("json_report", "--json-report"),
169
+ ("allure_report_dir", "--alluredir"),
170
+ )
171
+
172
+
173
+ def _writes_reports(config: Any) -> list[str]:
174
+ """Options in force that make pytest write report files (into the target, by default)."""
175
+ found: list[str] = [
176
+ flag for option, flag in REPORT_OPTIONS if config.getoption(option, None)
177
+ ]
178
+ cov = config.getoption("cov_report", None)
179
+ # A tuple, not ``list | tuple``: this module runs in the target's interpreter (3.9 and up).
180
+ names = cov.keys() if isinstance(cov, dict) else cov if isinstance(cov, (list, tuple)) else [] # noqa: UP038
181
+ if {str(n).split(":", 1)[0] for n in names} - {"term", "term-missing"}:
182
+ found.append("--cov-report")
183
+ return found
184
+
185
+
186
+ class _Namespace:
187
+ """``getoption`` over the early argparse namespace, before the config is fully parsed."""
188
+
189
+ def __init__(self, namespace: Any, early_config: Any = None) -> None:
190
+ self.namespace = namespace
191
+ self.early_config = early_config
192
+
193
+ def getoption(self, name: str, default: Any = None) -> Any:
194
+ return getattr(self.namespace, name, default)
195
+
196
+ def getini(self, name: str) -> Any:
197
+ return self.early_config.getini(name)
198
+
199
+
200
+ def _ini_log_file(config: Any) -> str:
201
+ """The ``log_file`` ini value after every ``-o`` override; empty when it cannot be read."""
202
+ try:
203
+ return str(config.getini("log_file") or "")
204
+ except (AttributeError, ValueError):
205
+ return ""
206
+
207
+
208
+ def _refuse_report_options(config: Any) -> None:
209
+ """Refuse a run whose options make pytest write files, whichever source supplied them.
210
+
211
+ codeterrain blanks ``log_file`` and switches ``addopts`` off; what is left is ``pytest_args``
212
+ (user config only) and, under ``respect_addopts``, the project's own options.
213
+ """
214
+ import pytest
215
+
216
+ active = _writes_reports(config)
217
+ if _ini_log_file(config):
218
+ active.append("-o log_file=...")
219
+ if not active:
220
+ return
221
+ if os.environ.get("CODETERRAIN_RESPECT_ADDOPTS") == "1":
222
+ raise pytest.UsageError(
223
+ f"codeterrain: respect_addopts is on but the project's options ({', '.join(active)}) "
224
+ "make pytest write report files into the repository; remove them for the "
225
+ "extraction run or set respect_addopts = false"
226
+ )
227
+ raise pytest.UsageError(
228
+ f"codeterrain: the extraction options ({', '.join(active)}) make pytest write files "
229
+ "into the repository; remove them from pytest_args"
230
+ )
231
+
232
+
233
+ def pytest_load_initial_conftests(early_config: Any, parser: Any = None, args: Any = None) -> None:
234
+ """Refuse before pytest opens ``--debug``'s log file, which happens right after parsing."""
235
+ namespace = getattr(early_config, "known_args_namespace", None)
236
+ if namespace is not None:
237
+ _refuse_report_options(_Namespace(namespace, early_config))
238
+
239
+
240
+ def pytest_configure(config: Any) -> None:
241
+ _refuse_report_options(config)
242
+
243
+
244
+ def pytest_collection_finish(session: Any) -> None:
245
+ import pytest
246
+
247
+ target = os.environ.get("CODETERRAIN_RESULT")
248
+ if not target:
249
+ return
250
+ root = Path(os.path.realpath(os.environ.get("CODETERRAIN_ROOT") or session.config.rootpath))
251
+ document = build_document(list(session.items), root, session.testsfailed, pytest.__version__)
252
+ # bytes, not write_text(newline=...), which needs 3.10 in the target's interpreter
253
+ Path(target).write_bytes(json.dumps(document).encode("utf-8"))
@@ -0,0 +1,119 @@
1
+ """Generic app importer, run INSIDE the target interpreter (``-m codeterrain._routes_runner``).
2
+
3
+ Imports each ``module:attr`` application, asks the pluggy ``iter_routes`` hook for its routes and
4
+ writes a ``codeterrain.routes/1`` document (docs/extractor-protocol.md) to ``$CODETERRAIN_RESULT``.
5
+ One app failing to import is recorded in ``errors`` and does not stop the others.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import contextlib
12
+ import importlib
13
+ import json
14
+ import os
15
+ import sys
16
+ import traceback
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pluggy
21
+
22
+ from codeterrain import hookspecs
23
+ from codeterrain.plugins import fastapi_routes
24
+
25
+ SCHEMA = "codeterrain.routes/1"
26
+
27
+
28
+ def make_plugin_manager(extra: list[str]) -> pluggy.PluginManager:
29
+ manager = pluggy.PluginManager(hookspecs.PROJECT)
30
+ manager.add_hookspecs(hookspecs)
31
+ manager.register(fastapi_routes)
32
+ for module in extra:
33
+ manager.register(importlib.import_module(module))
34
+ return manager
35
+
36
+
37
+ def relative_file(file: str, root: Path) -> str:
38
+ """Repo-relative POSIX path with symlinks and junctions resolved; absolute when outside."""
39
+ if not file:
40
+ return ""
41
+ real = Path(os.path.realpath(file))
42
+ try:
43
+ return real.relative_to(root).as_posix()
44
+ except ValueError:
45
+ return real.as_posix()
46
+
47
+
48
+ def exception_chain(exc: BaseException | None) -> list[str]:
49
+ """``Type: message`` for the exception and each cause behind it, outermost first."""
50
+ chain: list[str] = []
51
+ while exc is not None and len(chain) < 12:
52
+ chain.append(f"{type(exc).__name__}: {exc}")
53
+ exc = exc.__cause__ or exc.__context__
54
+ return chain
55
+
56
+
57
+ def collect(
58
+ apps: list[str], plugins: list[str], root: Path, moto_mock: bool
59
+ ) -> dict[str, Any]:
60
+ routes: list[dict[str, Any]] = []
61
+ errors: list[dict[str, str]] = []
62
+ notes: list[str] = []
63
+ sys.path.insert(0, str(root))
64
+ with contextlib.ExitStack() as stack:
65
+ if moto_mock:
66
+ try:
67
+ from moto import mock_aws
68
+ except ImportError:
69
+ notes.append("moto_mock is set but moto is not importable in the target")
70
+ else:
71
+ stack.enter_context(mock_aws())
72
+ notes.append("imports ran inside moto.mock_aws()")
73
+ manager = make_plugin_manager(plugins)
74
+ for spec in apps:
75
+ module_name, _, attr = spec.partition(":")
76
+ fastapi_routes.forget_host_routes()
77
+ try:
78
+ app = getattr(importlib.import_module(module_name), attr)
79
+ found = manager.hook.iter_routes(app=app)
80
+ if found is None:
81
+ raise TypeError(f"no plugin recognises {type(app).__name__}")
82
+ except Exception as exc:
83
+ tail = traceback.format_exc().strip().splitlines()
84
+ errors.append(
85
+ {"app": spec, "error": "\n".join(tail[-30:]), "chain": exception_chain(exc)}
86
+ )
87
+ continue
88
+ notes.extend(
89
+ f"warning: {spec}: Host route {host!r} is not walked, its routes are missing"
90
+ for host in fastapi_routes.host_routes_seen()
91
+ )
92
+ for record in found:
93
+ routes.append(
94
+ {**record, "app": spec, "file": relative_file(record["file"], root)}
95
+ )
96
+ return {
97
+ "schema": SCHEMA,
98
+ "generator": {"name": "codeterrain-routes-runner", "python": sys.version.split()[0]},
99
+ "routes": routes,
100
+ "errors": errors,
101
+ "notes": notes,
102
+ }
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ parser = argparse.ArgumentParser(prog="codeterrain._routes_runner")
107
+ parser.add_argument("--apps", nargs="*", default=[])
108
+ parser.add_argument("--plugins", nargs="*", default=[])
109
+ parser.add_argument("--moto-mock", action="store_true")
110
+ args = parser.parse_args(argv)
111
+ root = Path(os.path.realpath(os.environ.get("CODETERRAIN_ROOT") or os.getcwd()))
112
+ document = collect(args.apps, args.plugins, root, args.moto_mock)
113
+ # bytes, not write_text(newline=...), which needs 3.10 in the target's interpreter
114
+ Path(os.environ["CODETERRAIN_RESULT"]).write_bytes(json.dumps(document).encode("utf-8"))
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ raise SystemExit(main())