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/postman.py ADDED
@@ -0,0 +1,412 @@
1
+ """Postman Collection v2.1 and Postman environment conversion.
2
+
3
+ `from_collection()` turns a Postman collection into the same shape as pyhttp's
4
+ own export ({folders, requests, variables}); `to_collection()` does the
5
+ reverse so a pyhttp collection can be opened in Postman. Scripts (pre-request
6
+ and test JavaScript) cannot run here; they are kept in the request description
7
+ so nothing is silently lost.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import uuid
13
+ from typing import Any
14
+
15
+ SCHEMA = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
16
+
17
+
18
+ def is_collection(data: Any) -> bool:
19
+ return isinstance(data, dict) and isinstance(data.get("info"), dict) and "item" in data
20
+
21
+
22
+ def is_environment(data: Any) -> bool:
23
+ return isinstance(data, dict) and isinstance(data.get("values"), list) and "name" in data and "item" not in data
24
+
25
+
26
+ # ------------------------------------------------------------------ import
27
+
28
+
29
+ def _kv(items: Any, key_name: str = "key") -> list[dict[str, Any]]:
30
+ out = []
31
+ for it in items or []:
32
+ if not isinstance(it, dict):
33
+ continue
34
+ out.append({"key": str(it.get(key_name) or ""), "value": "" if it.get("value") is None else str(it.get("value")),
35
+ "enabled": not it.get("disabled", False)})
36
+ return out
37
+
38
+
39
+ def _url(u: Any) -> tuple[str, list[dict[str, Any]]]:
40
+ if isinstance(u, str):
41
+ return u, []
42
+ if not isinstance(u, dict):
43
+ return "", []
44
+ raw = u.get("raw") or ""
45
+ if not raw:
46
+ host = u.get("host")
47
+ host = ".".join(host) if isinstance(host, list) else (host or "")
48
+ path = u.get("path")
49
+ path = "/".join(path) if isinstance(path, list) else (path or "")
50
+ proto = u.get("protocol")
51
+ raw = (f"{proto}://" if proto else "") + host + (f":{u['port']}" if u.get("port") else "") + ("/" + path.lstrip("/") if path else "")
52
+ params = _kv(u.get("query"))
53
+ if params and "?" in raw:
54
+ raw = raw.split("?", 1)[0]
55
+ return raw, params
56
+
57
+
58
+ def _auth(a: Any) -> dict[str, Any]:
59
+ if not isinstance(a, dict) or not a.get("type"):
60
+ return {"type": "inherit"}
61
+ t = a["type"]
62
+
63
+ def vals(name: str) -> dict[str, str]:
64
+ rows = a.get(name)
65
+ if isinstance(rows, list):
66
+ return {str(r.get("key")): str(r.get("value") if r.get("value") is not None else "") for r in rows if isinstance(r, dict)}
67
+ if isinstance(rows, dict):
68
+ return {k: str(v) for k, v in rows.items()}
69
+ return {}
70
+
71
+ if t == "noauth":
72
+ return {"type": "none"}
73
+ if t == "basic":
74
+ v = vals("basic")
75
+ return {"type": "basic", "username": v.get("username", ""), "password": v.get("password", "")}
76
+ if t == "bearer":
77
+ v = vals("bearer")
78
+ return {"type": "bearer", "token": v.get("token", ""), "prefix": "Bearer"}
79
+ if t == "apikey":
80
+ v = vals("apikey")
81
+ return {"type": "apikey", "key": v.get("key", ""), "value": v.get("value", ""),
82
+ "add_to": "query" if v.get("in") == "query" else "header"}
83
+ if t == "digest":
84
+ v = vals("digest")
85
+ return {"type": "digest", "username": v.get("username", ""), "password": v.get("password", "")}
86
+ if t == "oauth2":
87
+ v = vals("oauth2")
88
+ grant = v.get("grant_type", "client_credentials")
89
+ if grant not in ("client_credentials", "password_credentials"):
90
+ # authorization code / implicit need a browser callback: keep the token if there is one
91
+ return {"type": "bearer", "token": v.get("accessToken", ""), "prefix": v.get("headerPrefix") or "Bearer"}
92
+ return {"type": "oauth2", "grant": "password" if grant == "password_credentials" else "client_credentials",
93
+ "token_url": v.get("accessTokenUrl", ""), "client_id": v.get("clientId", ""),
94
+ "client_secret": v.get("clientSecret", ""), "scope": v.get("scope", ""),
95
+ "username": v.get("username", ""), "password": v.get("password", ""),
96
+ "audience": v.get("audience", ""), "prefix": v.get("headerPrefix") or "Bearer",
97
+ "client_auth": "body" if v.get("client_authentication") == "body" else "basic"}
98
+ return {"type": "inherit"}
99
+
100
+
101
+ def _body(b: Any) -> tuple[str, str]:
102
+ if not isinstance(b, dict) or not b.get("mode"):
103
+ return "none", ""
104
+ mode = b["mode"]
105
+ if mode == "raw":
106
+ lang = ((b.get("options") or {}).get("raw") or {}).get("language", "")
107
+ raw = b.get("raw") or ""
108
+ if lang in ("json", "xml", "html", "javascript", "text"):
109
+ return lang, raw
110
+ return ("json" if raw.lstrip().startswith(("{", "[")) else "text"), raw
111
+ if mode == "urlencoded":
112
+ rows = b.get("urlencoded") or []
113
+ return "form", "\n".join(("// " if r.get("disabled") else "") + f"{r.get('key', '')}: {r.get('value', '')}" for r in rows if isinstance(r, dict))
114
+ if mode == "formdata":
115
+ lines = []
116
+ for r in b.get("formdata") or []:
117
+ if not isinstance(r, dict):
118
+ continue
119
+ prefix = "// " if r.get("disabled") else ""
120
+ if r.get("type") == "file":
121
+ src = r.get("src")
122
+ src = src[0] if isinstance(src, list) and src else (src or "")
123
+ lines.append(f"{prefix}{r.get('key', '')}: @{src}")
124
+ else:
125
+ lines.append(f"{prefix}{r.get('key', '')}: {r.get('value', '')}")
126
+ return "multipart", "\n".join(lines)
127
+ if mode == "file":
128
+ src = (b.get("file") or {}).get("src") or ""
129
+ return "binary", src
130
+ if mode == "graphql":
131
+ g = b.get("graphql") or {}
132
+ variables = g.get("variables") or "{}"
133
+ try:
134
+ variables = json.loads(variables) if isinstance(variables, str) else variables
135
+ except ValueError:
136
+ pass
137
+ return "graphql", json.dumps({"query": g.get("query") or "", "variables": variables}, indent=2)
138
+ return "none", ""
139
+
140
+
141
+ def _scripts(events: Any) -> dict[str, str]:
142
+ """Pre-request and test scripts of an item; they run unchanged in pyhttp's JavaScript engine."""
143
+ out = {"pre": "", "test": "", "lang": "js"}
144
+ for ev in events or []:
145
+ if not isinstance(ev, dict):
146
+ continue
147
+ exec_ = (ev.get("script") or {}).get("exec")
148
+ code = "\n".join(exec_) if isinstance(exec_, list) else (exec_ or "")
149
+ if not code.strip():
150
+ continue
151
+ key = "pre" if ev.get("listen") == "prerequest" else "test"
152
+ out[key] = (out[key] + "\n\n" + code).strip() if out[key] else code.strip()
153
+ return out
154
+
155
+
156
+ def _scripts_for(r: dict[str, Any], body: dict[str, Any] | None) -> dict[str, Any]:
157
+ """The request's scripts for one exported body: those for all bodies followed by the body's own."""
158
+ sc = dict((r.get("options") or {}).get("scripts") or {})
159
+ own = (sc.get("bodies") or {}).get(body.get("id") or "") if body else None
160
+ if isinstance(own, dict):
161
+ for key in ("pre", "test"):
162
+ parts = [c for c in ((sc.get(key) or "").strip(), (own.get(key) or "").strip()) if c]
163
+ sc[key] = "\n\n".join(parts)
164
+ return sc
165
+
166
+
167
+ def _events(scripts: dict[str, Any] | None, extra_tests: list[str]) -> list[dict[str, Any]]:
168
+ """Postman events for export: pyhttp's scripts (JavaScript as-is, Python as comments) plus translated tests."""
169
+ scripts = scripts or {}
170
+ python = scripts.get("lang") == "py"
171
+ out = []
172
+ for listen, key in (("prerequest", "pre"), ("test", "test")):
173
+ code = (scripts.get(key) or "").strip()
174
+ lines: list[str] = []
175
+ if code:
176
+ if python:
177
+ lines = ["// pyhttp Python script (does not run in Postman):"] + ["// " + ln for ln in code.splitlines()]
178
+ else:
179
+ lines = code.splitlines()
180
+ if key == "test" and extra_tests:
181
+ lines += ([""] if lines else []) + extra_tests
182
+ if lines:
183
+ out.append({"listen": listen, "script": {"type": "text/javascript", "exec": lines}})
184
+ return out
185
+
186
+
187
+ def from_collection(data: dict[str, Any]) -> dict[str, Any]:
188
+ """Convert a Postman v2.1 collection into pyhttp's import shape."""
189
+ folders: list[dict[str, Any]] = []
190
+ requests: list[dict[str, Any]] = []
191
+ root_name = (data.get("info") or {}).get("name") or "Imported collection"
192
+ root_id = uuid.uuid4().hex
193
+ folders.append({"id": root_id, "name": root_name, "parent_id": None, "description": _desc(data.get("info", {}).get("description")),
194
+ "auth": _auth(data.get("auth")), "headers": [], "variables": _kv(data.get("variable"))})
195
+
196
+ def walk(items: Any, parent_id: str) -> None:
197
+ for it in items or []:
198
+ if not isinstance(it, dict):
199
+ continue
200
+ if "item" in it: # folder
201
+ fid = uuid.uuid4().hex
202
+ folders.append({"id": fid, "name": it.get("name") or "Folder", "parent_id": parent_id,
203
+ "description": _desc(it.get("description")), "auth": _auth(it.get("auth")),
204
+ "headers": [], "variables": _kv(it.get("variable"))})
205
+ walk(it.get("item"), fid)
206
+ continue
207
+ req = it.get("request")
208
+ if isinstance(req, str):
209
+ req = {"url": req, "method": "GET"}
210
+ if not isinstance(req, dict):
211
+ continue
212
+ url, params = _url(req.get("url"))
213
+ body_type, body = _body(req.get("body"))
214
+ desc = _desc(req.get("description") or it.get("description"))
215
+ requests.append({
216
+ "folder_id": parent_id, "name": it.get("name") or url or "Request",
217
+ "description": desc, "tags": [], "method": (req.get("method") or "GET").upper(), "url": url,
218
+ "params": params, "headers": _kv(req.get("header")), "body_type": body_type, "body": body,
219
+ "bodies": [{"id": "default", "name": "Default", "body": body, "enabled": True}] if body else [],
220
+ "auth": _auth(req.get("auth")), "tests": [], "timeout": 30,
221
+ "options": {"scripts": _scripts(it.get("event"))},
222
+ })
223
+
224
+ walk(data.get("item"), root_id)
225
+ return {"folders": folders, "requests": requests, "variables": {}, "environments": [], "name": root_name}
226
+
227
+
228
+ def _desc(d: Any) -> str:
229
+ if isinstance(d, dict):
230
+ return str(d.get("content") or "")
231
+ return str(d or "")
232
+
233
+
234
+ def from_environment(data: dict[str, Any]) -> dict[str, Any]:
235
+ return {"name": data.get("name") or "Imported environment",
236
+ "variables": [{"key": str(v.get("key") or ""), "value": "" if v.get("value") is None else str(v.get("value")),
237
+ "enabled": v.get("enabled", True) is not False, **({"secret": True} if v.get("type") == "secret" else {})}
238
+ for v in data.get("values") or [] if isinstance(v, dict)]}
239
+
240
+
241
+ # ------------------------------------------------------------------ export
242
+
243
+
244
+ def _pm_auth(a: dict[str, Any] | None) -> dict[str, Any] | None:
245
+ a = a or {}
246
+ t = a.get("type", "inherit")
247
+ if t == "inherit":
248
+ return None
249
+ if t == "none":
250
+ return {"type": "noauth"}
251
+ if t == "basic":
252
+ return {"type": "basic", "basic": [{"key": "username", "value": a.get("username", ""), "type": "string"},
253
+ {"key": "password", "value": a.get("password", ""), "type": "string"}]}
254
+ if t == "bearer":
255
+ return {"type": "bearer", "bearer": [{"key": "token", "value": a.get("token", ""), "type": "string"}]}
256
+ if t == "apikey":
257
+ return {"type": "apikey", "apikey": [{"key": "key", "value": a.get("key", ""), "type": "string"},
258
+ {"key": "value", "value": a.get("value", ""), "type": "string"},
259
+ {"key": "in", "value": a.get("add_to", "header"), "type": "string"}]}
260
+ if t == "digest":
261
+ return {"type": "digest", "digest": [{"key": "username", "value": a.get("username", ""), "type": "string"},
262
+ {"key": "password", "value": a.get("password", ""), "type": "string"}]}
263
+ if t == "oauth2":
264
+ fields = {
265
+ "grant_type": "password_credentials" if a.get("grant") == "password" else "client_credentials",
266
+ "accessTokenUrl": a.get("token_url", ""), "clientId": a.get("client_id", ""),
267
+ "clientSecret": a.get("client_secret", ""), "scope": a.get("scope", ""),
268
+ "client_authentication": "body" if a.get("client_auth") == "body" else "header",
269
+ "headerPrefix": a.get("prefix") or "Bearer", "addTokenTo": "header",
270
+ }
271
+ if a.get("grant") == "password":
272
+ fields.update(username=a.get("username", ""), password=a.get("password", ""))
273
+ if a.get("audience"):
274
+ fields["audience"] = a["audience"]
275
+ return {"type": "oauth2", "oauth2": [{"key": k, "value": v, "type": "string"} for k, v in fields.items()]}
276
+ return None
277
+
278
+
279
+ def _pm_kv(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
280
+ return [{"key": r.get("key", ""), "value": r.get("value", ""), **({"disabled": True} if not r.get("enabled", True) else {})}
281
+ for r in rows or []]
282
+
283
+
284
+ def _form_rows(text: str) -> list[dict[str, Any]]:
285
+ from .runner import parse_form_lines
286
+
287
+ out = []
288
+ for r in parse_form_lines(text):
289
+ row: dict[str, Any] = {"key": r["key"], "type": "file" if r["file"] else "text"}
290
+ if r["file"]:
291
+ row["src"] = r["value"]
292
+ else:
293
+ row["value"] = r["value"]
294
+ if not r["enabled"]:
295
+ row["disabled"] = True
296
+ out.append(row)
297
+ return out
298
+
299
+
300
+ def _pm_body(r: dict[str, Any], body: str) -> dict[str, Any] | None:
301
+ t = r.get("body_type", "none")
302
+ if t == "none":
303
+ return None
304
+ if t in ("json", "text", "xml", "html", "javascript"):
305
+ return {"mode": "raw", "raw": body, "options": {"raw": {"language": t}}}
306
+ if t == "form":
307
+ return {"mode": "urlencoded", "urlencoded": [{k: v for k, v in row.items() if k != "type"} for row in _form_rows(body)]}
308
+ if t == "multipart":
309
+ return {"mode": "formdata", "formdata": _form_rows(body)}
310
+ if t == "binary":
311
+ return {"mode": "file", "file": {"src": body.strip()}}
312
+ if t == "graphql":
313
+ try:
314
+ d = json.loads(body)
315
+ v = d.get("variables")
316
+ return {"mode": "graphql", "graphql": {"query": d.get("query", ""), "variables": v if isinstance(v, str) else json.dumps(v or {})}}
317
+ except (ValueError, AttributeError):
318
+ return {"mode": "graphql", "graphql": {"query": body, "variables": "{}"}}
319
+ return None
320
+
321
+
322
+ def _pm_tests(tests: list[dict[str, Any]]) -> list[str]:
323
+ from .assertions import describe
324
+
325
+ lines = []
326
+ for t in tests or []:
327
+ if not t.get("enabled", True):
328
+ continue
329
+ name = (t.get("name") or describe(t)).replace('"', '\\"')
330
+ kind, op, val, target = t.get("type"), t.get("op"), t.get("value", ""), t.get("target", "")
331
+ if kind == "status" and op == "equals":
332
+ lines.append(f'pm.test("{name}", function () {{ pm.response.to.have.status({val}); }});')
333
+ elif kind == "time" and op == "less_than":
334
+ lines.append(f'pm.test("{name}", function () {{ pm.expect(pm.response.responseTime).to.be.below({val}); }});')
335
+ elif kind == "header" and op == "exists":
336
+ lines.append(f'pm.test("{name}", function () {{ pm.response.to.have.header("{target}"); }});')
337
+ elif kind == "body" and op == "contains":
338
+ lines.append(f'pm.test("{name}", function () {{ pm.expect(pm.response.text()).to.include({json.dumps(val)}); }});')
339
+ elif kind == "json" and op == "equals":
340
+ lines.append(f'pm.test("{name}", function () {{ pm.expect(String(pm.response.json().{target})).to.eql({json.dumps(str(val))}); }});')
341
+ elif kind == "json" and op == "exists":
342
+ lines.append(f'pm.test("{name}", function () {{ pm.expect(pm.response.json().{target}).to.not.be.undefined; }});')
343
+ else:
344
+ lines.append(f"// pyhttp test not translated: {name}")
345
+ return lines
346
+
347
+
348
+ def to_collection(name: str, folders: list[dict[str, Any]], requests: list[dict[str, Any]],
349
+ variables: dict[str, str]) -> dict[str, Any]:
350
+ """Build a Postman v2.1 collection. Each named body becomes its own request."""
351
+ by_parent: dict[str | None, list[dict[str, Any]]] = {}
352
+ for f in folders:
353
+ by_parent.setdefault(f.get("parent_id"), []).append(f)
354
+ reqs_by_folder: dict[str | None, list[dict[str, Any]]] = {}
355
+ for r in requests:
356
+ reqs_by_folder.setdefault(r.get("folder_id"), []).append(r)
357
+
358
+ def request_items(r: dict[str, Any]) -> list[dict[str, Any]]:
359
+ bodies = [b for b in r.get("bodies") or [] if r.get("body_type", "none") != "none"] or [None]
360
+ items = []
361
+ for b in bodies:
362
+ body_text = b["body"] if b else ""
363
+ label = r["name"] if (b is None or len(bodies) == 1) else f"{r['name']} · {b['name']}"
364
+ url = r.get("url", "")
365
+ params = [p for p in r.get("params") or [] if p.get("key")]
366
+ pm_url: dict[str, Any] = {"raw": url + ("?" + "&".join(f"{p['key']}={p['value']}" for p in params if p.get("enabled", True)) if params else "")}
367
+ if params:
368
+ pm_url["query"] = _pm_kv(params)
369
+ req: dict[str, Any] = {"method": r.get("method", "GET"), "header": _pm_kv(r.get("headers") or []), "url": pm_url}
370
+ if r.get("description"):
371
+ req["description"] = r["description"]
372
+ auth = _pm_auth(r.get("auth"))
373
+ if auth:
374
+ req["auth"] = auth
375
+ body = _pm_body(r, body_text)
376
+ if body:
377
+ req["body"] = body
378
+ item: dict[str, Any] = {"name": label, "request": req, "response": []}
379
+ events = _events(_scripts_for(r, b), _pm_tests(r.get("tests") or []))
380
+ if events:
381
+ item["event"] = events
382
+ items.append(item)
383
+ return items
384
+
385
+ def walk(parent: str | None) -> list[dict[str, Any]]:
386
+ out: list[dict[str, Any]] = []
387
+ for f in by_parent.get(parent, []):
388
+ node: dict[str, Any] = {"name": f["name"], "item": walk(f["id"])}
389
+ if f.get("description"):
390
+ node["description"] = f["description"]
391
+ auth = _pm_auth(f.get("auth"))
392
+ if auth:
393
+ node["auth"] = auth
394
+ if f.get("variables"):
395
+ node["variable"] = _pm_kv(f["variables"])
396
+ out.append(node)
397
+ for r in reqs_by_folder.get(parent, []):
398
+ out.extend(request_items(r))
399
+ return out
400
+
401
+ return {
402
+ "info": {"_postman_id": str(uuid.uuid4()), "name": name, "schema": SCHEMA},
403
+ "item": walk(None),
404
+ "variable": [{"key": k, "value": v, "type": "string"} for k, v in (variables or {}).items()],
405
+ }
406
+
407
+
408
+ def to_environment(env: dict[str, Any]) -> dict[str, Any]:
409
+ return {"id": str(uuid.uuid4()), "name": env["name"], "_postman_variable_scope": "environment",
410
+ "values": [{"key": v["key"], "value": v["value"], "enabled": v.get("enabled", True),
411
+ "type": "secret" if v.get("secret") else "default"}
412
+ for v in env.get("variables") or []]}