specrig 0.1.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.
- specrig/__init__.py +7 -0
- specrig/__main__.py +5 -0
- specrig/_yaml.py +117 -0
- specrig/asgi.py +120 -0
- specrig/backend.py +153 -0
- specrig/clock.py +41 -0
- specrig/code/__init__.py +31 -0
- specrig/code/calls.py +61 -0
- specrig/code/django.py +118 -0
- specrig/code/fastapi.py +129 -0
- specrig/code/index.py +477 -0
- specrig/code/settings.py +132 -0
- specrig/contract.py +106 -0
- specrig/django/__init__.py +188 -0
- specrig/docker.py +133 -0
- specrig/fastapi/__init__.py +244 -0
- specrig/flows.py +285 -0
- specrig/gate.py +95 -0
- specrig/http.py +168 -0
- specrig/postgres.py +278 -0
- specrig/protocol/agents/contributing.md +6 -0
- specrig/protocol/agents/header.md +5 -0
- specrig/protocol/agents/reference.md +1 -0
- specrig/protocol/agents/rule11.md +423 -0
- specrig/protocol/agents/short.md +19 -0
- specrig/protocol/modes.yaml +55 -0
- specrig/proxy.py +73 -0
- specrig/py.typed +0 -0
- specrig/records.py +48 -0
- specrig/redis.py +154 -0
- specrig/resource.py +49 -0
- specrig/rig.py +370 -0
- specrig/run.py +22 -0
- specrig/standins.py +112 -0
- specrig/suite.py +239 -0
- specrig/surface.py +87 -0
- specrig/tool/__init__.py +0 -0
- specrig/tool/agents.py +150 -0
- specrig/tool/cli.py +137 -0
- specrig/tool/init.py +244 -0
- specrig/tool/modes.py +54 -0
- specrig/tool/service.py +209 -0
- specrig/tool/templates.py +196 -0
- specrig-0.1.0.dist-info/METADATA +603 -0
- specrig-0.1.0.dist-info/RECORD +48 -0
- specrig-0.1.0.dist-info/WHEEL +4 -0
- specrig-0.1.0.dist-info/entry_points.txt +5 -0
- specrig-0.1.0.dist-info/licenses/LICENSE +21 -0
specrig/__init__.py
ADDED
specrig/__main__.py
ADDED
specrig/_yaml.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
_WHITE = "\x09-\x0d\x20\x85\xa0 -
"
|
|
10
|
+
_CONTROL = "\x00-\x1f\x7f-\x9f"
|
|
11
|
+
_WORDS = re.compile(r"^(?:true|false|yes|no|on|off|y|n|null|~)$", re.IGNORECASE)
|
|
12
|
+
_NUMBER = re.compile(r"^[-+]?(\.?[0-9]|0x|0o|\.inf|\.nan)", re.IGNORECASE)
|
|
13
|
+
_EDGES = re.compile(f"^[{_WHITE}]|[{_WHITE}]$")
|
|
14
|
+
_CONTROLS = re.compile(f"[{_CONTROL}]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A mapping is a dict, a sequence a list; an entry whose value is None or empty is
|
|
18
|
+
# left out, as omitempty does in the Go record.
|
|
19
|
+
def emit(root: dict[str, Any]) -> str:
|
|
20
|
+
return _write(root, 0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write(mapping: dict[str, Any], indent: int) -> str:
|
|
24
|
+
pad = " " * indent
|
|
25
|
+
text = ""
|
|
26
|
+
for key, value in mapping.items():
|
|
27
|
+
if value is None or value == [] or value == {}:
|
|
28
|
+
continue
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
text += f"{pad}{key}: {_scalar(value, indent + 2)}\n"
|
|
31
|
+
elif isinstance(value, dict):
|
|
32
|
+
text += f"{pad}{key}:\n{_write(value, indent + 2)}"
|
|
33
|
+
elif isinstance(value, list) and isinstance(value[0], str):
|
|
34
|
+
text += f"{pad}{key}:\n"
|
|
35
|
+
for item in value:
|
|
36
|
+
text += f"{pad} - {_scalar(item, indent + 4)}\n"
|
|
37
|
+
elif isinstance(value, list) and isinstance(value[0], dict):
|
|
38
|
+
text += f"{pad}{key}:\n"
|
|
39
|
+
for item in value:
|
|
40
|
+
text += f"{pad} - {_write(item, indent + 4)[indent + 4 :]}"
|
|
41
|
+
else:
|
|
42
|
+
raise TypeError(f"{key}: no YAML for a {type(value).__name__}")
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _scalar(value: str, indent: int) -> str:
|
|
47
|
+
if _plain(value):
|
|
48
|
+
return value
|
|
49
|
+
if "\n" not in value or "\r" in value or value.startswith(" ") or value.endswith("\n\n"):
|
|
50
|
+
return _quoted(value)
|
|
51
|
+
clip = value.endswith("\n")
|
|
52
|
+
pad = " " * indent
|
|
53
|
+
text = "|" if clip else "|-"
|
|
54
|
+
for line in (value[:-1] if clip else value).split("\n"):
|
|
55
|
+
text += "\n" if line == "" else f"\n{pad}{line}"
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _plain(value: str) -> bool:
|
|
60
|
+
return (
|
|
61
|
+
value != ""
|
|
62
|
+
and not _EDGES.search(value)
|
|
63
|
+
and value[0] not in "-?:,[]{}#&*!|>'\"%@`"
|
|
64
|
+
and ": " not in value
|
|
65
|
+
and " #" not in value
|
|
66
|
+
and not value.endswith(":")
|
|
67
|
+
and not _CONTROLS.search(value)
|
|
68
|
+
and not _WORDS.match(value)
|
|
69
|
+
and not _NUMBER.match(value)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _quoted(value: str) -> str:
|
|
74
|
+
text = '"'
|
|
75
|
+
for c in value:
|
|
76
|
+
if c == '"':
|
|
77
|
+
text += '\\"'
|
|
78
|
+
elif c == "\\":
|
|
79
|
+
text += "\\\\"
|
|
80
|
+
elif c == "\n":
|
|
81
|
+
text += "\\n"
|
|
82
|
+
elif c == "\t":
|
|
83
|
+
text += "\\t"
|
|
84
|
+
elif c == "\r":
|
|
85
|
+
text += "\\r"
|
|
86
|
+
elif _CONTROLS.match(c):
|
|
87
|
+
text += f"\\x{ord(c):02x}"
|
|
88
|
+
else:
|
|
89
|
+
text += c
|
|
90
|
+
return text + '"'
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Loader(yaml.SafeLoader):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# YAML 1.2's core schema, as Go and Symfony read it: yes and no are words, and a
|
|
98
|
+
# date is the text it is written as.
|
|
99
|
+
_Loader.yaml_implicit_resolvers = {
|
|
100
|
+
first: [
|
|
101
|
+
(tag, pattern)
|
|
102
|
+
for tag, pattern in resolvers
|
|
103
|
+
if tag not in ("tag:yaml.org,2002:bool", "tag:yaml.org,2002:timestamp")
|
|
104
|
+
]
|
|
105
|
+
for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
106
|
+
}
|
|
107
|
+
_Loader.add_implicit_resolver(
|
|
108
|
+
"tag:yaml.org,2002:bool", re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load(text: str) -> Any:
|
|
113
|
+
return yaml.load(text, Loader=_Loader)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read(path: Path) -> Any:
|
|
117
|
+
return load(path.read_text(encoding="utf-8")) if path.is_file() else None
|
specrig/asgi.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import unquote, urlsplit
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import anyio.from_thread
|
|
12
|
+
|
|
13
|
+
Message = dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# An ASGI server of the harness's own, in a thread of its own: the service's lifespan
|
|
17
|
+
# runs from the boot to the end of the case, and a request is answered whole — its
|
|
18
|
+
# background tasks included — before the step that sent it goes on.
|
|
19
|
+
class Server:
|
|
20
|
+
def __init__(self, app: Callable[..., Any]) -> None:
|
|
21
|
+
self.app = app
|
|
22
|
+
self.state: dict[str, Any] = {}
|
|
23
|
+
self._stack = contextlib.ExitStack()
|
|
24
|
+
self.portal = self._stack.enter_context(anyio.from_thread.start_blocking_portal())
|
|
25
|
+
self._to_app, self._app_reads = anyio.create_memory_object_stream[Message](math.inf)
|
|
26
|
+
self._app_sends, self._from_app = anyio.create_memory_object_stream[Message | None](math.inf)
|
|
27
|
+
self._lifespan = False
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
self.portal.start_task_soon(self._run_lifespan)
|
|
31
|
+
answer = self.portal.call(self._lifespan_event, "startup")
|
|
32
|
+
if answer is None:
|
|
33
|
+
return
|
|
34
|
+
if answer["type"] == "lifespan.startup.failed":
|
|
35
|
+
raise RuntimeError(f"the service did not start: {answer.get('message', '')}")
|
|
36
|
+
self._lifespan = True
|
|
37
|
+
|
|
38
|
+
def stop(self) -> None:
|
|
39
|
+
try:
|
|
40
|
+
if self._lifespan:
|
|
41
|
+
answer = self.portal.call(self._lifespan_event, "shutdown")
|
|
42
|
+
if answer is not None and answer["type"] == "lifespan.shutdown.failed":
|
|
43
|
+
raise RuntimeError(f"the service did not stop: {answer.get('message', '')}")
|
|
44
|
+
finally:
|
|
45
|
+
self._stack.close()
|
|
46
|
+
|
|
47
|
+
def request(
|
|
48
|
+
self, method: str, url: str, headers: Mapping[str, str], body: bytes, client: str = "127.0.0.1"
|
|
49
|
+
) -> tuple[int, list[tuple[str, str]], bytes]:
|
|
50
|
+
parts = urlsplit(url)
|
|
51
|
+
host = parts.netloc or "testserver"
|
|
52
|
+
sent = [(b"host", host.encode())]
|
|
53
|
+
sent += [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items() if k.lower() != "host"]
|
|
54
|
+
if body and not any(k == b"content-length" for k, _ in sent):
|
|
55
|
+
sent.append((b"content-length", str(len(body)).encode()))
|
|
56
|
+
scope = {
|
|
57
|
+
"type": "http",
|
|
58
|
+
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
|
59
|
+
"http_version": "1.1",
|
|
60
|
+
"method": method.upper(),
|
|
61
|
+
"scheme": parts.scheme or "http",
|
|
62
|
+
"path": unquote(parts.path) or "/",
|
|
63
|
+
"raw_path": (parts.path or "/").encode(),
|
|
64
|
+
"query_string": parts.query.encode(),
|
|
65
|
+
"root_path": "",
|
|
66
|
+
"headers": sent,
|
|
67
|
+
"client": (client, 50000),
|
|
68
|
+
"server": (parts.hostname or "testserver", parts.port or (443 if parts.scheme == "https" else 80)),
|
|
69
|
+
"state": dict(self.state),
|
|
70
|
+
}
|
|
71
|
+
return self.portal.call(self._request, scope, body)
|
|
72
|
+
|
|
73
|
+
async def _run_lifespan(self) -> None:
|
|
74
|
+
scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.0"}, "state": self.state}
|
|
75
|
+
try:
|
|
76
|
+
await self.app(scope, self._app_reads.receive, self._app_sends.send)
|
|
77
|
+
except Exception:
|
|
78
|
+
if self._lifespan:
|
|
79
|
+
logging.getLogger("specrig").exception("the service's lifespan failed")
|
|
80
|
+
finally:
|
|
81
|
+
await self._app_sends.send(None)
|
|
82
|
+
|
|
83
|
+
async def _lifespan_event(self, event: str) -> Message | None:
|
|
84
|
+
await self._to_app.send({"type": f"lifespan.{event}"})
|
|
85
|
+
return await self._from_app.receive()
|
|
86
|
+
|
|
87
|
+
async def _request(self, scope: dict[str, Any], body: bytes) -> tuple[int, list[tuple[str, str]], bytes]:
|
|
88
|
+
read = False
|
|
89
|
+
done = anyio.Event()
|
|
90
|
+
status: int | None = None
|
|
91
|
+
headers: list[tuple[str, str]] = []
|
|
92
|
+
chunks: list[bytes] = []
|
|
93
|
+
|
|
94
|
+
async def receive() -> Message:
|
|
95
|
+
nonlocal read
|
|
96
|
+
if not read:
|
|
97
|
+
read = True
|
|
98
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
99
|
+
await done.wait()
|
|
100
|
+
return {"type": "http.disconnect"}
|
|
101
|
+
|
|
102
|
+
async def send(message: Message) -> None:
|
|
103
|
+
nonlocal status, headers
|
|
104
|
+
if message["type"] == "http.response.start":
|
|
105
|
+
status = message["status"]
|
|
106
|
+
headers = [(k.decode("latin-1"), v.decode("latin-1")) for k, v in message.get("headers", [])]
|
|
107
|
+
elif message["type"] == "http.response.body":
|
|
108
|
+
chunks.append(message.get("body", b""))
|
|
109
|
+
if not message.get("more_body", False):
|
|
110
|
+
done.set()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
await self.app(scope, receive, send)
|
|
114
|
+
except Exception:
|
|
115
|
+
logging.getLogger("specrig").exception("the service failed on %s %s", scope["method"], scope["path"])
|
|
116
|
+
if status is None:
|
|
117
|
+
return 500, [], b""
|
|
118
|
+
finally:
|
|
119
|
+
done.set()
|
|
120
|
+
return status or 500, headers, b"".join(chunks)
|
specrig/backend.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from . import http
|
|
11
|
+
from .contract import Contract
|
|
12
|
+
from .http import Request, Response, Unreachable
|
|
13
|
+
from .records import Trace
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
Query = Mapping[str, str | int | Sequence[str | int]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Received:
|
|
22
|
+
method: str
|
|
23
|
+
path: str
|
|
24
|
+
query: dict[str, list[str]]
|
|
25
|
+
json: Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StandIn(ABC):
|
|
29
|
+
origin: str
|
|
30
|
+
setting: str | None
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def attach(self, name: str, trace: Trace, root: Path) -> None: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def answer(self, request: Request) -> Response: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def failures(self) -> list[str]: ...
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def fresh(self) -> StandIn: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Backend(StandIn):
|
|
46
|
+
def __init__(
|
|
47
|
+
self, origin: str, setting: str | None = None, contract: str | None = None, failure: str | None = None
|
|
48
|
+
) -> None:
|
|
49
|
+
self.origin = http.origin(origin)
|
|
50
|
+
self.setting = setting
|
|
51
|
+
self._contract = contract
|
|
52
|
+
self._failure = failure
|
|
53
|
+
self.name = ""
|
|
54
|
+
self._trace = Trace()
|
|
55
|
+
self._checked: Contract | None = None
|
|
56
|
+
self._replies: dict[str, list[tuple[dict[str, list[str]], int, Any]]] = {}
|
|
57
|
+
self._timeouts: dict[str, list[dict[str, list[str]]]] = {}
|
|
58
|
+
self._received: list[Received] = []
|
|
59
|
+
self._unanswered: list[str] = []
|
|
60
|
+
self._violations: list[str] = []
|
|
61
|
+
|
|
62
|
+
def fresh(self) -> Backend:
|
|
63
|
+
return Backend(self.origin, self.setting, self._contract, self._failure)
|
|
64
|
+
|
|
65
|
+
def attach(self, name: str, trace: Trace, root: Path) -> None:
|
|
66
|
+
self.name = name
|
|
67
|
+
self._trace = trace
|
|
68
|
+
if self._contract is not None:
|
|
69
|
+
self._checked = Contract.load((root / self._contract).resolve(), name, self._failure)
|
|
70
|
+
|
|
71
|
+
def failures(self) -> list[str]:
|
|
72
|
+
failures = []
|
|
73
|
+
if self._unanswered:
|
|
74
|
+
failures.append(f"{self.name} was called where no step declared its answer: {', '.join(self._unanswered)}")
|
|
75
|
+
if self._violations:
|
|
76
|
+
failures.append(
|
|
77
|
+
f"what {self.name}'s contract, {self._contract}, does not allow: {'; '.join(self._violations)}"
|
|
78
|
+
)
|
|
79
|
+
return failures
|
|
80
|
+
|
|
81
|
+
def reply(self, method: str, path: str, status: int, body: Any = None) -> None:
|
|
82
|
+
self.reply_to(method, path, {}, status, body)
|
|
83
|
+
|
|
84
|
+
def reply_to(self, method: str, path: str, query: Query, status: int, body: Any = None) -> None:
|
|
85
|
+
key = f"{method.upper()} {path}"
|
|
86
|
+
asked = _listed(query)
|
|
87
|
+
kept = [r for r in self._replies.get(key, []) if r[0] != asked]
|
|
88
|
+
self._replies[key] = [*kept, (asked, status, copy.deepcopy(body))]
|
|
89
|
+
|
|
90
|
+
def time_out(self, method: str, path: str, query: Query | None = None) -> None:
|
|
91
|
+
key = f"{method.upper()} {path}"
|
|
92
|
+
asked = _listed(query or {})
|
|
93
|
+
self._timeouts[key] = [*(q for q in self._timeouts.get(key, []) if q != asked), asked]
|
|
94
|
+
|
|
95
|
+
def requests(self, method: str, path: str) -> list[Received]:
|
|
96
|
+
return [r for r in self._received if r.method == method.upper() and r.path == path]
|
|
97
|
+
|
|
98
|
+
def answer(self, request: Request) -> Response:
|
|
99
|
+
method = request.method.upper()
|
|
100
|
+
path = request.path()
|
|
101
|
+
key = f"{method} {path}"
|
|
102
|
+
asked = http.query(request.search())
|
|
103
|
+
body = http.decoded(request.body)
|
|
104
|
+
call = self._trace.begin()
|
|
105
|
+
try:
|
|
106
|
+
self._received.append(Received(method, path, asked, body))
|
|
107
|
+
if self._checked is not None:
|
|
108
|
+
self._violations.extend(self._checked.request(method, path, body))
|
|
109
|
+
if _pick(self._timeouts.get(key, []), asked, lambda q: q) is not None:
|
|
110
|
+
raise Unreachable(f"{self.name} did not answer {key} in time", timed_out=True)
|
|
111
|
+
return self._fulfil(method, path, key + request.search(), asked)
|
|
112
|
+
finally:
|
|
113
|
+
name = self._checked.name(method, path) if self._checked is not None else key
|
|
114
|
+
self._trace.end(call, "out", "http", name, body)
|
|
115
|
+
|
|
116
|
+
def _fulfil(self, method: str, path: str, asked: str, query: dict[str, list[str]]) -> Response:
|
|
117
|
+
reply = _pick(self._replies.get(f"{method} {path}", []), query, lambda r: r[0])
|
|
118
|
+
if reply is None:
|
|
119
|
+
self._unanswered.append(asked)
|
|
120
|
+
return _respond(501, {"code": "unanswered", "message": f"{method} {path}"})
|
|
121
|
+
_, status, body = reply
|
|
122
|
+
if self._checked is not None:
|
|
123
|
+
self._violations.extend(self._checked.response(method, path, status, {} if body is None else body))
|
|
124
|
+
return _respond(status, None if status == 204 else ({} if body is None else body))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _respond(status: int, body: Any) -> Response:
|
|
128
|
+
if body is None:
|
|
129
|
+
return Response(status)
|
|
130
|
+
return Response(status, {"Content-Type": "application/json"}, http.encoded(body))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pick(candidates: list[T], query: dict[str, list[str]], of: Callable[[T], dict[str, list[str]]]) -> T | None:
|
|
134
|
+
found: T | None = None
|
|
135
|
+
for candidate in candidates:
|
|
136
|
+
if _matches(of(candidate), query) and len(of(candidate)) >= (0 if found is None else len(of(found))):
|
|
137
|
+
found = candidate
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _matches(wanted: dict[str, list[str]], got: dict[str, list[str]]) -> bool:
|
|
142
|
+
for name, values in wanted.items():
|
|
143
|
+
have = got.get(name)
|
|
144
|
+
if have is None or (have[:1] != values if len(values) == 1 else have != values):
|
|
145
|
+
return False
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _listed(query: Query) -> dict[str, list[str]]:
|
|
150
|
+
return {
|
|
151
|
+
name: [str(v) for v in value] if isinstance(value, (list, tuple)) else [str(value)]
|
|
152
|
+
for name, value in query.items()
|
|
153
|
+
}
|
specrig/clock.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
|
|
5
|
+
import time_machine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Clock:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._stopped: dt.datetime | None = None
|
|
11
|
+
self._travel: time_machine.travel | None = None
|
|
12
|
+
|
|
13
|
+
def now(self) -> dt.datetime:
|
|
14
|
+
return self._stopped or dt.datetime.now(dt.UTC)
|
|
15
|
+
|
|
16
|
+
def stopped(self) -> dt.datetime | None:
|
|
17
|
+
return self._stopped
|
|
18
|
+
|
|
19
|
+
def set(self, at: dt.datetime | dt.date | str) -> None:
|
|
20
|
+
if isinstance(at, str):
|
|
21
|
+
at = dt.datetime.fromisoformat(at)
|
|
22
|
+
elif not isinstance(at, dt.datetime):
|
|
23
|
+
at = dt.datetime.combine(at, dt.time())
|
|
24
|
+
self._move(at if at.tzinfo else at.replace(tzinfo=dt.UTC))
|
|
25
|
+
|
|
26
|
+
def advance(self, by: dt.timedelta) -> None:
|
|
27
|
+
if self._stopped is None:
|
|
28
|
+
raise RuntimeError("the clock follows the wall clock until a step sets it")
|
|
29
|
+
self._move(self._stopped + by)
|
|
30
|
+
|
|
31
|
+
def release(self) -> None:
|
|
32
|
+
if self._travel is not None:
|
|
33
|
+
self._travel.stop()
|
|
34
|
+
self._travel = None
|
|
35
|
+
self._stopped = None
|
|
36
|
+
|
|
37
|
+
def _move(self, at: dt.datetime) -> None:
|
|
38
|
+
self.release()
|
|
39
|
+
self._travel = time_machine.travel(at, tick=False)
|
|
40
|
+
self._travel.start()
|
|
41
|
+
self._stopped = at
|
specrig/code/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..surface import Surface
|
|
6
|
+
from ..tool.service import Service
|
|
7
|
+
from . import calls, django, fastapi, settings
|
|
8
|
+
from .index import Code
|
|
9
|
+
|
|
10
|
+
_SKIPPED = {"tests", "scripts", "venv", "env", "node_modules", "__pycache__", "build", "dist", "site-packages"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read(service: Service, name: str) -> Surface:
|
|
14
|
+
code = Code(service.root, files(service.root))
|
|
15
|
+
surface = Surface(name)
|
|
16
|
+
calls.read(code, surface)
|
|
17
|
+
settings.read(code, surface)
|
|
18
|
+
if service.framework() == "django":
|
|
19
|
+
django.read(code, surface, service.django_settings())
|
|
20
|
+
else:
|
|
21
|
+
fastapi.read(code, surface)
|
|
22
|
+
return surface
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def files(root: Path) -> list[Path]:
|
|
26
|
+
found = []
|
|
27
|
+
for file in root.rglob("*.py"):
|
|
28
|
+
parts = file.relative_to(root).parts[:-1]
|
|
29
|
+
if file.name != "conftest.py" and not any(part in _SKIPPED or part.startswith(".") for part in parts):
|
|
30
|
+
found.append(file)
|
|
31
|
+
return sorted(found)
|
specrig/code/calls.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..surface import Surface
|
|
7
|
+
from .index import UNCALLED, VARYING, Code
|
|
8
|
+
|
|
9
|
+
_CLIENTS = {
|
|
10
|
+
"httpx.Client",
|
|
11
|
+
"httpx.AsyncClient",
|
|
12
|
+
"httpx2.Client",
|
|
13
|
+
"httpx2.AsyncClient",
|
|
14
|
+
"requests.Session",
|
|
15
|
+
"requests.sessions.Session",
|
|
16
|
+
}
|
|
17
|
+
_MODULES = {"httpx", "httpx2", "requests"}
|
|
18
|
+
_VERBS = {"get", "head", "post", "put", "patch", "delete", "options"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read(code: Code, surface: Surface) -> None:
|
|
22
|
+
for module in code.modules.values():
|
|
23
|
+
for call in ast.walk(module.tree):
|
|
24
|
+
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute):
|
|
25
|
+
continue
|
|
26
|
+
method = call.func.attr
|
|
27
|
+
if method not in _VERBS and method not in ("request", "stream"):
|
|
28
|
+
continue
|
|
29
|
+
types = set(code.types(call.func.value))
|
|
30
|
+
if not types & _CLIENTS and not types & _MODULES:
|
|
31
|
+
continue
|
|
32
|
+
if method in _VERBS:
|
|
33
|
+
verbs, url = [method.upper()], code.argument(call, 0, "url")
|
|
34
|
+
else:
|
|
35
|
+
verbs = [
|
|
36
|
+
v.upper()
|
|
37
|
+
for v in code.templates(code.argument(call, 0, "method"))
|
|
38
|
+
if VARYING not in v and UNCALLED not in v
|
|
39
|
+
]
|
|
40
|
+
url = code.argument(call, 1, "url")
|
|
41
|
+
for template in code.templates(url):
|
|
42
|
+
if template.startswith(UNCALLED):
|
|
43
|
+
continue
|
|
44
|
+
for verb in verbs:
|
|
45
|
+
surface.call(code.where(call), f"{verb} {path(template.replace(UNCALLED, VARYING))}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A request's path as the record names it: what varies is {}, an address that starts
|
|
49
|
+
# with what varies is a base address and its path, and one with no constant part is
|
|
50
|
+
# from data, somewhere outside.
|
|
51
|
+
def path(template: str) -> str:
|
|
52
|
+
route = template.split("?", 1)[0]
|
|
53
|
+
absolute = re.match(r"^[a-z][a-z0-9+.-]*://[^/]*(/.*)?$", route, re.IGNORECASE)
|
|
54
|
+
if absolute:
|
|
55
|
+
route = absolute.group(1) or ""
|
|
56
|
+
elif route.startswith(VARYING) and "/" in route:
|
|
57
|
+
route = route[route.index("/") :]
|
|
58
|
+
if route.replace("/", "") in (VARYING, ""):
|
|
59
|
+
return "/" if route == "/" else "external"
|
|
60
|
+
segments = [s for s in route.split("/") if s != ""]
|
|
61
|
+
return "/" + "/".join(VARYING if VARYING in s else s for s in segments)
|
specrig/code/django.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..http import route
|
|
7
|
+
from ..surface import Surface
|
|
8
|
+
from .index import UNCALLED, VARYING, Code
|
|
9
|
+
|
|
10
|
+
_PATHS = {
|
|
11
|
+
"django.urls.path",
|
|
12
|
+
"django.urls.re_path",
|
|
13
|
+
"django.conf.urls.url",
|
|
14
|
+
"django.urls.conf.path",
|
|
15
|
+
"django.urls.conf.re_path",
|
|
16
|
+
}
|
|
17
|
+
_INCLUDE = {"django.urls.include", "django.conf.urls.include", "django.urls.conf.include"}
|
|
18
|
+
_VERBS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
19
|
+
_DECORATED = {
|
|
20
|
+
"django.views.decorators.http.require_GET": ["GET"],
|
|
21
|
+
"django.views.decorators.http.require_POST": ["POST"],
|
|
22
|
+
"django.views.decorators.http.require_safe": ["GET", "HEAD"],
|
|
23
|
+
}
|
|
24
|
+
_LISTED = {"django.views.decorators.http.require_http_methods", "rest_framework.decorators.api_view"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read(code: Code, surface: Surface, settings: str) -> None:
|
|
28
|
+
urlconf = code.definitions.get(f"{settings}.ROOT_URLCONF")
|
|
29
|
+
if urlconf is None:
|
|
30
|
+
raise ValueError(f"{settings} sets no ROOT_URLCONF the code names")
|
|
31
|
+
_module(code, _single(code, urlconf), "", surface, set())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _single(code: Code, expr: ast.AST) -> str:
|
|
35
|
+
found = code.templates(expr)
|
|
36
|
+
if len(found) != 1 or VARYING in found[0] or UNCALLED in found[0]:
|
|
37
|
+
raise ValueError("a URLconf named by what is not a constant")
|
|
38
|
+
return found[0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _module(code: Code, name: str, prefix: str, surface: Surface, seen: set[str]) -> None:
|
|
42
|
+
if name in seen or name not in code.modules:
|
|
43
|
+
return
|
|
44
|
+
seen = {*seen, name}
|
|
45
|
+
patterns: list[ast.expr] = []
|
|
46
|
+
for stmt in code.modules[name].tree.body:
|
|
47
|
+
if isinstance(stmt, ast.Assign) and any(
|
|
48
|
+
isinstance(t, ast.Name) and t.id == "urlpatterns" for t in stmt.targets
|
|
49
|
+
):
|
|
50
|
+
patterns = _listed(stmt.value)
|
|
51
|
+
elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name) and stmt.target.id == "urlpatterns":
|
|
52
|
+
patterns += _listed(stmt.value)
|
|
53
|
+
_patterns(code, patterns, prefix, surface, seen)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _listed(expr: ast.expr) -> list[ast.expr]:
|
|
57
|
+
if isinstance(expr, (ast.List, ast.Tuple)):
|
|
58
|
+
return list(expr.elts)
|
|
59
|
+
if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
|
|
60
|
+
return [*_listed(expr.left), *_listed(expr.right)]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _patterns(code: Code, patterns: list[ast.expr], prefix: str, surface: Surface, seen: set[str]) -> None:
|
|
65
|
+
for pattern in patterns:
|
|
66
|
+
if not isinstance(pattern, ast.Call) or code.qualified(pattern.func) not in _PATHS:
|
|
67
|
+
continue
|
|
68
|
+
at = prefix + _single(code, code.argument(pattern, 0, "route"))
|
|
69
|
+
view = code.argument(pattern, 1, "view")
|
|
70
|
+
if isinstance(view, ast.Call) and code.qualified(view.func) in _INCLUDE:
|
|
71
|
+
included = code.argument(view, 0, "arg")
|
|
72
|
+
if isinstance(included, ast.Tuple) and included.elts:
|
|
73
|
+
included = included.elts[0]
|
|
74
|
+
if isinstance(included, (ast.List, ast.Tuple)):
|
|
75
|
+
_patterns(code, list(included.elts), at, surface, seen)
|
|
76
|
+
elif isinstance(included, ast.Constant):
|
|
77
|
+
_module(code, str(included.value), at, surface, seen)
|
|
78
|
+
else:
|
|
79
|
+
_module(code, code.qualified(included) or "", at, surface, seen)
|
|
80
|
+
continue
|
|
81
|
+
for method in _methods(code, view):
|
|
82
|
+
surface.serves.add(f"{method} {_route(at)}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The view says which methods it answers; one that does not answers any, *.
|
|
86
|
+
def _methods(code: Code, view: ast.AST | None) -> list[str]:
|
|
87
|
+
if isinstance(view, ast.Call) and isinstance(view.func, ast.Attribute) and view.func.attr == "as_view":
|
|
88
|
+
klass = code.qualified(view.func.value) or ""
|
|
89
|
+
defined = {
|
|
90
|
+
verb
|
|
91
|
+
for at in code.ancestry(klass)
|
|
92
|
+
if isinstance(definition := code.definitions.get(at), ast.ClassDef)
|
|
93
|
+
for verb in _VERBS
|
|
94
|
+
if f"{at}.{verb}" in code.definitions
|
|
95
|
+
}
|
|
96
|
+
return sorted(v.upper() for v in defined) or ["*"]
|
|
97
|
+
definition = code.definitions.get(code.qualified(view) or "")
|
|
98
|
+
if isinstance(definition, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
99
|
+
for decorator in definition.decorator_list:
|
|
100
|
+
named = code.qualified(decorator.func if isinstance(decorator, ast.Call) else decorator)
|
|
101
|
+
if named in _DECORATED:
|
|
102
|
+
return _DECORATED[named]
|
|
103
|
+
if named in _LISTED and isinstance(decorator, ast.Call):
|
|
104
|
+
listed = code.argument(decorator, 0, "http_method_names")
|
|
105
|
+
if isinstance(listed, (ast.List, ast.Tuple)):
|
|
106
|
+
return [m.upper() for e in listed.elts for m in code.templates(e) if VARYING not in m]
|
|
107
|
+
return ["GET"]
|
|
108
|
+
return ["*"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _route(template: str) -> str:
|
|
112
|
+
if template.startswith("^") or "(?P<" in template:
|
|
113
|
+
template = re.sub(r"\(\?P<(\w+)>[^)]*\)", r"{\1}", template)
|
|
114
|
+
template = template.replace("^", "").replace("\\Z", "").replace("$", "")
|
|
115
|
+
template = re.sub(
|
|
116
|
+
r"<(?:(\w+):)?(\w+)>", lambda m: "{" + m.group(2) + (":path}" if m.group(1) == "path" else "}"), template
|
|
117
|
+
)
|
|
118
|
+
return route(template, lambda name, kind: kind == "path")
|