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/__init__.py
ADDED
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())
|