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/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
pyhttp/console.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""In-memory log of every request the backend sends (shown in the UI console).
|
|
2
|
+
|
|
3
|
+
Kept in a bounded ring buffer: it lives as long as the app is running and is
|
|
4
|
+
never written to disk.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
MAX_ENTRIES = 500
|
|
14
|
+
MAX_BODY_CHARS = 64_000
|
|
15
|
+
|
|
16
|
+
_entries: deque[dict[str, Any]] = deque(maxlen=MAX_ENTRIES)
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_seq = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _clip(text: str | None) -> tuple[str, bool]:
|
|
22
|
+
text = text or ""
|
|
23
|
+
if len(text) > MAX_BODY_CHARS:
|
|
24
|
+
return text[:MAX_BODY_CHARS], True
|
|
25
|
+
return text, False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def record(result: dict[str, Any], *, source: str, request_name: str = "",
|
|
29
|
+
body_name: str | None = None, environment: str | None = None) -> dict[str, Any]:
|
|
30
|
+
"""Store one executed request. `result` is what runner.execute() returned."""
|
|
31
|
+
global _seq
|
|
32
|
+
req = result.get("request") or {}
|
|
33
|
+
resp = result.get("response")
|
|
34
|
+
req_body, req_cut = _clip(req.get("body"))
|
|
35
|
+
entry: dict[str, Any] = {
|
|
36
|
+
"ts": time.time(),
|
|
37
|
+
"source": source,
|
|
38
|
+
"request_name": request_name,
|
|
39
|
+
"body_name": body_name,
|
|
40
|
+
"environment": environment,
|
|
41
|
+
"method": req.get("method", ""),
|
|
42
|
+
"url": req.get("url", ""),
|
|
43
|
+
"status": result.get("status"),
|
|
44
|
+
"status_code": result.get("status_code"),
|
|
45
|
+
"reason": result.get("reason") or "",
|
|
46
|
+
"duration_ms": round(result.get("duration_ms") or 0, 2),
|
|
47
|
+
"error": result.get("error"),
|
|
48
|
+
"request": {
|
|
49
|
+
"headers": [list(h) for h in req.get("headers") or []],
|
|
50
|
+
"body": req_body,
|
|
51
|
+
"body_truncated": req_cut,
|
|
52
|
+
},
|
|
53
|
+
"response": None,
|
|
54
|
+
}
|
|
55
|
+
if resp:
|
|
56
|
+
body, cut = _clip(resp.get("body_text"))
|
|
57
|
+
entry["response"] = {
|
|
58
|
+
"headers": [list(h) for h in resp.get("headers") or []],
|
|
59
|
+
"body": body,
|
|
60
|
+
"body_truncated": cut or bool(resp.get("truncated")),
|
|
61
|
+
"size": resp.get("size"),
|
|
62
|
+
"content_type": resp.get("content_type"),
|
|
63
|
+
"is_json": resp.get("is_json"),
|
|
64
|
+
"http_version": resp.get("http_version"),
|
|
65
|
+
"redirects": resp.get("redirects") or [],
|
|
66
|
+
}
|
|
67
|
+
with _lock:
|
|
68
|
+
_seq += 1
|
|
69
|
+
entry["seq"] = _seq
|
|
70
|
+
_entries.append(entry)
|
|
71
|
+
return entry
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def entries(after: int = 0) -> dict[str, Any]:
|
|
75
|
+
with _lock:
|
|
76
|
+
items = [e for e in _entries if e["seq"] > after]
|
|
77
|
+
return {"entries": items, "last_seq": _seq, "count": len(_entries)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clear() -> None:
|
|
81
|
+
with _lock:
|
|
82
|
+
_entries.clear()
|
pyhttp/curl.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Turn a `curl ...` command line into a pyhttp request dict (Postman's "Import > Raw text")."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import shlex
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
_DATA_FLAGS = {"-d", "--data", "--data-raw", "--data-binary", "--data-ascii", "--data-urlencode", "--json"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _split(command: str) -> list[str]:
|
|
13
|
+
text = command.strip()
|
|
14
|
+
# line continuations from copied shell / PowerShell / cmd snippets
|
|
15
|
+
for cont in ("\\\n", "`\n", "^\n", "\\\r\n", "`\r\n", "^\r\n"):
|
|
16
|
+
text = text.replace(cont, " ")
|
|
17
|
+
try:
|
|
18
|
+
return shlex.split(text, posix=True)
|
|
19
|
+
except ValueError:
|
|
20
|
+
return shlex.split(text.replace("'", '"'), posix=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse(command: str) -> dict[str, Any]:
|
|
24
|
+
"""Parse the common curl flags. Unknown flags are ignored. Raises ValueError without a URL."""
|
|
25
|
+
args = _split(command)
|
|
26
|
+
if args and args[0].lower() in ("curl", "curl.exe"):
|
|
27
|
+
args = args[1:]
|
|
28
|
+
method: str | None = None
|
|
29
|
+
url = ""
|
|
30
|
+
headers: list[dict[str, Any]] = []
|
|
31
|
+
data_parts: list[str] = []
|
|
32
|
+
form_parts: list[str] = []
|
|
33
|
+
urlencode_parts: list[str] = []
|
|
34
|
+
user: str | None = None
|
|
35
|
+
is_json_flag = False
|
|
36
|
+
i = 0
|
|
37
|
+
while i < len(args):
|
|
38
|
+
a = args[i]
|
|
39
|
+
nxt = args[i + 1] if i + 1 < len(args) else None
|
|
40
|
+
|
|
41
|
+
def take() -> str:
|
|
42
|
+
nonlocal i
|
|
43
|
+
i += 1
|
|
44
|
+
return args[i] if i < len(args) else ""
|
|
45
|
+
|
|
46
|
+
if a in ("-X", "--request"):
|
|
47
|
+
method = take().upper()
|
|
48
|
+
elif a.startswith("-X") and len(a) > 2:
|
|
49
|
+
method = a[2:].upper()
|
|
50
|
+
elif a in ("-H", "--header"):
|
|
51
|
+
h = take()
|
|
52
|
+
k, _, v = h.partition(":")
|
|
53
|
+
if k.strip():
|
|
54
|
+
headers.append({"key": k.strip(), "value": v.strip(), "enabled": True})
|
|
55
|
+
elif a in _DATA_FLAGS:
|
|
56
|
+
v = take()
|
|
57
|
+
if a == "--data-urlencode":
|
|
58
|
+
urlencode_parts.append(v)
|
|
59
|
+
else:
|
|
60
|
+
data_parts.append(v[1:] if v.startswith("@") and a == "--data-binary" else v)
|
|
61
|
+
if a == "--json":
|
|
62
|
+
is_json_flag = True
|
|
63
|
+
elif a.startswith("-d") and len(a) > 2:
|
|
64
|
+
data_parts.append(a[2:])
|
|
65
|
+
elif a in ("-F", "--form", "--form-string"):
|
|
66
|
+
form_parts.append(take())
|
|
67
|
+
elif a in ("-u", "--user"):
|
|
68
|
+
user = take()
|
|
69
|
+
elif a in ("-A", "--user-agent"):
|
|
70
|
+
headers.append({"key": "User-Agent", "value": take(), "enabled": True})
|
|
71
|
+
elif a in ("-e", "--referer"):
|
|
72
|
+
headers.append({"key": "Referer", "value": take(), "enabled": True})
|
|
73
|
+
elif a in ("-b", "--cookie"):
|
|
74
|
+
headers.append({"key": "Cookie", "value": take(), "enabled": True})
|
|
75
|
+
elif a == "--url":
|
|
76
|
+
url = take()
|
|
77
|
+
elif a in ("-k", "--insecure", "-L", "--location", "-s", "--silent", "-S", "--show-error", "-v",
|
|
78
|
+
"--verbose", "-i", "--include", "--compressed", "-g", "--globoff", "-#", "--http1.1",
|
|
79
|
+
"--http2", "-f", "--fail"):
|
|
80
|
+
pass
|
|
81
|
+
elif a in ("-o", "--output", "-m", "--max-time", "--connect-timeout", "-x", "--proxy", "--cacert",
|
|
82
|
+
"-E", "--cert", "--key", "-w", "--write-out", "--retry", "-c", "--cookie-jar", "-T", "--upload-file"):
|
|
83
|
+
take()
|
|
84
|
+
elif a.startswith("-") and nxt is not None and not nxt.startswith("-") and a not in ("-G", "--get"):
|
|
85
|
+
# unknown flag with a value: skip both
|
|
86
|
+
i += 1
|
|
87
|
+
elif not a.startswith("-") and not url:
|
|
88
|
+
url = a
|
|
89
|
+
i += 1
|
|
90
|
+
if not url:
|
|
91
|
+
raise ValueError("No URL found in the curl command")
|
|
92
|
+
if "://" not in url:
|
|
93
|
+
url = "http://" + url
|
|
94
|
+
|
|
95
|
+
auth: dict[str, Any] = {"type": "inherit"}
|
|
96
|
+
if user:
|
|
97
|
+
u, _, pw = user.partition(":")
|
|
98
|
+
auth = {"type": "basic", "username": u, "password": pw}
|
|
99
|
+
else:
|
|
100
|
+
for h in headers:
|
|
101
|
+
if h["key"].lower() == "authorization":
|
|
102
|
+
v = h["value"]
|
|
103
|
+
if v.lower().startswith("bearer "):
|
|
104
|
+
auth = {"type": "bearer", "token": v[7:].strip(), "prefix": "Bearer"}
|
|
105
|
+
headers.remove(h)
|
|
106
|
+
elif v.lower().startswith("basic "):
|
|
107
|
+
try:
|
|
108
|
+
u, _, pw = base64.b64decode(v[6:].strip()).decode().partition(":")
|
|
109
|
+
auth = {"type": "basic", "username": u, "password": pw}
|
|
110
|
+
headers.remove(h)
|
|
111
|
+
except Exception: # noqa: BLE001 - keep the raw header
|
|
112
|
+
pass
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
body_type = "none"
|
|
116
|
+
body = ""
|
|
117
|
+
ctype = next((h["value"].lower() for h in headers if h["key"].lower() == "content-type"), "")
|
|
118
|
+
if form_parts:
|
|
119
|
+
body_type = "multipart"
|
|
120
|
+
lines = []
|
|
121
|
+
for part in form_parts:
|
|
122
|
+
k, _, v = part.partition("=")
|
|
123
|
+
lines.append(f"{k}: {v}" if v.startswith("@") else f"{k}: {v}")
|
|
124
|
+
body = "\n".join(lines)
|
|
125
|
+
elif data_parts or urlencode_parts:
|
|
126
|
+
raw = "&".join(data_parts + urlencode_parts)
|
|
127
|
+
if is_json_flag or "json" in ctype or raw.lstrip().startswith(("{", "[")):
|
|
128
|
+
body_type = "json"
|
|
129
|
+
body = raw
|
|
130
|
+
if is_json_flag and not ctype:
|
|
131
|
+
headers.append({"key": "Content-Type", "value": "application/json", "enabled": True})
|
|
132
|
+
elif "xml" in ctype:
|
|
133
|
+
body_type, body = "xml", raw
|
|
134
|
+
elif ctype and not ctype.startswith("application/x-www-form-urlencoded"):
|
|
135
|
+
body_type, body = "text", raw
|
|
136
|
+
else:
|
|
137
|
+
body_type = "form"
|
|
138
|
+
body = "\n".join(f"{k}: {v}" for k, v in parse_qsl(raw, keep_blank_values=True)) or raw
|
|
139
|
+
if method is None:
|
|
140
|
+
method = "POST" if body_type != "none" else "GET"
|
|
141
|
+
|
|
142
|
+
parts = urlsplit(url)
|
|
143
|
+
params = [{"key": k, "value": v, "enabled": True} for k, v in parse_qsl(parts.query, keep_blank_values=True)]
|
|
144
|
+
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment)) if params else url
|
|
145
|
+
name = parts.path.rstrip("/").split("/")[-1] or parts.netloc
|
|
146
|
+
return {
|
|
147
|
+
"name": f"{method} {name}"[:80], "method": method, "url": clean_url, "params": params, "headers": headers,
|
|
148
|
+
"body_type": body_type, "body": body,
|
|
149
|
+
"bodies": [{"id": "default", "name": "Default", "body": body, "enabled": True}] if body else [],
|
|
150
|
+
"auth": auth, "tags": [], "description": f"Imported from cURL:\n{command.strip()[:2000]}",
|
|
151
|
+
}
|