pyhttp-app 2.0.1__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.
pyhttp/mock.py ADDED
@@ -0,0 +1,204 @@
1
+ """Mock servers served from pyhttp's own port.
2
+
3
+ A mock server is a name, a slug and a list of routes. Everything under
4
+ `/mock/<slug>/…` on the port pyhttp already listens on is answered from those
5
+ routes, so there is no second process and no second port to remember:
6
+
7
+ http://127.0.0.1:8765/mock/payments/users/42
8
+
9
+ A route path may hold parameters written either way (`/users/:id` or
10
+ `/users/{id}`); `*` matches one segment and `**` matches the rest of the path
11
+ (as `{{req.params.rest}}`).
12
+ The response body is a `{{ ... }}` template, so besides variables and the
13
+ dynamic values (`{{$guid}}`, `{{$randomEmail}}`…) it can read the incoming
14
+ request:
15
+
16
+ {{req.params.id}} a path parameter (from `:id` / `{id}` in the route path)
17
+ {{req.query.limit}} a query-string value
18
+ {{req.headers.x-key}} a request header (case-insensitive)
19
+ {{req.body}} the raw request body
20
+ {{req.json.user.name}} a path into the JSON request body
21
+ {{req.method}} {{req.path}}
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import time
28
+ from typing import Any
29
+ from urllib.parse import parse_qsl
30
+
31
+ from . import db
32
+ from .templating import build_context, render
33
+
34
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
35
+ MAX_LOGGED_BODY = 4000 # a logged call keeps at most this much of the request body
36
+ _PARAM = re.compile(r":([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\}")
37
+
38
+
39
+ def normalize_path(path: str) -> str:
40
+ """`users/:id/` -> `/users/:id` so routes and incoming paths compare equal."""
41
+ path = (path or "").strip()
42
+ path = path.split("?", 1)[0].split("#", 1)[0]
43
+ if not path.startswith("/"):
44
+ path = "/" + path
45
+ return path.rstrip("/") or "/"
46
+
47
+
48
+ def compile_path(path: str) -> tuple[re.Pattern[str], int]:
49
+ """Regex for a route path, plus how many wildcard segments it has (lower = more specific)."""
50
+ parts: list[str] = []
51
+ wild = 0
52
+ for seg in normalize_path(path).strip("/").split("/"):
53
+ if not seg:
54
+ continue
55
+ if seg == "**":
56
+ parts.append("(?P<rest>.*)")
57
+ wild += 100
58
+ break
59
+ if seg == "*":
60
+ parts.append("[^/]+")
61
+ wild += 1
62
+ continue
63
+ m = _PARAM.fullmatch(seg)
64
+ if m:
65
+ parts.append(f"(?P<{m.group(1) or m.group(2)}>[^/]+)")
66
+ wild += 1
67
+ else:
68
+ parts.append(re.escape(seg))
69
+ pattern = "/" + "/".join(parts) if parts else "/"
70
+ return re.compile(f"^{pattern}/?$"), wild
71
+
72
+
73
+ def _query_conditions(match_query: str) -> list[tuple[str, str]]:
74
+ """`status=error&kind=card` -> the pairs an incoming query must contain."""
75
+ return [(k, v) for k, v in parse_qsl(match_query.lstrip("?"), keep_blank_values=True)]
76
+
77
+
78
+ def match_route(routes: list[dict[str, Any]], method: str, path: str,
79
+ query: dict[str, str]) -> tuple[dict[str, Any] | None, dict[str, str]]:
80
+ """The best route for a request: exact paths beat parameters, and a route with
81
+ query conditions beats a plain one. Ties go to the earlier route."""
82
+ method, path = method.upper(), normalize_path(path)
83
+ best: tuple[tuple[int, int, int], dict[str, Any], dict[str, str]] | None = None
84
+ for pos, route in enumerate(routes):
85
+ if not route.get("enabled", True):
86
+ continue
87
+ route_method = str(route.get("method") or "GET").upper()
88
+ if route_method not in ("ANY", "*") and route_method != method:
89
+ continue
90
+ rx, wild = compile_path(route.get("path") or "/")
91
+ m = rx.match(path)
92
+ if not m:
93
+ continue
94
+ conditions = _query_conditions(route.get("match_query") or "")
95
+ if any(query.get(k) != v for k, v in conditions):
96
+ continue
97
+ rank = (wild, -len(conditions), pos) # smaller wins
98
+ if best is None or rank < best[0]:
99
+ best = (rank, route, {k: v for k, v in m.groupdict().items() if v is not None})
100
+ if best is None and method == "HEAD":
101
+ return match_route(routes, "GET", path, query) # HEAD is a GET without the body
102
+ return (best[1], best[2]) if best else (None, {})
103
+
104
+
105
+ def request_context(method: str, path: str, query: dict[str, str], headers: dict[str, str],
106
+ body: str, params: dict[str, str]) -> dict[str, Any]:
107
+ """Template context for a mock response: variables plus a `req` object."""
108
+ parsed: Any = {}
109
+ if body:
110
+ try:
111
+ parsed = json.loads(body)
112
+ except ValueError:
113
+ parsed = {}
114
+ variables, _env = db.effective_variables()
115
+ ctx = build_context(variables)
116
+ ctx["req"] = {"method": method.upper(), "path": normalize_path(path), "params": params,
117
+ "query": dict(query), "headers": dict(headers), "body": body, "json": parsed}
118
+ return ctx
119
+
120
+
121
+ def cors_headers(origin: str = "*", request_headers: str = "") -> dict[str, str]:
122
+ return {
123
+ "Access-Control-Allow-Origin": origin or "*",
124
+ "Access-Control-Allow-Methods": ", ".join(METHODS),
125
+ "Access-Control-Allow-Headers": request_headers or "*",
126
+ "Access-Control-Max-Age": "600",
127
+ }
128
+
129
+
130
+ def build_response(server: dict[str, Any], route: dict[str, Any], ctx: dict[str, Any]) -> dict[str, Any]:
131
+ """Status, headers and rendered body for a matched route."""
132
+ body = render(route.get("body") or "", ctx)
133
+ headers = {"Content-Type": route.get("content_type") or "application/json"}
134
+ for row in route.get("headers") or []:
135
+ if row.get("enabled", True) and str(row.get("key") or "").strip():
136
+ headers[str(row["key"]).strip()] = render(str(row.get("value") or ""), ctx)
137
+ if server.get("cors", True):
138
+ headers.update(cors_headers())
139
+ return {"status_code": int(route.get("status_code") or 200), "headers": headers, "body": body,
140
+ "delay_s": (float(server.get("delay_ms") or 0) + float(route.get("delay_ms") or 0)) / 1000.0}
141
+
142
+
143
+ def not_found_body(server: dict[str, Any], method: str, path: str) -> dict[str, Any]:
144
+ """The answer when nothing matches: a 404 that says what the server does know."""
145
+ routes = server.get("routes") or []
146
+ known = [f"{r.get('method', 'GET')} {normalize_path(r.get('path') or '/')}" for r in routes
147
+ if r.get("enabled", True)]
148
+ detail = (f"No mock route in \"{server['name']}\" matches {method.upper()} {normalize_path(path)}")
149
+ body = json.dumps({"error": "no_mock_route", "detail": detail, "mock": server["name"],
150
+ "routes": known}, indent=2)
151
+ headers = {"Content-Type": "application/json"}
152
+ if server.get("cors", True):
153
+ headers.update(cors_headers())
154
+ return {"status_code": 404, "headers": headers, "body": body, "delay_s": 0.0}
155
+
156
+
157
+ def log_call(server: dict[str, Any], route: dict[str, Any] | None, method: str, path: str,
158
+ query_string: str, headers: dict[str, str], body: str, status_code: int,
159
+ duration_ms: float) -> None:
160
+ if not server.get("log_calls", True):
161
+ return
162
+ db.add_mock_call(server["id"], {
163
+ "route_id": route["id"] if route else None,
164
+ "route_name": (route.get("name") or f"{route.get('method')} {route.get('path')}") if route else "",
165
+ "ts": time.time(), "method": method.upper(), "path": normalize_path(path),
166
+ "query": query_string, "status_code": status_code, "matched": bool(route),
167
+ "duration_ms": duration_ms, "req_headers": headers, "req_body": body[:MAX_LOGGED_BODY],
168
+ })
169
+
170
+
171
+ # --------------------------------------------------------- seeding from requests
172
+ def _path_of(url: str) -> str:
173
+ """The path part of a saved request's URL, variables and all: `{{base}}/users/:id` -> `/users/:id`."""
174
+ url = (url or "").strip()
175
+ url = re.sub(r"^\s*\{\{[^}]*\}\}", "", url) # a leading {{base_url}}
176
+ url = re.sub(r"^[a-zA-Z][\w+.-]*://[^/]+", "", url) # or a real scheme + host
177
+ return normalize_path(url.split("?", 1)[0] or "/")
178
+
179
+
180
+ def routes_from_requests(request_ids: list[str]) -> list[dict[str, Any]]:
181
+ """Draft routes for saved requests, answering with the response each last gave
182
+ (from the request history) or an empty JSON object."""
183
+ history = db.list_history(limit=db.MAX_HISTORY)
184
+ last: dict[str, dict[str, Any]] = {}
185
+ for entry in history: # newest first
186
+ rid = entry.get("request_id")
187
+ if rid and rid not in last and entry.get("status_code"):
188
+ last[rid] = entry
189
+ drafts = []
190
+ for rid in request_ids:
191
+ req = db.get_request(rid)
192
+ if not req:
193
+ continue
194
+ seen = last.get(rid)
195
+ full = db.get_history(seen["id"]) if seen else None
196
+ resp = ((full or {}).get("detail") or {}).get("response") or {}
197
+ body = resp.get("body_text") or ""
198
+ status = int(resp.get("status_code") or 200)
199
+ content_type = next((v for k, v in (resp.get("headers") or []) if str(k).lower() == "content-type"),
200
+ "application/json")
201
+ drafts.append({"name": req["name"], "method": req.get("method") or "GET", "path": _path_of(req.get("url")),
202
+ "status_code": status, "content_type": content_type.split(";")[0].strip(),
203
+ "body": body or "{}"})
204
+ return drafts
pyhttp/monitors.py ADDED
@@ -0,0 +1,116 @@
1
+ """Scheduled monitors: picked requests sent again on an interval, in the background of the
2
+ running app. Each check stores a compact result row (no run folders, no history entries, so
3
+ a monitor every minute does not flood the disk); failures can ping a webhook.
4
+
5
+ A check fails when any request errors, answers 4xx/5xx, or fails one of its tests —
6
+ the same rule the collection runner uses.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from . import USER_AGENT, db, runner
17
+ from .templating import build_context
18
+
19
+ TICK_SECONDS = 5.0 # how often the scheduler looks for due monitors
20
+
21
+
22
+ async def run_check(monitor: dict[str, Any]) -> dict[str, Any]:
23
+ """Send the monitor's requests in order and store one check row. Returns the check."""
24
+ started = time.time()
25
+ results: list[dict[str, Any]] = []
26
+ failed = 0
27
+ prev: dict[str, Any] | None = None
28
+ for rid in monitor.get("request_ids") or []:
29
+ req = db.get_request(rid)
30
+ if not req:
31
+ results.append({"request_id": rid, "name": "(deleted request)", "ok": False,
32
+ "error": "the request no longer exists"})
33
+ failed += 1
34
+ continue
35
+ for item in runner.expand(req):
36
+ variables, _env = db.effective_variables(item.get("folder_id"))
37
+ ctx = build_context(variables, prev)
38
+ result = await runner.execute(item, ctx)
39
+ prev = runner.prev_context(result)
40
+ ok = result["status"] != "error" and (result.get("status_code") or 0) < 400 \
41
+ and not result.get("tests_failed")
42
+ if not ok:
43
+ failed += 1
44
+ results.append({
45
+ "request_id": rid, "name": runner.display_name(item), "method": item.get("method", ""),
46
+ "ok": ok, "status_code": result.get("status_code"),
47
+ "duration_ms": result.get("duration_ms"), "error": result.get("error"),
48
+ "tests_failed": result.get("tests_failed", 0),
49
+ })
50
+ check = {"ts": started, "ok": failed == 0, "failed": failed, "total": len(results),
51
+ "duration_ms": round((time.time() - started) * 1000, 1), "results": results}
52
+ stored = db.add_monitor_check(monitor["id"], check)
53
+ db.update_monitor(monitor["id"], last_run_at=started, last_status="fail" if failed else "pass")
54
+ if failed and (monitor.get("webhook_url") or "").strip():
55
+ await _webhook(monitor, check)
56
+ return stored
57
+
58
+
59
+ async def _webhook(monitor: dict[str, Any], check: dict[str, Any]) -> None:
60
+ """POST the failure to the monitor's webhook. Errors are swallowed: an unreachable
61
+ webhook must never break the schedule."""
62
+ payload = {
63
+ "monitor": monitor["name"], "monitor_id": monitor["id"], "status": "fail",
64
+ "failed": check["failed"], "total": check["total"], "ts": check["ts"],
65
+ "results": [r for r in check["results"] if not r["ok"]],
66
+ }
67
+ try:
68
+ async with httpx.AsyncClient(timeout=10, headers={"User-Agent": USER_AGENT}) as client:
69
+ await client.post(monitor["webhook_url"].strip(), json=payload)
70
+ except Exception: # noqa: BLE001
71
+ pass
72
+
73
+
74
+ class MonitorScheduler:
75
+ """Runs due monitors from the app's event loop. One check per monitor at a time."""
76
+
77
+ def __init__(self) -> None:
78
+ self._task: asyncio.Task[None] | None = None
79
+ self._running: set[str] = set()
80
+
81
+ def start(self) -> None:
82
+ if self._task is None or self._task.done():
83
+ self._task = asyncio.create_task(self._loop())
84
+
85
+ def stop(self) -> None:
86
+ if self._task is not None:
87
+ self._task.cancel()
88
+ self._task = None
89
+
90
+ async def _loop(self) -> None:
91
+ while True:
92
+ try:
93
+ self.tick()
94
+ except Exception: # noqa: BLE001 - the schedule must survive anything
95
+ pass
96
+ await asyncio.sleep(TICK_SECONDS)
97
+
98
+ def tick(self, now: float | None = None) -> list[asyncio.Task[Any]]:
99
+ """Start a check task for every due monitor; called by the loop and by tests."""
100
+ tasks = []
101
+ for monitor in db.due_monitors(now if now is not None else time.time()):
102
+ if monitor["id"] in self._running:
103
+ continue
104
+ self._running.add(monitor["id"])
105
+ # claim at once, so the monitor is not due again while its check runs
106
+ db.update_monitor(monitor["id"], last_run_at=time.time())
107
+ tasks.append(asyncio.create_task(self._run(monitor)))
108
+ return tasks
109
+
110
+ async def _run(self, monitor: dict[str, Any]) -> None:
111
+ try:
112
+ await run_check(monitor)
113
+ except Exception: # noqa: BLE001
114
+ db.update_monitor(monitor["id"], last_status="fail")
115
+ finally:
116
+ self._running.discard(monitor["id"])
pyhttp/oauth2.py ADDED
@@ -0,0 +1,153 @@
1
+ """OAuth 2.0 tokens for the "OAuth 2.0" auth type.
2
+
3
+ Grants that need no browser: client credentials and password (resource owner).
4
+ Tokens are cached in memory per (token URL, client, user, scope) and fetched
5
+ again when they expire; a refresh token is used first when the server gave one.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import threading
12
+ import time
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ GRANTS = ("client_credentials", "password")
18
+ _EARLY = 30 # seconds: treat a token as expired this long before it really is
19
+
20
+ _cache: dict[tuple[str, ...], dict[str, Any]] = {}
21
+ _lock = threading.Lock()
22
+ _fetch_locks: dict[tuple[str, ...], asyncio.Lock] = {}
23
+
24
+
25
+ def _key(auth: dict[str, Any]) -> tuple[str, ...]:
26
+ return (auth.get("grant") or "client_credentials", auth.get("token_url") or "", auth.get("client_id") or "",
27
+ auth.get("username") or "", auth.get("scope") or "", auth.get("audience") or "")
28
+
29
+
30
+ def cached(auth: dict[str, Any]) -> dict[str, Any] | None:
31
+ """The cached token for these (rendered) settings, if it is still valid."""
32
+ with _lock:
33
+ tok = _cache.get(_key(auth))
34
+ if tok and (not tok.get("expires_at") or tok["expires_at"] - _EARLY > time.time()):
35
+ return tok
36
+ return None
37
+
38
+
39
+ def status(auth: dict[str, Any]) -> dict[str, Any]:
40
+ """Token info for the UI (the token itself is shortened)."""
41
+ with _lock:
42
+ tok = _cache.get(_key(auth))
43
+ if not tok:
44
+ return {"has_token": False}
45
+ exp = tok.get("expires_at")
46
+ return {
47
+ "has_token": True, "valid": not exp or exp - _EARLY > time.time(), "expires_at": exp,
48
+ "token_type": tok.get("token_type") or "Bearer", "scope": tok.get("scope") or "",
49
+ "preview": _preview(tok["access_token"]), "has_refresh": bool(tok.get("refresh_token")),
50
+ "fetched_at": tok.get("fetched_at"),
51
+ }
52
+
53
+
54
+ def _preview(token: str) -> str:
55
+ return token if len(token) <= 16 else f"{token[:8]}…{token[-6:]}"
56
+
57
+
58
+ def forget(auth: dict[str, Any] | None = None) -> None:
59
+ with _lock:
60
+ if auth is None:
61
+ _cache.clear()
62
+ else:
63
+ _cache.pop(_key(auth), None)
64
+
65
+
66
+ def _store(auth: dict[str, Any], data: dict[str, Any], old_refresh: str | None = None) -> dict[str, Any]:
67
+ if not isinstance(data, dict) or not data.get("access_token"):
68
+ raise ValueError("token response has no access_token")
69
+ try:
70
+ expires_in = float(data.get("expires_in")) if data.get("expires_in") not in (None, "") else None
71
+ except (TypeError, ValueError):
72
+ expires_in = None
73
+ now = time.time()
74
+ tok = {
75
+ "access_token": str(data["access_token"]),
76
+ "token_type": str(data.get("token_type") or "Bearer"),
77
+ "refresh_token": data.get("refresh_token") or old_refresh,
78
+ "scope": str(data.get("scope") or auth.get("scope") or ""),
79
+ "expires_at": now + expires_in if expires_in else None,
80
+ "fetched_at": now,
81
+ }
82
+ with _lock:
83
+ _cache[_key(auth)] = tok
84
+ return tok
85
+
86
+
87
+ def _token_request(auth: dict[str, Any], refresh_token: str | None) -> tuple[dict[str, str], dict[str, str]]:
88
+ """Form fields and headers of the token request."""
89
+ if refresh_token:
90
+ form = {"grant_type": "refresh_token", "refresh_token": refresh_token}
91
+ elif auth.get("grant") == "password":
92
+ form = {"grant_type": "password", "username": auth.get("username") or "", "password": auth.get("password") or ""}
93
+ else:
94
+ form = {"grant_type": "client_credentials"}
95
+ if auth.get("scope"):
96
+ form["scope"] = auth["scope"]
97
+ if auth.get("audience"):
98
+ form["audience"] = auth["audience"]
99
+ headers = {"Accept": "application/json"}
100
+ cid, secret = auth.get("client_id") or "", auth.get("client_secret") or ""
101
+ if auth.get("client_auth") == "body":
102
+ if cid:
103
+ form["client_id"] = cid
104
+ if secret:
105
+ form["client_secret"] = secret
106
+ elif cid or secret:
107
+ headers["Authorization"] = "Basic " + base64.b64encode(f"{cid}:{secret}".encode()).decode()
108
+ return form, headers
109
+
110
+
111
+ async def fetch(auth: dict[str, Any], client_kwargs: dict[str, Any] | None = None, *, force: bool = False) -> dict[str, Any]:
112
+ """A valid token: cached, refreshed, or newly requested. Raises ValueError with a readable message."""
113
+ if not force:
114
+ tok = cached(auth)
115
+ if tok:
116
+ return tok
117
+ if not (auth.get("token_url") or "").strip():
118
+ raise ValueError("OAuth 2.0: no token URL")
119
+ key = _key(auth)
120
+ lock = _fetch_locks.setdefault(key, asyncio.Lock())
121
+ async with lock:
122
+ if not force:
123
+ tok = cached(auth) # another request may have fetched it while we waited
124
+ if tok:
125
+ return tok
126
+ with _lock:
127
+ old = _cache.get(key)
128
+ refresh = old.get("refresh_token") if old else None
129
+ kwargs = {k: v for k, v in (client_kwargs or {}).items() if k in ("verify", "proxy", "timeout")}
130
+ kwargs.setdefault("timeout", 30)
131
+ async with httpx.AsyncClient(**kwargs) as client:
132
+ if refresh:
133
+ form, headers = _token_request(auth, refresh)
134
+ resp = await client.post(auth["token_url"], data=form, headers=headers)
135
+ if resp.status_code < 400:
136
+ try:
137
+ return _store(auth, resp.json(), refresh)
138
+ except ValueError:
139
+ pass
140
+ form, headers = _token_request(auth, None)
141
+ resp = await client.post(auth["token_url"], data=form, headers=headers)
142
+ if resp.status_code >= 400:
143
+ detail = resp.text.strip()[:300]
144
+ raise ValueError(f"OAuth 2.0 token request failed: HTTP {resp.status_code}" + (f" — {detail}" if detail else ""))
145
+ try:
146
+ data = resp.json()
147
+ except ValueError:
148
+ # some servers answer with a form-encoded body
149
+ data = dict(httpx.QueryParams(resp.text))
150
+ try:
151
+ return _store(auth, data)
152
+ except ValueError as exc:
153
+ raise ValueError(f"OAuth 2.0: {exc}") from None