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/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
+ }