httpx2-audit 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agent-one
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ include LICENSE
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpx2-audit
3
+ Version: 0.1.0
4
+ Summary: Find what breaks when your Python stack moves from httpx to httpx2: static audit, import/dependency codemod and a mixed-stack/TLS environment report.
5
+ Author-email: agent-one <rafal.baranowski.iot@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rafal-one/httpx2-audit
8
+ Keywords: httpx,httpx2,migration,codemod,audit,openai,anthropic,respx,pytest-httpx
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # httpx2-audit
18
+
19
+ Find what breaks when your Python stack moves from **httpx** to **httpx2** (the pydantic fork; openai 3.x,
20
+ anthropic 1.x, Starlette, the MCP SDK and huggingface_hub already switched). Pure stdlib, Python 3.9+
21
+ (it analyses code, it does not import httpx), no network except the optional TLS probe.
22
+
23
+ Unofficial community tool, not affiliated with httpx, httpx2 or pydantic. State as of 2026-10-18.
24
+
25
+ ## Why
26
+
27
+ The move breaks things *silently*: `import httpx` fails once an SDK stops pulling it in; `respx` and
28
+ `pytest-httpx` mocks stop intercepting, so tests hit the live API; `except httpx.ConnectError` no longer
29
+ catches errors raised by httpx2 code; httpx clients passed as `http_client=` are the wrong type; TLS switches
30
+ from the certifi bundle to the OS trust store.
31
+
32
+ ```
33
+ pip install httpx2-audit # or: pip install git+https://github.com/rafal-one/httpx2-audit
34
+ ```
35
+
36
+ ## `check`: static audit
37
+
38
+ ```
39
+ $ httpx2-audit check src/
40
+ src/app.py:1:1: HX001 imports httpx; httpx2 is a separate package with its own module name [info]
41
+ src/app.py:4:36: HX004 httpx object or http_client passed on; an httpx2-based SDK needs httpx2 objects (http_client) [warning]
42
+ src/app.py:7:8: HX003 except clause names an httpx exception; errors raised by httpx2-based code are not caught (httpx.ConnectError) [warning]
43
+ ```
44
+
45
+ | Rule | Severity | Meaning |
46
+ |---|---|---|
47
+ | HX001 | info | `import httpx` / `from httpx import ...` |
48
+ | HX002 | error | httpx imported but not declared in pyproject/requirements (transitive install is gone) |
49
+ | HX003 | warning | `except httpx.X` (also aliases and tuples) |
50
+ | HX004 | warning | `http_client=` or any httpx object (`httpx.Timeout`, `httpx.Limits`, ...) passed to a non-httpx call |
51
+ | HX005 | warning | `isinstance`/`issubclass` against httpx classes |
52
+ | HX006 | warning | `import httpcore` (httpx2 uses httpcore2) |
53
+ | HX007 | warning | `respx` / `pytest_httpx` imports: they do not mock httpx2 |
54
+ | HX008 | info | `certifi.where()` passed explicitly while httpx is in use |
55
+ | HX010 | warning | file imports both httpx and httpx2 |
56
+
57
+ Options: `--format text|json|sarif` (SARIF for GitHub code scanning), `--min-severity`, `--fail-on
58
+ info|warning|error|never` (default `warning`), `-q` (no footer). Exit codes: `0` nothing at or above
59
+ `--fail-on`, `1` findings, `2` bad usage or missing path. Directories like `.venv`, `node_modules`, `build`
60
+ are skipped; files that do not parse are reported on stderr and skipped. Imports already wrapped in
61
+ `try: import httpx2 ... except ImportError: import httpx` are treated as handled.
62
+
63
+ CI: `httpx2-audit check . --format sarif > audit.sarif`, or just `httpx2-audit check . --fail-on error`.
64
+
65
+ ## `fix`: import and dependency codemod
66
+
67
+ Dry run by default (prints a unified diff); `--write` applies it.
68
+
69
+ ```
70
+ httpx2-audit fix . # diff
71
+ httpx2-audit fix . --write # apply
72
+ httpx2-audit fix . --dual --write # libraries: try httpx2, fall back to httpx
73
+ ```
74
+
75
+ * `import httpx` -> `import httpx2 as httpx` (so the rest of the file keeps working), `from httpx import X`
76
+ -> `from httpx2 import X`; same for `httpcore` -> `httpcore2` and `pytest_httpx` -> `pytest_httpx2`.
77
+ * `requirements*.txt` and `pyproject.toml` requirement strings: `httpx` -> `httpx2>=2.0`, `httpcore`,
78
+ `pytest-httpx` (extras and markers are kept).
79
+ * `--dual` wraps each import in a `try/except ImportError` and leaves dependencies alone.
80
+ * Private modules (`httpx._models`) and dotted imports are reported as `manual:` and left untouched.
81
+ * It does not touch `except` clauses, `respx` mocks or TLS settings; run `check` afterwards. httpx2 needs Python 3.10+.
82
+
83
+ ## `env`: mixed stacks and TLS
84
+
85
+ ```
86
+ $ httpx2-audit env --probe api.openai.com --probe intranet.corp:8443
87
+ installed:
88
+ httpx 0.28.1
89
+ httpx2 2.14.0
90
+ depend on httpx: respx
91
+ depend on httpx2: pytest-httpx2
92
+ tls intranet.corp:8443: openssl-default=ok, certifi=FAIL: CERTIFICATE_VERIFY_FAILED, truststore=ok
93
+ WARNING: TLS trust differs for intranet.corp:8443: ...
94
+ ```
95
+
96
+ Lists the relevant installed distributions, which installed packages require httpx or httpx2 (a mixed
97
+ environment cannot pass clients between them), and compares what the system store, certifi and truststore
98
+ (if installed) accept for the hosts you probe. Exit code `1` when warnings are printed.
99
+
100
+ ## Development
101
+
102
+ `pytest` (37 tests). Tested on Python 3.9 and 3.14; clean installs of wheel and sdist checked.
103
+
104
+ ## Donate
105
+
106
+ If this saved your migration a day: https://paypal.me/RafalBaranowski
107
+
108
+ MIT licensed.
@@ -0,0 +1,92 @@
1
+ # httpx2-audit
2
+
3
+ Find what breaks when your Python stack moves from **httpx** to **httpx2** (the pydantic fork; openai 3.x,
4
+ anthropic 1.x, Starlette, the MCP SDK and huggingface_hub already switched). Pure stdlib, Python 3.9+
5
+ (it analyses code, it does not import httpx), no network except the optional TLS probe.
6
+
7
+ Unofficial community tool, not affiliated with httpx, httpx2 or pydantic. State as of 2026-10-18.
8
+
9
+ ## Why
10
+
11
+ The move breaks things *silently*: `import httpx` fails once an SDK stops pulling it in; `respx` and
12
+ `pytest-httpx` mocks stop intercepting, so tests hit the live API; `except httpx.ConnectError` no longer
13
+ catches errors raised by httpx2 code; httpx clients passed as `http_client=` are the wrong type; TLS switches
14
+ from the certifi bundle to the OS trust store.
15
+
16
+ ```
17
+ pip install httpx2-audit # or: pip install git+https://github.com/rafal-one/httpx2-audit
18
+ ```
19
+
20
+ ## `check`: static audit
21
+
22
+ ```
23
+ $ httpx2-audit check src/
24
+ src/app.py:1:1: HX001 imports httpx; httpx2 is a separate package with its own module name [info]
25
+ src/app.py:4:36: HX004 httpx object or http_client passed on; an httpx2-based SDK needs httpx2 objects (http_client) [warning]
26
+ src/app.py:7:8: HX003 except clause names an httpx exception; errors raised by httpx2-based code are not caught (httpx.ConnectError) [warning]
27
+ ```
28
+
29
+ | Rule | Severity | Meaning |
30
+ |---|---|---|
31
+ | HX001 | info | `import httpx` / `from httpx import ...` |
32
+ | HX002 | error | httpx imported but not declared in pyproject/requirements (transitive install is gone) |
33
+ | HX003 | warning | `except httpx.X` (also aliases and tuples) |
34
+ | HX004 | warning | `http_client=` or any httpx object (`httpx.Timeout`, `httpx.Limits`, ...) passed to a non-httpx call |
35
+ | HX005 | warning | `isinstance`/`issubclass` against httpx classes |
36
+ | HX006 | warning | `import httpcore` (httpx2 uses httpcore2) |
37
+ | HX007 | warning | `respx` / `pytest_httpx` imports: they do not mock httpx2 |
38
+ | HX008 | info | `certifi.where()` passed explicitly while httpx is in use |
39
+ | HX010 | warning | file imports both httpx and httpx2 |
40
+
41
+ Options: `--format text|json|sarif` (SARIF for GitHub code scanning), `--min-severity`, `--fail-on
42
+ info|warning|error|never` (default `warning`), `-q` (no footer). Exit codes: `0` nothing at or above
43
+ `--fail-on`, `1` findings, `2` bad usage or missing path. Directories like `.venv`, `node_modules`, `build`
44
+ are skipped; files that do not parse are reported on stderr and skipped. Imports already wrapped in
45
+ `try: import httpx2 ... except ImportError: import httpx` are treated as handled.
46
+
47
+ CI: `httpx2-audit check . --format sarif > audit.sarif`, or just `httpx2-audit check . --fail-on error`.
48
+
49
+ ## `fix`: import and dependency codemod
50
+
51
+ Dry run by default (prints a unified diff); `--write` applies it.
52
+
53
+ ```
54
+ httpx2-audit fix . # diff
55
+ httpx2-audit fix . --write # apply
56
+ httpx2-audit fix . --dual --write # libraries: try httpx2, fall back to httpx
57
+ ```
58
+
59
+ * `import httpx` -> `import httpx2 as httpx` (so the rest of the file keeps working), `from httpx import X`
60
+ -> `from httpx2 import X`; same for `httpcore` -> `httpcore2` and `pytest_httpx` -> `pytest_httpx2`.
61
+ * `requirements*.txt` and `pyproject.toml` requirement strings: `httpx` -> `httpx2>=2.0`, `httpcore`,
62
+ `pytest-httpx` (extras and markers are kept).
63
+ * `--dual` wraps each import in a `try/except ImportError` and leaves dependencies alone.
64
+ * Private modules (`httpx._models`) and dotted imports are reported as `manual:` and left untouched.
65
+ * It does not touch `except` clauses, `respx` mocks or TLS settings; run `check` afterwards. httpx2 needs Python 3.10+.
66
+
67
+ ## `env`: mixed stacks and TLS
68
+
69
+ ```
70
+ $ httpx2-audit env --probe api.openai.com --probe intranet.corp:8443
71
+ installed:
72
+ httpx 0.28.1
73
+ httpx2 2.14.0
74
+ depend on httpx: respx
75
+ depend on httpx2: pytest-httpx2
76
+ tls intranet.corp:8443: openssl-default=ok, certifi=FAIL: CERTIFICATE_VERIFY_FAILED, truststore=ok
77
+ WARNING: TLS trust differs for intranet.corp:8443: ...
78
+ ```
79
+
80
+ Lists the relevant installed distributions, which installed packages require httpx or httpx2 (a mixed
81
+ environment cannot pass clients between them), and compares what the system store, certifi and truststore
82
+ (if installed) accept for the hosts you probe. Exit code `1` when warnings are printed.
83
+
84
+ ## Development
85
+
86
+ `pytest` (37 tests). Tested on Python 3.9 and 3.14; clean installs of wheel and sdist checked.
87
+
88
+ ## Donate
89
+
90
+ If this saved your migration a day: https://paypal.me/RafalBaranowski
91
+
92
+ MIT licensed.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpx2-audit
3
+ Version: 0.1.0
4
+ Summary: Find what breaks when your Python stack moves from httpx to httpx2: static audit, import/dependency codemod and a mixed-stack/TLS environment report.
5
+ Author-email: agent-one <rafal.baranowski.iot@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rafal-one/httpx2-audit
8
+ Keywords: httpx,httpx2,migration,codemod,audit,openai,anthropic,respx,pytest-httpx
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # httpx2-audit
18
+
19
+ Find what breaks when your Python stack moves from **httpx** to **httpx2** (the pydantic fork; openai 3.x,
20
+ anthropic 1.x, Starlette, the MCP SDK and huggingface_hub already switched). Pure stdlib, Python 3.9+
21
+ (it analyses code, it does not import httpx), no network except the optional TLS probe.
22
+
23
+ Unofficial community tool, not affiliated with httpx, httpx2 or pydantic. State as of 2026-10-18.
24
+
25
+ ## Why
26
+
27
+ The move breaks things *silently*: `import httpx` fails once an SDK stops pulling it in; `respx` and
28
+ `pytest-httpx` mocks stop intercepting, so tests hit the live API; `except httpx.ConnectError` no longer
29
+ catches errors raised by httpx2 code; httpx clients passed as `http_client=` are the wrong type; TLS switches
30
+ from the certifi bundle to the OS trust store.
31
+
32
+ ```
33
+ pip install httpx2-audit # or: pip install git+https://github.com/rafal-one/httpx2-audit
34
+ ```
35
+
36
+ ## `check`: static audit
37
+
38
+ ```
39
+ $ httpx2-audit check src/
40
+ src/app.py:1:1: HX001 imports httpx; httpx2 is a separate package with its own module name [info]
41
+ src/app.py:4:36: HX004 httpx object or http_client passed on; an httpx2-based SDK needs httpx2 objects (http_client) [warning]
42
+ src/app.py:7:8: HX003 except clause names an httpx exception; errors raised by httpx2-based code are not caught (httpx.ConnectError) [warning]
43
+ ```
44
+
45
+ | Rule | Severity | Meaning |
46
+ |---|---|---|
47
+ | HX001 | info | `import httpx` / `from httpx import ...` |
48
+ | HX002 | error | httpx imported but not declared in pyproject/requirements (transitive install is gone) |
49
+ | HX003 | warning | `except httpx.X` (also aliases and tuples) |
50
+ | HX004 | warning | `http_client=` or any httpx object (`httpx.Timeout`, `httpx.Limits`, ...) passed to a non-httpx call |
51
+ | HX005 | warning | `isinstance`/`issubclass` against httpx classes |
52
+ | HX006 | warning | `import httpcore` (httpx2 uses httpcore2) |
53
+ | HX007 | warning | `respx` / `pytest_httpx` imports: they do not mock httpx2 |
54
+ | HX008 | info | `certifi.where()` passed explicitly while httpx is in use |
55
+ | HX010 | warning | file imports both httpx and httpx2 |
56
+
57
+ Options: `--format text|json|sarif` (SARIF for GitHub code scanning), `--min-severity`, `--fail-on
58
+ info|warning|error|never` (default `warning`), `-q` (no footer). Exit codes: `0` nothing at or above
59
+ `--fail-on`, `1` findings, `2` bad usage or missing path. Directories like `.venv`, `node_modules`, `build`
60
+ are skipped; files that do not parse are reported on stderr and skipped. Imports already wrapped in
61
+ `try: import httpx2 ... except ImportError: import httpx` are treated as handled.
62
+
63
+ CI: `httpx2-audit check . --format sarif > audit.sarif`, or just `httpx2-audit check . --fail-on error`.
64
+
65
+ ## `fix`: import and dependency codemod
66
+
67
+ Dry run by default (prints a unified diff); `--write` applies it.
68
+
69
+ ```
70
+ httpx2-audit fix . # diff
71
+ httpx2-audit fix . --write # apply
72
+ httpx2-audit fix . --dual --write # libraries: try httpx2, fall back to httpx
73
+ ```
74
+
75
+ * `import httpx` -> `import httpx2 as httpx` (so the rest of the file keeps working), `from httpx import X`
76
+ -> `from httpx2 import X`; same for `httpcore` -> `httpcore2` and `pytest_httpx` -> `pytest_httpx2`.
77
+ * `requirements*.txt` and `pyproject.toml` requirement strings: `httpx` -> `httpx2>=2.0`, `httpcore`,
78
+ `pytest-httpx` (extras and markers are kept).
79
+ * `--dual` wraps each import in a `try/except ImportError` and leaves dependencies alone.
80
+ * Private modules (`httpx._models`) and dotted imports are reported as `manual:` and left untouched.
81
+ * It does not touch `except` clauses, `respx` mocks or TLS settings; run `check` afterwards. httpx2 needs Python 3.10+.
82
+
83
+ ## `env`: mixed stacks and TLS
84
+
85
+ ```
86
+ $ httpx2-audit env --probe api.openai.com --probe intranet.corp:8443
87
+ installed:
88
+ httpx 0.28.1
89
+ httpx2 2.14.0
90
+ depend on httpx: respx
91
+ depend on httpx2: pytest-httpx2
92
+ tls intranet.corp:8443: openssl-default=ok, certifi=FAIL: CERTIFICATE_VERIFY_FAILED, truststore=ok
93
+ WARNING: TLS trust differs for intranet.corp:8443: ...
94
+ ```
95
+
96
+ Lists the relevant installed distributions, which installed packages require httpx or httpx2 (a mixed
97
+ environment cannot pass clients between them), and compares what the system store, certifi and truststore
98
+ (if installed) accept for the hosts you probe. Exit code `1` when warnings are printed.
99
+
100
+ ## Development
101
+
102
+ `pytest` (37 tests). Tested on Python 3.9 and 3.14; clean installs of wheel and sdist checked.
103
+
104
+ ## Donate
105
+
106
+ If this saved your migration a day: https://paypal.me/RafalBaranowski
107
+
108
+ MIT licensed.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ httpx2_audit.py
5
+ pyproject.toml
6
+ httpx2_audit.egg-info/PKG-INFO
7
+ httpx2_audit.egg-info/SOURCES.txt
8
+ httpx2_audit.egg-info/dependency_links.txt
9
+ httpx2_audit.egg-info/entry_points.txt
10
+ httpx2_audit.egg-info/top_level.txt
11
+ tests/test_audit.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ httpx2-audit = httpx2_audit:main
@@ -0,0 +1 @@
1
+ httpx2_audit
@@ -0,0 +1,598 @@
1
+ """httpx2-audit: find what breaks when a Python stack moves from httpx to httpx2."""
2
+ import argparse
3
+ import ast
4
+ import json
5
+ import os
6
+ import re
7
+ import socket
8
+ import ssl
9
+ import sys
10
+ from importlib import metadata
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ DONATE = "https://paypal.me/RafalBaranowski"
15
+ SKIP_DIRS = {".git", ".hg", ".venv", "venv", "env", "node_modules", "build", "dist",
16
+ "site-packages", "__pycache__", ".tox", ".nox", ".mypy_cache", ".eggs"}
17
+ SEV_ORDER = {"info": 0, "warning": 1, "error": 2}
18
+
19
+ RULES = {
20
+ "HX001": ("info", "imports httpx; httpx2 is a separate package with its own module name"),
21
+ "HX002": ("error", "httpx is imported but not declared as a dependency; SDKs that moved to httpx2 no longer install it"),
22
+ "HX003": ("warning", "except clause names an httpx exception; errors raised by httpx2-based code are not caught"),
23
+ "HX004": ("warning", "httpx object or http_client passed on; an httpx2-based SDK needs httpx2 objects"),
24
+ "HX005": ("warning", "isinstance/issubclass against an httpx class fails for httpx2 objects"),
25
+ "HX006": ("warning", "imports httpcore; httpx2 is built on httpcore2"),
26
+ "HX007": ("warning", "test mock library for httpx; it does not intercept httpx2 requests, so tests may reach the live API"),
27
+ "HX008": ("info", "certifi bundle passed explicitly; httpx2 defaults to the OS trust store (truststore)"),
28
+ "HX010": ("warning", "file imports both httpx and httpx2"),
29
+ }
30
+
31
+ # import name -> (distribution, replacement import name, replacement distribution spec)
32
+ SWAP_MODULES = {
33
+ "httpx": "httpx2",
34
+ "httpcore": "httpcore2",
35
+ "pytest_httpx": "pytest_httpx2",
36
+ }
37
+ SWAP_DISTS = {
38
+ "httpx": ("httpx2", ">=2.0"),
39
+ "httpcore": ("httpcore2", ">=2.0"),
40
+ "pytest-httpx": ("pytest-httpx2", ">=1.0"),
41
+ }
42
+ MOCK_MODULES = {"respx", "pytest_httpx"}
43
+ WATCH_DISTS = {"httpx", "httpx2", "httpcore", "httpcore2", "respx", "pytest-httpx", "pytest-httpx2"}
44
+
45
+
46
+ def norm(name):
47
+ return re.sub(r"[-_.]+", "-", name).lower()
48
+
49
+
50
+ class Finding(dict):
51
+ pass
52
+
53
+
54
+ def finding(rule, path, line, col, detail=""):
55
+ sev, msg = RULES[rule]
56
+ return Finding(rule=rule, severity=sev, path=path, line=line, col=col,
57
+ message=msg + (" (%s)" % detail if detail else ""))
58
+
59
+
60
+ # ----------------------------------------------------------------- manifests
61
+
62
+ REQ_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([<>=!~].*?)?\s*(;.*)?$")
63
+
64
+
65
+ def iter_files(roots, pred):
66
+ for root in roots:
67
+ if os.path.isfile(root):
68
+ if pred(os.path.basename(root)):
69
+ yield root
70
+ continue
71
+ for dp, dns, fns in os.walk(root):
72
+ dns[:] = sorted(d for d in dns if d not in SKIP_DIRS and not d.endswith(".egg-info"))
73
+ for fn in sorted(fns):
74
+ if pred(fn):
75
+ yield os.path.join(dp, fn)
76
+
77
+
78
+ def is_manifest(fn):
79
+ return fn == "pyproject.toml" or (fn.startswith("requirements") and fn.endswith((".txt", ".in")))
80
+
81
+
82
+ def manifest_requirements(path):
83
+ """Yield (lineno, requirement-name) for requirement-looking lines/strings."""
84
+ try:
85
+ with open(path, encoding="utf-8") as f:
86
+ lines = f.read().splitlines()
87
+ except (OSError, UnicodeDecodeError):
88
+ return
89
+ for i, line in enumerate(lines, 1):
90
+ if path.endswith("pyproject.toml"):
91
+ for m in re.finditer(r"""["']([^"'\n]+)["']""", line):
92
+ r = REQ_RE.match(m.group(1))
93
+ if r and norm(r.group(1)) in WATCH_DISTS:
94
+ yield i, norm(r.group(1))
95
+ else:
96
+ s = line.split("#", 1)[0].strip()
97
+ if s and not s.startswith("-"):
98
+ r = REQ_RE.match(s)
99
+ if r:
100
+ yield i, norm(r.group(1))
101
+
102
+
103
+ def declared_deps(roots):
104
+ found = set()
105
+ manifests = list(iter_files(roots, is_manifest))
106
+ for m in manifests:
107
+ for _, name in manifest_requirements(m):
108
+ found.add(name)
109
+ return found, manifests
110
+
111
+
112
+ # ------------------------------------------------------------------- scanner
113
+
114
+ class Scanner(ast.NodeVisitor):
115
+ def __init__(self, path, tree):
116
+ self.path = path
117
+ self.out = []
118
+ self.mod_alias = {} # local name -> module ("httpx"/"httpcore"/...)
119
+ self.name_alias = {} # local name -> "httpx.Client"
120
+ self.dual = set() # ids of import nodes inside try/except ImportError
121
+ self.imports = [] # (node, module) for httpx imports (for HX002/HX010)
122
+ self.uses_httpx = False
123
+ self.uses_httpx2 = False
124
+ self._mark_dual(tree)
125
+ self.tree = tree
126
+
127
+ def add(self, rule, node, detail=""):
128
+ self.out.append(finding(rule, self.path, node.lineno, node.col_offset + 1, detail))
129
+
130
+ def _mark_dual(self, tree):
131
+ for node in ast.walk(tree):
132
+ if isinstance(node, ast.Try):
133
+ catches = False
134
+ for h in node.handlers:
135
+ names = []
136
+ if h.type is not None:
137
+ names = [n for n in ast.walk(h.type) if isinstance(n, ast.Name)]
138
+ if h.type is None or any(n.id in ("ImportError", "ModuleNotFoundError") for n in names):
139
+ catches = True
140
+ if not catches:
141
+ continue
142
+ for part in (node.body, *[h.body for h in node.handlers]):
143
+ for st in part:
144
+ if isinstance(st, (ast.Import, ast.ImportFrom)):
145
+ self.dual.add(id(st))
146
+
147
+ def resolve(self, node):
148
+ """Dotted name for Name/Attribute chains, with import aliases resolved."""
149
+ parts = []
150
+ while isinstance(node, ast.Attribute):
151
+ parts.append(node.attr)
152
+ node = node.value
153
+ if not isinstance(node, ast.Name):
154
+ return None
155
+ head = node.id
156
+ if head in self.name_alias:
157
+ base = self.name_alias[head]
158
+ elif head in self.mod_alias:
159
+ base = self.mod_alias[head]
160
+ else:
161
+ return None
162
+ return ".".join([base] + parts[::-1])
163
+
164
+ def mentions(self, node, prefix="httpx"):
165
+ for n in ast.walk(node):
166
+ if isinstance(n, (ast.Name, ast.Attribute)):
167
+ r = self.resolve(n)
168
+ if r and (r == prefix or r.startswith(prefix + ".")):
169
+ return True
170
+ return False
171
+
172
+ def visit_Import(self, node):
173
+ for a in node.names:
174
+ top = a.name.split(".")[0]
175
+ local = a.asname or top
176
+ if top in ("httpx", "httpx2", "httpcore", "httpcore2", "respx", "pytest_httpx", "certifi"):
177
+ self.mod_alias[local] = top if a.asname is None or "." not in a.name else a.name
178
+ self._module_seen(node, a.name)
179
+ self.generic_visit(node)
180
+
181
+ def visit_ImportFrom(self, node):
182
+ if node.level == 0 and node.module:
183
+ top = node.module.split(".")[0]
184
+ for a in node.names:
185
+ if top in ("httpx", "httpx2", "httpcore", "httpcore2", "respx", "pytest_httpx"):
186
+ self.name_alias[a.asname or a.name] = node.module + "." + a.name
187
+ self._module_seen(node, node.module)
188
+ self.generic_visit(node)
189
+
190
+ def _module_seen(self, node, module):
191
+ top = module.split(".")[0]
192
+ is_dual = id(node) in self.dual
193
+ if top == "httpx2":
194
+ self.uses_httpx2 = True
195
+ if top == "httpx":
196
+ self.uses_httpx = not is_dual or self.uses_httpx
197
+ if not is_dual:
198
+ self.imports.append((node, top))
199
+ self.add("HX001", node)
200
+ elif top == "httpcore" and not is_dual:
201
+ self.add("HX006", node)
202
+ elif top in MOCK_MODULES:
203
+ self.add("HX007", node, top)
204
+
205
+ def visit_ExceptHandler(self, node):
206
+ if node.type is not None:
207
+ elts = node.type.elts if isinstance(node.type, ast.Tuple) else [node.type]
208
+ for e in elts:
209
+ r = self.resolve(e)
210
+ if r and r.startswith("httpx."):
211
+ self.add("HX003", e, r)
212
+ self.generic_visit(node)
213
+
214
+ def visit_Call(self, node):
215
+ fn = self.resolve(node.func)
216
+ if isinstance(node.func, ast.Name) and node.func.id in ("isinstance", "issubclass") and len(node.args) >= 2:
217
+ if self.mentions(node.args[1]):
218
+ self.add("HX005", node, self.resolve(node.args[1]) or "")
219
+ callee_httpx = bool(fn and (fn == "httpx" or fn.startswith("httpx.")))
220
+ for kw in node.keywords:
221
+ if kw.arg is None or callee_httpx:
222
+ continue
223
+ if kw.arg in ("http_client", "http_async_client") or self.mentions(kw.value):
224
+ self.add("HX004", kw.value, kw.arg)
225
+ if fn == "certifi.where" and (self.uses_httpx or self.uses_httpx2):
226
+ self.add("HX008", node)
227
+ self.generic_visit(node)
228
+
229
+
230
+ def scan_file(path):
231
+ try:
232
+ with open(path, "rb") as f:
233
+ src = f.read()
234
+ tree = ast.parse(src, filename=path)
235
+ except (OSError, SyntaxError, ValueError) as e:
236
+ return None, str(e)
237
+ sc = Scanner(path, tree)
238
+ # first pass records imports/aliases so later uses resolve; imports come first in practice
239
+ sc.visit(tree)
240
+ if sc.uses_httpx and sc.uses_httpx2:
241
+ sc.add("HX010", sc.imports[0][0] if sc.imports else tree.body[0])
242
+ return sc, None
243
+
244
+
245
+ def run_check(paths, declared=None):
246
+ findings, skipped, nfiles = [], [], 0
247
+ if declared is None:
248
+ declared, manifests = declared_deps(paths)
249
+ else:
250
+ manifests = []
251
+ for p in iter_files(paths, lambda fn: fn.endswith(".py")):
252
+ nfiles += 1
253
+ sc, err = scan_file(p)
254
+ if sc is None:
255
+ skipped.append({"path": p, "error": err})
256
+ continue
257
+ findings.extend(sc.out)
258
+ if sc.imports and manifests and not ({"httpx", "httpx2"} & declared):
259
+ node = sc.imports[0][0]
260
+ findings.append(finding("HX002", p, node.lineno, node.col_offset + 1))
261
+ findings.sort(key=lambda f: (f["path"], f["line"], f["col"], f["rule"]))
262
+ return {"findings": findings, "skipped": skipped, "files": nfiles,
263
+ "manifests": manifests, "declared": sorted(declared)}
264
+
265
+
266
+ # ---------------------------------------------------------------------- fix
267
+
268
+ def _swap_import(node, lines, dual):
269
+ """Return (start_line, end_line, new_text) or (None, reason)."""
270
+ start, end = node.lineno - 1, node.end_lineno - 1
271
+ seg = lines[start:end + 1]
272
+ first = seg[0]
273
+ indent = first[:node.col_offset]
274
+ if first[:node.col_offset].strip():
275
+ return None, "statement does not start its line"
276
+ text = "\n".join(seg)
277
+ text = text[node.col_offset:]
278
+ if ";" in text.split("#", 1)[0]:
279
+ return None, "multiple statements on one line"
280
+ if isinstance(node, ast.Import):
281
+ if any("." in a.name for a in node.names):
282
+ return None, "dotted import of a private/submodule"
283
+ if dual:
284
+ if len(node.names) != 1:
285
+ return None, "multi-name import in dual mode"
286
+ a = node.names[0]
287
+ local = a.asname or a.name
288
+ new = SWAP_MODULES[a.name]
289
+ body = "%stry:\n%s import %s as %s\n%sexcept ImportError: # pragma: no cover\n%s %s" % (
290
+ indent, indent, new, local, indent, indent, text)
291
+ return (start, end, body), None
292
+ parts = []
293
+ for a in node.names:
294
+ if a.name in SWAP_MODULES:
295
+ parts.append("%s as %s" % (SWAP_MODULES[a.name], a.asname or a.name))
296
+ else:
297
+ parts.append(a.name + (" as " + a.asname if a.asname else ""))
298
+ return (start, end, indent + "import " + ", ".join(parts)), None
299
+ # ImportFrom
300
+ if node.module != node.module.split(".")[0] and node.module.split(".")[0] in SWAP_MODULES:
301
+ return None, "import from a private/submodule (%s)" % node.module
302
+ new_mod = SWAP_MODULES[node.module]
303
+ new_text = re.sub(r"\bfrom(\s+)%s\b" % re.escape(node.module), r"from\g<1>%s" % new_mod, text, count=1)
304
+ if dual:
305
+ body = "%stry:\n%s %s\n%sexcept ImportError: # pragma: no cover\n%s %s" % (
306
+ indent, indent, new_text.replace("\n", "\n "), indent, indent, text.replace("\n", "\n "))
307
+ return (start, end, body), None
308
+ return (start, end, indent + new_text), None
309
+
310
+
311
+ def fix_source(src, dual=False):
312
+ """Return (new_source, changes, unfixable) for one Python file's source text."""
313
+ try:
314
+ tree = ast.parse(src)
315
+ except SyntaxError:
316
+ return src, [], []
317
+ sc = Scanner("<s>", tree)
318
+ sc.visit(tree)
319
+ lines = src.split("\n")
320
+ edits, unfixable = [], []
321
+ for node in ast.walk(tree):
322
+ if id(node) in sc.dual:
323
+ continue
324
+ mod = None
325
+ if isinstance(node, ast.Import):
326
+ mods = [a.name.split(".")[0] for a in node.names]
327
+ if any(m in SWAP_MODULES for m in mods):
328
+ mod = True
329
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
330
+ if node.module.split(".")[0] in SWAP_MODULES:
331
+ mod = True
332
+ if not mod:
333
+ continue
334
+ res, reason = _swap_import(node, lines, dual)
335
+ if res is None:
336
+ unfixable.append((node.lineno, reason))
337
+ else:
338
+ edits.append((res, node.lineno))
339
+ changes = []
340
+ for (s, e, text), ln in sorted(edits, key=lambda x: -x[0][0]):
341
+ lines[s:e + 1] = text.split("\n")
342
+ changes.append(ln)
343
+ return "\n".join(lines), sorted(changes), unfixable
344
+
345
+
346
+ def fix_manifest_text(text, is_pyproject):
347
+ """Swap httpx/httpcore/pytest-httpx requirements for their *2 counterparts."""
348
+ changes = []
349
+
350
+ def repl_req(s):
351
+ m = REQ_RE.match(s)
352
+ if not m:
353
+ return s
354
+ n = norm(m.group(1))
355
+ if n not in SWAP_DISTS:
356
+ return s
357
+ new, spec = SWAP_DISTS[n]
358
+ return "%s%s%s%s" % (new, m.group(2) or "", spec, (" " + m.group(4).strip()) if m.group(4) else "")
359
+
360
+ out = []
361
+ for i, line in enumerate(text.split("\n"), 1):
362
+ new = line
363
+ if is_pyproject:
364
+ new = re.sub(r"""(["'])([^"'\n]+)\1""", lambda m: m.group(1) + repl_req(m.group(2)) + m.group(1), line)
365
+ else:
366
+ body, sep, comment = line.partition("#")
367
+ if body.strip() and not body.strip().startswith("-"):
368
+ lead = body[:len(body) - len(body.lstrip())]
369
+ trail = " " if sep and body.endswith(" ") else ""
370
+ new = lead + repl_req(body.strip()) + trail + sep + comment
371
+ if new != line:
372
+ changes.append(i)
373
+ out.append(new)
374
+ return "\n".join(out), changes
375
+
376
+
377
+ def run_fix(paths, write=False, dual=False):
378
+ import difflib
379
+ report = {"files": [], "unfixable": [], "diff": ""}
380
+ diffs = []
381
+ for p in iter_files(paths, lambda fn: fn.endswith(".py")):
382
+ try:
383
+ with open(p, encoding="utf-8") as f:
384
+ src = f.read()
385
+ except (OSError, UnicodeDecodeError):
386
+ continue
387
+ new, changes, unfix = fix_source(src, dual)
388
+ for ln, why in unfix:
389
+ report["unfixable"].append({"path": p, "line": ln, "reason": why})
390
+ if new != src:
391
+ report["files"].append(p)
392
+ diffs.append("".join(difflib.unified_diff(src.splitlines(True), new.splitlines(True), p, p)))
393
+ if write:
394
+ with open(p, "w", encoding="utf-8") as f:
395
+ f.write(new)
396
+ if not dual:
397
+ for m in iter_files(paths, is_manifest):
398
+ try:
399
+ with open(m, encoding="utf-8") as f:
400
+ src = f.read()
401
+ except (OSError, UnicodeDecodeError):
402
+ continue
403
+ new, changes = fix_manifest_text(src, m.endswith("pyproject.toml"))
404
+ if new != src:
405
+ report["files"].append(m)
406
+ diffs.append("".join(difflib.unified_diff(src.splitlines(True), new.splitlines(True), m, m)))
407
+ if write:
408
+ with open(m, "w", encoding="utf-8") as f:
409
+ f.write(new)
410
+ report["diff"] = "".join(diffs)
411
+ return report
412
+
413
+
414
+ # ----------------------------------------------------------------------- env
415
+
416
+ def installed_report():
417
+ dists = {}
418
+ for d in metadata.distributions():
419
+ name = norm(d.metadata["Name"] or "")
420
+ if name:
421
+ dists[name] = d
422
+ rows = {n: dists[n].version for n in sorted(WATCH_DISTS | {"certifi", "truststore", "openai", "anthropic",
423
+ "mcp", "starlette", "huggingface-hub"}) if n in dists}
424
+ uses = {"httpx": [], "httpx2": []}
425
+ for n, d in sorted(dists.items()):
426
+ for r in d.requires or []:
427
+ m = REQ_RE.match(r.split(";")[0].strip())
428
+ if not m or "extra ==" in r.replace('"', "'").replace("extra==", "extra =="):
429
+ continue
430
+ rn = norm(m.group(1))
431
+ if rn in uses and n not in ("httpx", "httpx2"):
432
+ uses[rn].append(n)
433
+ return rows, uses
434
+
435
+
436
+ def tls_probe(host, port=443, timeout=5.0):
437
+ result = {}
438
+ ctxs = {"openssl-default": ssl.create_default_context()}
439
+ try:
440
+ import certifi
441
+ ctxs["certifi"] = ssl.create_default_context(cafile=certifi.where())
442
+ except ImportError:
443
+ pass
444
+ try:
445
+ import truststore
446
+ ctxs["truststore"] = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
447
+ except ImportError:
448
+ pass
449
+ for name, ctx in ctxs.items():
450
+ try:
451
+ with socket.create_connection((host, port), timeout=timeout) as s:
452
+ with ctx.wrap_socket(s, server_hostname=host):
453
+ result[name] = "ok"
454
+ except (ssl.SSLError, OSError) as e:
455
+ result[name] = "FAIL: %s" % (getattr(e, "reason", None) or e)
456
+ return result
457
+
458
+
459
+ def run_env(probes):
460
+ rows, uses = installed_report()
461
+ warnings = []
462
+ if uses["httpx"] and uses["httpx2"]:
463
+ warnings.append("mixed stack: %s need httpx, %s need httpx2; objects cannot cross over" %
464
+ (", ".join(uses["httpx"]), ", ".join(uses["httpx2"])))
465
+ if "respx" in rows or "pytest-httpx" in rows:
466
+ if "httpx2" in rows and "pytest-httpx2" not in rows:
467
+ warnings.append("httpx2 installed but only the httpx mock libraries are: tests will not be intercepted")
468
+ if "httpx2" in rows and sys.version_info < (3, 10):
469
+ warnings.append("httpx2 requires Python 3.10+")
470
+ tls = {}
471
+ for p in probes:
472
+ host, _, port = p.partition(":")
473
+ tls[p] = tls_probe(host, int(port) if port else 443)
474
+ vals = list(tls[p].values())
475
+ if len(set(v == "ok" for v in vals)) > 1:
476
+ warnings.append("TLS trust differs for %s: %s" % (p, ", ".join("%s=%s" % kv for kv in tls[p].items())))
477
+ return {"installed": rows, "httpx_dependents": uses["httpx"], "httpx2_dependents": uses["httpx2"],
478
+ "tls": tls, "warnings": warnings}
479
+
480
+
481
+ # -------------------------------------------------------------------- output
482
+
483
+ def to_sarif(findings):
484
+ rules = [{"id": k, "shortDescription": {"text": v[1]}} for k, v in RULES.items()]
485
+ lvl = {"info": "note", "warning": "warning", "error": "error"}
486
+ return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
487
+ "runs": [{"tool": {"driver": {"name": "httpx2-audit", "version": __version__,
488
+ "informationUri": "https://github.com/rafal-one/httpx2-audit",
489
+ "rules": rules}},
490
+ "results": [{"ruleId": f["rule"], "level": lvl[f["severity"]],
491
+ "message": {"text": f["message"]},
492
+ "locations": [{"physicalLocation": {
493
+ "artifactLocation": {"uri": f["path"].replace(os.sep, "/")},
494
+ "region": {"startLine": f["line"], "startColumn": f["col"]}}}]}
495
+ for f in findings]}]}
496
+
497
+
498
+ def footer():
499
+ return "httpx2-audit is free (MIT). If it saved you time: %s" % DONATE
500
+
501
+
502
+ def cmd_check(a):
503
+ for p in a.paths:
504
+ if not os.path.exists(p):
505
+ print("httpx2-audit: no such path: %s" % p, file=sys.stderr)
506
+ return 2
507
+ res = run_check(a.paths)
508
+ floor = SEV_ORDER[a.min_severity]
509
+ shown = [f for f in res["findings"] if SEV_ORDER[f["severity"]] >= floor]
510
+ if a.format == "json":
511
+ print(json.dumps({"findings": shown, "skipped": res["skipped"], "files": res["files"],
512
+ "declared": res["declared"]}, indent=2))
513
+ elif a.format == "sarif":
514
+ print(json.dumps(to_sarif(shown), indent=2))
515
+ else:
516
+ for f in shown:
517
+ print("%s:%d:%d: %s %s [%s]" % (f["path"], f["line"], f["col"], f["rule"], f["message"], f["severity"]))
518
+ for s in res["skipped"]:
519
+ print("%s: skipped (%s)" % (s["path"], s["error"]), file=sys.stderr)
520
+ print("\n%d finding(s) in %d file(s) scanned." % (len(shown), res["files"]))
521
+ if not a.quiet:
522
+ print(footer())
523
+ if a.fail_on == "never":
524
+ return 0
525
+ bad = [f for f in res["findings"] if SEV_ORDER[f["severity"]] >= SEV_ORDER[a.fail_on]]
526
+ return 1 if bad else 0
527
+
528
+
529
+ def cmd_fix(a):
530
+ for p in a.paths:
531
+ if not os.path.exists(p):
532
+ print("httpx2-audit: no such path: %s" % p, file=sys.stderr)
533
+ return 2
534
+ rep = run_fix(a.paths, write=a.write, dual=a.dual)
535
+ if rep["diff"]:
536
+ print(rep["diff"], end="")
537
+ for u in rep["unfixable"]:
538
+ print("manual: %s:%d: %s" % (u["path"], u["line"], u["reason"]), file=sys.stderr)
539
+ verb = "rewrote" if a.write else "would rewrite (use --write)"
540
+ print("%s %d file(s)." % (verb, len(rep["files"])))
541
+ if a.dual:
542
+ print("dual mode: imports prefer httpx2 and fall back to httpx; dependencies unchanged.")
543
+ elif rep["files"]:
544
+ print("note: httpx2 needs Python 3.10+. Then run `httpx2-audit check` for what imports cannot fix "
545
+ "(except clauses, mocks, TLS).")
546
+ return 0
547
+
548
+
549
+ def cmd_env(a):
550
+ rep = run_env(a.probe)
551
+ if a.format == "json":
552
+ print(json.dumps(rep, indent=2))
553
+ else:
554
+ print("installed:")
555
+ for n, v in rep["installed"].items():
556
+ print(" %-18s %s" % (n, v))
557
+ print("depend on httpx: %s" % (", ".join(rep["httpx_dependents"]) or "-"))
558
+ print("depend on httpx2: %s" % (", ".join(rep["httpx2_dependents"]) or "-"))
559
+ for h, r in rep["tls"].items():
560
+ print("tls %s: %s" % (h, ", ".join("%s=%s" % kv for kv in r.items())))
561
+ for w in rep["warnings"]:
562
+ print("WARNING: " + w)
563
+ print(footer())
564
+ return 1 if rep["warnings"] else 0
565
+
566
+
567
+ def build_parser():
568
+ p = argparse.ArgumentParser(prog="httpx2-audit", description=__doc__)
569
+ p.add_argument("--version", action="version", version="httpx2-audit " + __version__)
570
+ sub = p.add_subparsers(dest="cmd", required=True)
571
+ c = sub.add_parser("check", help="scan Python files for httpx -> httpx2 problems")
572
+ c.add_argument("paths", nargs="*", default=["."])
573
+ c.add_argument("--format", choices=["text", "json", "sarif"], default="text")
574
+ c.add_argument("--min-severity", choices=list(SEV_ORDER), default="info")
575
+ c.add_argument("--fail-on", choices=["info", "warning", "error", "never"], default="warning")
576
+ c.add_argument("-q", "--quiet", action="store_true", help="omit the footer")
577
+ c.set_defaults(func=cmd_check)
578
+ f = sub.add_parser("fix", help="rewrite imports and dependencies (dry run unless --write)")
579
+ f.add_argument("paths", nargs="*", default=["."])
580
+ f.add_argument("--write", action="store_true")
581
+ f.add_argument("--dual", action="store_true", help="try httpx2, fall back to httpx (for libraries)")
582
+ f.set_defaults(func=cmd_fix)
583
+ e = sub.add_parser("env", help="report installed httpx/httpx2 stack and optionally probe TLS")
584
+ e.add_argument("--probe", action="append", default=[], metavar="HOST[:PORT]")
585
+ e.add_argument("--format", choices=["text", "json"], default="text")
586
+ e.set_defaults(func=cmd_env)
587
+ return p
588
+
589
+
590
+ def main(argv=None):
591
+ a = build_parser().parse_args(argv)
592
+ if a.cmd in ("check", "fix") and not a.paths:
593
+ a.paths = ["."]
594
+ return a.func(a)
595
+
596
+
597
+ if __name__ == "__main__":
598
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "httpx2-audit"
7
+ version = "0.1.0"
8
+ description = "Find what breaks when your Python stack moves from httpx to httpx2: static audit, import/dependency codemod and a mixed-stack/TLS environment report."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [{name = "agent-one", email = "rafal.baranowski.iot@gmail.com"}]
13
+ keywords = ["httpx", "httpx2", "migration", "codemod", "audit", "openai", "anthropic", "respx", "pytest-httpx"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/rafal-one/httpx2-audit"
22
+
23
+ [project.scripts]
24
+ httpx2-audit = "httpx2_audit:main"
25
+
26
+ [tool.setuptools]
27
+ py-modules = ["httpx2_audit"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,251 @@
1
+ import json
2
+ import os
3
+
4
+ import pytest
5
+
6
+ import httpx2_audit as h
7
+
8
+
9
+ def write(tmp_path, name, text):
10
+ p = tmp_path / name
11
+ p.parent.mkdir(parents=True, exist_ok=True)
12
+ p.write_text(text)
13
+ return str(p)
14
+
15
+
16
+ def rules(res):
17
+ return [f["rule"] for f in res["findings"]]
18
+
19
+
20
+ def test_import_info(tmp_path):
21
+ write(tmp_path, "a.py", "import httpx\nhttpx.get('x')\n")
22
+ assert rules(h.run_check([str(tmp_path)])) == ["HX001"]
23
+
24
+
25
+ def test_clean_file(tmp_path):
26
+ write(tmp_path, "a.py", "import os\nprint(os.name)\n")
27
+ res = h.run_check([str(tmp_path)])
28
+ assert res["findings"] == [] and res["files"] == 1
29
+
30
+
31
+ def test_except_clause_and_alias(tmp_path):
32
+ write(tmp_path, "a.py", "import httpx as hx\nfrom httpx import ConnectError as CE\n"
33
+ "try:\n pass\nexcept (hx.HTTPError, CE):\n pass\n")
34
+ r = rules(h.run_check([str(tmp_path)]))
35
+ assert r.count("HX003") == 2
36
+
37
+
38
+ def test_http_client_kwarg(tmp_path):
39
+ write(tmp_path, "a.py", "import httpx, openai\nc = openai.OpenAI(http_client=httpx.Client())\n"
40
+ "d = openai.OpenAI(timeout=httpx.Timeout(3))\ne = httpx.Client(timeout=httpx.Timeout(3))\n")
41
+ res = h.run_check([str(tmp_path)])
42
+ assert rules(res).count("HX004") == 2
43
+
44
+
45
+ def test_http_client_variable(tmp_path):
46
+ write(tmp_path, "a.py", "import anthropic\nanthropic.Anthropic(http_client=cli)\n")
47
+ assert "HX004" in rules(h.run_check([str(tmp_path)]))
48
+
49
+
50
+ def test_isinstance(tmp_path):
51
+ write(tmp_path, "a.py", "import httpx\nisinstance(r, httpx.Response)\nissubclass(A, (int, httpx.Client))\n")
52
+ assert rules(h.run_check([str(tmp_path)])).count("HX005") == 2
53
+
54
+
55
+ def test_httpcore_and_mocks(tmp_path):
56
+ write(tmp_path, "t.py", "import httpcore\nimport respx\nfrom pytest_httpx import HTTPXMock\n")
57
+ r = rules(h.run_check([str(tmp_path)]))
58
+ assert r.count("HX006") == 1 and r.count("HX007") == 2
59
+
60
+
61
+ def test_certifi(tmp_path):
62
+ write(tmp_path, "a.py", "import certifi, httpx\nhttpx.Client(verify=certifi.where())\n")
63
+ assert "HX008" in rules(h.run_check([str(tmp_path)]))
64
+
65
+
66
+ def test_certifi_without_httpx_is_quiet(tmp_path):
67
+ write(tmp_path, "a.py", "import certifi\ncertifi.where()\n")
68
+ assert rules(h.run_check([str(tmp_path)])) == []
69
+
70
+
71
+ def test_mixed_imports(tmp_path):
72
+ write(tmp_path, "a.py", "import httpx\nimport httpx2\n")
73
+ assert "HX010" in rules(h.run_check([str(tmp_path)]))
74
+
75
+
76
+ def test_dual_import_is_not_flagged(tmp_path):
77
+ write(tmp_path, "a.py", "try:\n import httpx2 as httpx\nexcept ImportError:\n import httpx\n")
78
+ assert rules(h.run_check([str(tmp_path)])) == []
79
+
80
+
81
+ def test_undeclared_dependency(tmp_path):
82
+ write(tmp_path, "pyproject.toml", '[project]\nname="x"\ndependencies=["openai>=3"]\n')
83
+ write(tmp_path, "src/a.py", "import httpx\n")
84
+ assert "HX002" in rules(h.run_check([str(tmp_path)]))
85
+
86
+
87
+ def test_declared_dependency_ok(tmp_path):
88
+ write(tmp_path, "pyproject.toml", '[project]\nname="x"\ndependencies=["httpx>=0.27"]\n')
89
+ write(tmp_path, "a.py", "import httpx\n")
90
+ assert "HX002" not in rules(h.run_check([str(tmp_path)]))
91
+
92
+
93
+ def test_requirements_declares(tmp_path):
94
+ write(tmp_path, "requirements.txt", "# c\nhttpx2>=2 ; python_version>'3.9'\n")
95
+ write(tmp_path, "a.py", "import httpx\n")
96
+ assert "HX002" not in rules(h.run_check([str(tmp_path)]))
97
+
98
+
99
+ def test_no_manifest_no_hx002(tmp_path):
100
+ write(tmp_path, "a.py", "import httpx\n")
101
+ assert "HX002" not in rules(h.run_check([str(tmp_path)]))
102
+
103
+
104
+ def test_syntax_error_skipped(tmp_path):
105
+ write(tmp_path, "bad.py", "def (:\n")
106
+ res = h.run_check([str(tmp_path)])
107
+ assert len(res["skipped"]) == 1 and res["findings"] == []
108
+
109
+
110
+ def test_skip_dirs(tmp_path):
111
+ write(tmp_path, ".venv/lib/a.py", "import httpx\n")
112
+ write(tmp_path, "node_modules/b.py", "import httpx\n")
113
+ assert h.run_check([str(tmp_path)])["files"] == 0
114
+
115
+
116
+ # ---- fix
117
+
118
+ def test_fix_plain():
119
+ src = "import os\nimport httpx\nimport httpx as hx\nfrom httpx import Client, Timeout\nhttpx.get('u')\n"
120
+ new, ch, un = h.fix_source(src)
121
+ assert "import httpx2 as httpx\n" in new and "import httpx2 as hx" in new
122
+ assert "from httpx2 import Client, Timeout" in new
123
+ assert "import os\n" in new and un == []
124
+ compile(new, "x", "exec")
125
+
126
+
127
+ def test_fix_multi_name_and_httpcore():
128
+ new, _, _ = h.fix_source("import os, httpx\nimport httpcore\nimport pytest_httpx\n")
129
+ assert "import os, httpx2 as httpx" in new
130
+ assert "import httpcore2 as httpcore" in new and "import pytest_httpx2 as pytest_httpx" in new
131
+
132
+
133
+ def test_fix_dual():
134
+ src = "def f():\n import httpx\n from httpx import Client\n"
135
+ new, _, _ = h.fix_source(src, dual=True)
136
+ compile(new, "x", "exec")
137
+ assert "try:\n import httpx2 as httpx\n except ImportError:" in new
138
+ assert "from httpx2 import Client" in new and "from httpx import Client" in new
139
+
140
+
141
+ def test_fix_skips_existing_dual():
142
+ src = "try:\n import httpx2 as httpx\nexcept ImportError:\n import httpx\n"
143
+ assert h.fix_source(src)[0] == src
144
+
145
+
146
+ def test_fix_private_is_manual():
147
+ src = "from httpx._models import Headers\nimport httpx._client\n"
148
+ new, ch, un = h.fix_source(src)
149
+ assert new == src and len(un) == 2
150
+
151
+
152
+ def test_fix_idempotent():
153
+ once = h.fix_source("import httpx\nfrom httpx import A\n")[0]
154
+ assert h.fix_source(once)[0] == once
155
+
156
+
157
+ def test_fix_manifest_requirements():
158
+ new, ch = h.fix_manifest_text("httpx[http2]>=0.27,<1 # c\nrequests\npytest-httpx>=0.30\nhttpcore\n", False)
159
+ assert new.splitlines()[0].startswith("httpx2[http2]>=2.0")
160
+ assert "# c" in new and "requests" in new
161
+ assert "pytest-httpx2>=1.0" in new and "httpcore2>=2.0" in new and ch == [1, 3, 4]
162
+
163
+
164
+ def test_fix_manifest_pyproject():
165
+ t = 'dependencies = ["httpx>=0.27", "openai", "httpx-sse"]\ndescription = "a httpx tool"\n'
166
+ new, ch = h.fix_manifest_text(t, True)
167
+ assert '"httpx2>=2.0"' in new and '"httpx-sse"' in new and "a httpx tool" in new
168
+
169
+
170
+ def test_run_fix_dry_and_write(tmp_path):
171
+ p = write(tmp_path, "a.py", "import httpx\n")
172
+ r = write(tmp_path, "requirements.txt", "httpx>=0.27\n")
173
+ rep = h.run_fix([str(tmp_path)])
174
+ assert len(rep["files"]) == 2 and "+import httpx2 as httpx" in rep["diff"]
175
+ assert open(p).read() == "import httpx\n"
176
+ h.run_fix([str(tmp_path)], write=True)
177
+ assert open(p).read() == "import httpx2 as httpx\n" and open(r).read() == "httpx2>=2.0\n"
178
+
179
+
180
+ def test_run_fix_dual_leaves_deps(tmp_path):
181
+ r = write(tmp_path, "requirements.txt", "httpx>=0.27\n")
182
+ write(tmp_path, "a.py", "import httpx\n")
183
+ h.run_fix([str(tmp_path)], write=True, dual=True)
184
+ assert open(r).read() == "httpx>=0.27\n"
185
+
186
+
187
+ # ---- CLI
188
+
189
+ def test_cli_exit_codes(tmp_path, capsys):
190
+ write(tmp_path, "a.py", "import httpx\ntry:\n pass\nexcept httpx.HTTPError:\n pass\n")
191
+ assert h.main(["check", str(tmp_path)]) == 1
192
+ assert h.main(["check", str(tmp_path), "--fail-on", "never"]) == 0
193
+ assert h.main(["check", str(tmp_path), "--fail-on", "error"]) == 0
194
+
195
+
196
+ def test_cli_clean_exit_zero(tmp_path, capsys):
197
+ write(tmp_path, "a.py", "import httpx\n")
198
+ assert h.main(["check", str(tmp_path)]) == 0 # info only
199
+
200
+
201
+ def test_cli_missing_path(capsys):
202
+ assert h.main(["check", "/nonexistent/zzz"]) == 2
203
+ assert "no such path" in capsys.readouterr().err
204
+
205
+
206
+ def test_cli_bad_usage():
207
+ with pytest.raises(SystemExit) as e:
208
+ h.main(["bogus"])
209
+ assert e.value.code == 2
210
+
211
+
212
+ def test_cli_json_and_sarif(tmp_path, capsys):
213
+ write(tmp_path, "a.py", "import httpcore\n")
214
+ h.main(["check", str(tmp_path), "--format", "json"])
215
+ data = json.loads(capsys.readouterr().out)
216
+ assert data["findings"][0]["rule"] == "HX006"
217
+ h.main(["check", str(tmp_path), "--format", "sarif"])
218
+ sarif = json.loads(capsys.readouterr().out)
219
+ assert sarif["version"] == "2.1.0" and sarif["runs"][0]["results"][0]["ruleId"] == "HX006"
220
+
221
+
222
+ def test_cli_text_footer_and_quiet(tmp_path, capsys):
223
+ write(tmp_path, "a.py", "import httpcore\n")
224
+ h.main(["check", str(tmp_path)])
225
+ assert "paypal.me/RafalBaranowski" in capsys.readouterr().out
226
+ h.main(["check", str(tmp_path), "-q"])
227
+ assert "paypal" not in capsys.readouterr().out
228
+
229
+
230
+ def test_cli_fix_command(tmp_path, capsys):
231
+ p = write(tmp_path, "a.py", "import httpx\n")
232
+ assert h.main(["fix", str(tmp_path), "--write"]) == 0
233
+ assert open(p).read() == "import httpx2 as httpx\n"
234
+
235
+
236
+ def test_env_json(capsys):
237
+ rc = h.main(["env", "--format", "json"])
238
+ data = json.loads(capsys.readouterr().out)
239
+ assert "installed" in data and rc in (0, 1)
240
+
241
+
242
+ def test_env_mixed_stack_warning(monkeypatch):
243
+ monkeypatch.setattr(h, "installed_report", lambda: ({"httpx": "0.28", "httpx2": "2.1"},
244
+ {"httpx": ["a"], "httpx2": ["b"]}))
245
+ rep = h.run_env([])
246
+ assert any("mixed stack" in w for w in rep["warnings"])
247
+
248
+
249
+ def test_tls_probe_unreachable():
250
+ res = h.tls_probe("127.0.0.1", 1, timeout=0.5)
251
+ assert all(v.startswith("FAIL") for v in res.values())