urban-http 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.
urban_http/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """urban-http : transformer une commande curl ou une collection Postman en appel
2
+ exécutable — HTTP via la bibliothèque standard, et SMTP/FTP/SFTP/POP3/MQTT/TFTP/
3
+ FILE/SMB/WebSocket via des adaptateurs (extras PyPI pour ceux qui le nécessitent).
4
+
5
+ Exemple :
6
+ from urban_http import parse_curl, execute
7
+
8
+ result = parse_curl("curl -X POST https://api.example.com -d '{\\"a\\":1}'")
9
+ resp = execute(result.requests[0]).response
10
+ print(resp.status, resp.text())
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .model import (
15
+ AuthSpec,
16
+ BodySpec,
17
+ DataItem,
18
+ FormField,
19
+ HttpRequestModel,
20
+ ParseResult,
21
+ ParseWarning,
22
+ RequestOptions,
23
+ )
24
+ from .tokenize import tokenize, tokenize_full
25
+ from .parse import parse_curl
26
+ from .postman import is_postman_collection, parse_postman
27
+ from .variables import parse_postman_environment
28
+ from .execute import ExecuteResult, HttpResponse, PreparedRequest, auth_header, execute, prepare_request
29
+ from .glob import GlobError, expand_curl_globs
30
+ from .digest import build_digest_authorization, parse_digest_challenge
31
+ from .cookies import cookies_for_url, parse_netscape_cookies
32
+ from .command import ParsedCommand, RunResult, parse_command, run
33
+ from .openapi import CurlOperation, OperationParam, openapi_to_curl
34
+ from .protocols import (
35
+ ProtocolContext,
36
+ ProtocolRequest,
37
+ ProtocolResult,
38
+ execute_protocol,
39
+ parse_protocol_command,
40
+ register_default_adapters,
41
+ register_protocol,
42
+ registered_schemes,
43
+ unregister_protocol,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ "AuthSpec", "BodySpec", "DataItem", "FormField", "HttpRequestModel", "ParseResult",
50
+ "ParseWarning", "RequestOptions", "tokenize", "tokenize_full", "parse_curl",
51
+ "is_postman_collection", "parse_postman", "parse_postman_environment",
52
+ "ExecuteResult", "HttpResponse", "PreparedRequest", "auth_header", "execute", "prepare_request",
53
+ "GlobError", "expand_curl_globs", "build_digest_authorization", "parse_digest_challenge",
54
+ "cookies_for_url", "parse_netscape_cookies", "ParsedCommand", "RunResult", "parse_command",
55
+ "run", "ProtocolContext", "ProtocolRequest", "ProtocolResult", "execute_protocol",
56
+ "parse_protocol_command", "register_default_adapters", "register_protocol",
57
+ "registered_schemes", "unregister_protocol",
58
+ "openapi_to_curl", "CurlOperation", "OperationParam", "__version__",
59
+ ]
urban_http/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """Interface en ligne de commande : `urban-http "curl ..."`.
2
+
3
+ Analyse une commande curl / Postman et affiche la requête obtenue, ou l'exécute
4
+ avec --run.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from typing import Dict, List
12
+
13
+ from . import parse_command, parse_postman, is_postman_collection, openapi_to_curl, register_default_adapters, run
14
+ from .model import HttpRequestModel
15
+
16
+
17
+ def _parse_vars(pairs: List[str]) -> Dict[str, str]:
18
+ out: Dict[str, str] = {}
19
+ for p in pairs or []:
20
+ if "=" in p:
21
+ k, v = p.split("=", 1)
22
+ out[k] = v
23
+ return out
24
+
25
+
26
+ def _print_http(req: HttpRequestModel) -> None:
27
+ print(f"{req.method} {req.url}")
28
+ for n, v in req.headers:
29
+ print(f" {n}: {v}")
30
+ if req.body and req.body.text:
31
+ print(f" corps: {req.body.text}")
32
+ for w in req.warnings:
33
+ print(f" [{w.level}] {w.message}", file=sys.stderr)
34
+
35
+
36
+ def main(argv: List[str] = None) -> int:
37
+ parser = argparse.ArgumentParser(prog="urban-http", description="Transformer une commande curl / Postman en appel exécutable.")
38
+ parser.add_argument("command", nargs="?", help="commande curl (entre guillemets), ou '-' pour lire une collection Postman sur stdin")
39
+ parser.add_argument("--run", action="store_true", help="exécuter la requête au lieu de seulement l'analyser")
40
+ parser.add_argument("--var", action="append", default=[], metavar="CLE=VALEUR", help="variable (répétable)")
41
+ parser.add_argument("--json", action="store_true", help="sortie JSON")
42
+ parser.add_argument("--openapi", metavar="FICHIER", help="générer les commandes curl d'un contrat OpenAPI (ou '-' pour stdin)")
43
+ args = parser.parse_args(argv)
44
+
45
+ variables = _parse_vars(args.var)
46
+
47
+ if args.openapi:
48
+ return _openapi_mode(args.openapi, args.json)
49
+
50
+ if not args.command:
51
+ parser.error("une commande curl ou --openapi est requis")
52
+ command = sys.stdin.read() if args.command == "-" else args.command
53
+
54
+ if is_postman_collection(command):
55
+ result = parse_postman(command, variables)
56
+ for req in result.requests:
57
+ _print_http(req)
58
+ print()
59
+ return 0
60
+
61
+ if args.run:
62
+ register_default_adapters()
63
+ res = run(command, variables)
64
+ if res.kind == "http":
65
+ resp = res.http.response
66
+ print(f"{resp.status} {resp.reason}")
67
+ print(resp.text())
68
+ else:
69
+ r = res.protocol
70
+ print(r.summary)
71
+ if r.body:
72
+ sys.stdout.buffer.write(r.body)
73
+ return 0
74
+
75
+ parsed = parse_command(command, variables)
76
+ if parsed.kind == "http":
77
+ if args.json:
78
+ print(json.dumps([_req_to_dict(r) for r in parsed.requests], indent=2, ensure_ascii=False))
79
+ else:
80
+ for req in parsed.requests:
81
+ _print_http(req)
82
+ print()
83
+ else:
84
+ r = parsed.request
85
+ if r is None:
86
+ for w in parsed.warnings:
87
+ print(f"[{w.level}] {w.message}", file=sys.stderr)
88
+ return 1
89
+ print(f"{r.scheme}:// {r.direction} {r.host}:{r.port}{r.path}")
90
+ return 0
91
+
92
+
93
+ def _openapi_mode(source: str, as_json: bool) -> int:
94
+ contract = sys.stdin.read() if source == "-" else open(source, "r", encoding="utf-8").read()
95
+ operations = openapi_to_curl(contract)
96
+ if as_json:
97
+ print(json.dumps([op.to_dict() for op in operations], indent=2, ensure_ascii=False))
98
+ return 0
99
+ for op in operations:
100
+ print(f"# {op.method} {op.path}" + (f" — {op.summary}" if op.summary else ""))
101
+ print(op.curl)
102
+ if op.example_variables:
103
+ print("# variables : " + ", ".join(f"{k}={v}" for k, v in op.example_variables.items()))
104
+ print()
105
+ return 0
106
+
107
+
108
+ def _req_to_dict(req: HttpRequestModel) -> dict:
109
+ return {
110
+ "method": req.method,
111
+ "url": req.url,
112
+ "headers": [list(h) for h in req.headers],
113
+ "body": req.body.text if req.body else None,
114
+ "warnings": [{"code": w.code, "level": w.level, "message": w.message} for w in req.warnings],
115
+ }
116
+
117
+
118
+ if __name__ == "__main__":
119
+ raise SystemExit(main())
urban_http/command.py ADDED
@@ -0,0 +1,61 @@
1
+ """Point d'entrée unifié : parser une commande curl quel que soit son protocole,
2
+ et l'exécuter via le bon moteur (HTTP via urllib, ou un adaptateur de protocole).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable, Dict, List, Optional, Union
8
+
9
+ from .execute import ExecuteResult, execute
10
+ from .model import HttpRequestModel, ParseWarning
11
+ from .parse import parse_curl
12
+ from .protocols.parse import parse_protocol_command
13
+ from .protocols.registry import execute_protocol
14
+ from .protocols.types import ProtocolContext, ProtocolRequest, ProtocolResult
15
+
16
+
17
+ @dataclass
18
+ class ParsedCommand:
19
+ kind: str # "http" | "protocol"
20
+ warnings: List[ParseWarning] = field(default_factory=list)
21
+ requests: List[HttpRequestModel] = field(default_factory=list)
22
+ scheme: str = ""
23
+ request: Optional[ProtocolRequest] = None
24
+
25
+
26
+ def parse_command(command: str, variables: Optional[Dict[str, str]] = None) -> ParsedCommand:
27
+ """Parse une commande curl de n'importe quel protocole."""
28
+ probe = parse_protocol_command(command, variables)
29
+ if probe.is_http:
30
+ result = parse_curl(command, variables)
31
+ return ParsedCommand(kind="http", requests=result.requests, warnings=result.warnings)
32
+ return ParsedCommand(kind="protocol", scheme=probe.scheme, request=probe.request, warnings=probe.warnings)
33
+
34
+
35
+ @dataclass
36
+ class RunResult:
37
+ kind: str # "http" | "protocol"
38
+ http: Optional[ExecuteResult] = None
39
+ protocol: Optional[ProtocolResult] = None
40
+
41
+
42
+ def run(
43
+ command: str,
44
+ variables: Optional[Dict[str, str]] = None,
45
+ file_reader: Optional[Callable[[str], Union[bytes, str]]] = None,
46
+ write_file: Optional[Callable[[str, bytes], None]] = None,
47
+ client_factory: Optional[object] = None,
48
+ ) -> RunResult:
49
+ """Parse et exécute une commande. Pour un protocole non-HTTP, l'adaptateur
50
+ correspondant doit être enregistré (register_default_adapters()).
51
+ """
52
+ parsed = parse_command(command, variables)
53
+ if parsed.kind == "http":
54
+ if not parsed.requests:
55
+ raise RuntimeError("Aucune requête HTTP à exécuter.")
56
+ return RunResult(kind="http", http=execute(parsed.requests[0], file_reader))
57
+ if parsed.request is None:
58
+ blocker = next((w for w in parsed.warnings if w.level == "error"), None)
59
+ raise RuntimeError(blocker.message if blocker else f'Commande "{parsed.scheme}://" non exécutable.')
60
+ ctx = ProtocolContext(read_file=file_reader, write_file=write_file, client_factory=client_factory)
61
+ return RunResult(kind="protocol", protocol=execute_protocol(parsed.request, ctx))
urban_http/cookies.py ADDED
@@ -0,0 +1,101 @@
1
+ """Lecture des fichiers de cookies au format Netscape (celui de curl -b/-c),
2
+ avec filtrage domaine / chemin / expiration / secure comme curl.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import List
9
+ from urllib.parse import urlsplit
10
+
11
+
12
+ @dataclass
13
+ class CookieEntry:
14
+ domain: str
15
+ include_subdomains: bool
16
+ path: str
17
+ secure: bool
18
+ expires: int # timestamp Unix en secondes ; 0 = cookie de session
19
+ name: str
20
+ value: str
21
+
22
+
23
+ def parse_netscape_cookies(text: str) -> List[CookieEntry]:
24
+ entries: List[CookieEntry] = []
25
+ for line in text.splitlines():
26
+ if not line.strip():
27
+ continue
28
+ http_only = line.startswith("#HttpOnly_")
29
+ if http_only:
30
+ line = line[len("#HttpOnly_") :]
31
+ elif line.startswith("#"):
32
+ continue
33
+
34
+ fields = line.split("\t")
35
+ if len(fields) >= 7:
36
+ domain, flag, path, secure, expires, name = fields[:6]
37
+ value = "\t".join(fields[6:])
38
+ entries.append(
39
+ CookieEntry(
40
+ domain=domain.lstrip("."),
41
+ include_subdomains=domain.startswith(".") or flag.upper() == "TRUE",
42
+ path=path or "/",
43
+ secure=secure.upper() == "TRUE",
44
+ expires=_to_int(expires),
45
+ name=name,
46
+ value=value,
47
+ )
48
+ )
49
+ elif len(fields) == 1 and "=" in line and " " not in line:
50
+ eq = line.index("=")
51
+ entries.append(
52
+ CookieEntry(
53
+ domain="",
54
+ include_subdomains=True,
55
+ path="/",
56
+ secure=False,
57
+ expires=0,
58
+ name=line[:eq],
59
+ value=line[eq + 1 :],
60
+ )
61
+ )
62
+ return entries
63
+
64
+
65
+ def cookies_for_url(entries: List[CookieEntry], url: str, now: float = None) -> str:
66
+ """Cookies applicables à une URL, au format "nom=valeur; ...". now en secondes."""
67
+ if now is None:
68
+ now = time.time()
69
+ try:
70
+ parts = urlsplit(url)
71
+ host = (parts.hostname or "").lower()
72
+ path = parts.path or "/"
73
+ is_https = parts.scheme == "https"
74
+ except ValueError:
75
+ return ""
76
+
77
+ selected: List[str] = []
78
+ for e in entries:
79
+ if e.expires != 0 and e.expires < now:
80
+ continue
81
+ if e.secure and not is_https:
82
+ continue
83
+ if e.domain:
84
+ domain = e.domain.lower()
85
+ matches = host == domain or (e.include_subdomains and host.endswith("." + domain))
86
+ if not matches:
87
+ continue
88
+ if e.path and e.path != "/":
89
+ prefix = e.path if e.path.endswith("/") else e.path + "/"
90
+ if not (path == e.path or path.startswith(prefix)):
91
+ continue
92
+ if e.name:
93
+ selected.append(f"{e.name}={e.value}")
94
+ return "; ".join(selected)
95
+
96
+
97
+ def _to_int(s: str) -> int:
98
+ try:
99
+ return int(s)
100
+ except (ValueError, TypeError):
101
+ return 0
urban_http/digest.py ADDED
@@ -0,0 +1,113 @@
1
+ """Authentification HTTP Digest (RFC 2617 / RFC 7616) :
2
+ parsing du challenge WWW-Authenticate et construction de l'en-tête Authorization.
3
+
4
+ Algorithmes : MD5, MD5-sess, SHA-256, SHA-256-sess, avec qop="auth" ou sans qop
5
+ (RFC 2069). qop="auth-int" n'est pas pris en charge.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ import secrets
12
+ from dataclasses import dataclass
13
+ from typing import Callable, Dict, Optional
14
+
15
+ _PARAM = re.compile(r'([a-zA-Z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))')
16
+
17
+
18
+ @dataclass
19
+ class DigestChallenge:
20
+ realm: str
21
+ nonce: str
22
+ qop: Optional[str] = None
23
+ opaque: Optional[str] = None
24
+ algorithm: Optional[str] = None
25
+ stale: bool = False
26
+
27
+
28
+ def parse_digest_challenge(header: str) -> Optional[DigestChallenge]:
29
+ idx = re.search(r"(^|[,\s])digest\s", header, re.IGNORECASE)
30
+ if not idx:
31
+ return None
32
+ rest = header[header.lower().index("digest", idx.start()) + len("digest") :]
33
+
34
+ params: Dict[str, str] = {}
35
+ for m in _PARAM.finditer(rest):
36
+ key = m.group(1).lower()
37
+ if m.group(2) is not None:
38
+ value = re.sub(r"\\(.)", r"\1", m.group(2))
39
+ else:
40
+ value = m.group(3) or ""
41
+ params[key] = value
42
+
43
+ if not params.get("realm") and not params.get("nonce"):
44
+ return None
45
+ return DigestChallenge(
46
+ realm=params.get("realm", ""),
47
+ nonce=params.get("nonce", ""),
48
+ qop=params.get("qop"),
49
+ opaque=params.get("opaque"),
50
+ algorithm=params.get("algorithm"),
51
+ stale=params.get("stale", "").lower() == "true",
52
+ )
53
+
54
+
55
+ def build_digest_authorization(
56
+ challenge: DigestChallenge,
57
+ username: str,
58
+ password: str,
59
+ method: str,
60
+ uri: str,
61
+ cnonce: Optional[str] = None,
62
+ nc: str = "00000001",
63
+ ) -> Optional[str]:
64
+ algorithm = (challenge.algorithm or "MD5").upper()
65
+ sess = algorithm.endswith("-SESS")
66
+ base = algorithm[:-5] if sess else algorithm
67
+
68
+ if base == "MD5":
69
+ hash_fn: Callable[[str], str] = lambda s: hashlib.md5(s.encode()).hexdigest()
70
+ elif base == "SHA-256":
71
+ hash_fn = lambda s: hashlib.sha256(s.encode()).hexdigest()
72
+ else:
73
+ return None
74
+
75
+ qop: Optional[str] = None
76
+ if challenge.qop:
77
+ offered = [q.strip().lower() for q in challenge.qop.split(",")]
78
+ if "auth" in offered:
79
+ qop = "auth"
80
+ else:
81
+ return None
82
+
83
+ if cnonce is None:
84
+ cnonce = secrets.token_hex(16)
85
+
86
+ ha1 = hash_fn(f"{username}:{challenge.realm}:{password}")
87
+ if sess:
88
+ ha1 = hash_fn(f"{ha1}:{challenge.nonce}:{cnonce}")
89
+ ha2 = hash_fn(f"{method.upper()}:{uri}")
90
+
91
+ if qop:
92
+ response = hash_fn(f"{ha1}:{challenge.nonce}:{nc}:{cnonce}:{qop}:{ha2}")
93
+ else:
94
+ response = hash_fn(f"{ha1}:{challenge.nonce}:{ha2}")
95
+
96
+ parts = [
97
+ f'username="{_escape(username)}"',
98
+ f'realm="{_escape(challenge.realm)}"',
99
+ f'nonce="{_escape(challenge.nonce)}"',
100
+ f'uri="{uri}"',
101
+ ]
102
+ if challenge.algorithm:
103
+ parts.append(f"algorithm={challenge.algorithm}")
104
+ if qop:
105
+ parts += [f"qop={qop}", f"nc={nc}", f'cnonce="{cnonce}"']
106
+ parts.append(f'response="{response}"')
107
+ if challenge.opaque:
108
+ parts.append(f'opaque="{_escape(challenge.opaque)}"')
109
+ return "Digest " + ", ".join(parts)
110
+
111
+
112
+ def _escape(s: str) -> str:
113
+ return s.replace("\\", "\\\\").replace('"', '\\"')