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.
Files changed (48) hide show
  1. specrig/__init__.py +7 -0
  2. specrig/__main__.py +5 -0
  3. specrig/_yaml.py +117 -0
  4. specrig/asgi.py +120 -0
  5. specrig/backend.py +153 -0
  6. specrig/clock.py +41 -0
  7. specrig/code/__init__.py +31 -0
  8. specrig/code/calls.py +61 -0
  9. specrig/code/django.py +118 -0
  10. specrig/code/fastapi.py +129 -0
  11. specrig/code/index.py +477 -0
  12. specrig/code/settings.py +132 -0
  13. specrig/contract.py +106 -0
  14. specrig/django/__init__.py +188 -0
  15. specrig/docker.py +133 -0
  16. specrig/fastapi/__init__.py +244 -0
  17. specrig/flows.py +285 -0
  18. specrig/gate.py +95 -0
  19. specrig/http.py +168 -0
  20. specrig/postgres.py +278 -0
  21. specrig/protocol/agents/contributing.md +6 -0
  22. specrig/protocol/agents/header.md +5 -0
  23. specrig/protocol/agents/reference.md +1 -0
  24. specrig/protocol/agents/rule11.md +423 -0
  25. specrig/protocol/agents/short.md +19 -0
  26. specrig/protocol/modes.yaml +55 -0
  27. specrig/proxy.py +73 -0
  28. specrig/py.typed +0 -0
  29. specrig/records.py +48 -0
  30. specrig/redis.py +154 -0
  31. specrig/resource.py +49 -0
  32. specrig/rig.py +370 -0
  33. specrig/run.py +22 -0
  34. specrig/standins.py +112 -0
  35. specrig/suite.py +239 -0
  36. specrig/surface.py +87 -0
  37. specrig/tool/__init__.py +0 -0
  38. specrig/tool/agents.py +150 -0
  39. specrig/tool/cli.py +137 -0
  40. specrig/tool/init.py +244 -0
  41. specrig/tool/modes.py +54 -0
  42. specrig/tool/service.py +209 -0
  43. specrig/tool/templates.py +196 -0
  44. specrig-0.1.0.dist-info/METADATA +603 -0
  45. specrig-0.1.0.dist-info/RECORD +48 -0
  46. specrig-0.1.0.dist-info/WHEEL +4 -0
  47. specrig-0.1.0.dist-info/entry_points.txt +5 -0
  48. specrig-0.1.0.dist-info/licenses/LICENSE +21 -0
specrig/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .backend import Backend, Received
2
+ from .http import Response
3
+ from .postgres import Postgres
4
+ from .redis import Redis
5
+ from .rig import Logged
6
+
7
+ __all__ = ["Backend", "Logged", "Postgres", "Received", "Redis", "Response"]
specrig/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .tool.cli import main
4
+
5
+ sys.exit(main())
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
@@ -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")