Skip to content

Commit 1ade92f

Browse files
claudeZac-HD
authored andcommitted
Support and encourage use of httpx2 (#460)
The original httpx package is no longer maintained; httpx2 is the continuation of the project, maintained by Pydantic. - Add ASYNC127 unmaintained-httpx: triggers on any import of httpx, recommending httpx2 to keep getting security updates. - ASYNC210 and ASYNC212 now detect blocking calls made through httpx2 identically to httpx ones. - ASYNC210/211/212 messages now recommend httpx2.AsyncClient instead of httpx.AsyncClient. https://claude.ai/code/session_01LmC7WhyCTN11M9xBycd4Xu
1 parent 845881e commit 1ade92f

10 files changed

Lines changed: 163 additions & 50 deletions

File tree

docs/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Changelog
44

55
`CalVer, YY.month.patch <https://calver.org/>`_
66

7+
26.6.1
8+
======
9+
- Add :ref:`ASYNC127 <async127>` unmaintained-httpx: use ``httpx2`` instead of ``httpx``, which is no longer maintained, to get security updates. `(issue #460) <https://github.com/python-trio/flake8-async/issues/460>`_
10+
- :ref:`ASYNC210 <async210>`, :ref:`ASYNC211 <async211>` and :ref:`ASYNC212 <async212>` now also detect blocking calls made through ``httpx2``, and their messages recommend ``httpx2.AsyncClient`` instead of ``httpx.AsyncClient``.
11+
712
26.4.2
813
======
914
- Fixed a regression in canonical-qualname resolution where a call nested inside an attribute chain (e.g. ``foo("x").bar``) was silently elided into a dotted name (``"foo.bar"``). This caused :ref:`ASYNC200 <async200>` false alarms for patterns like ``*session.get`` matching ``read_session("a").get(...)``, where ``.get`` is a method on the *return value* of ``read_session()``.

docs/rules.rst

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,24 @@ _`ASYNC126`: exceptiongroup-subclass-missing-derive
134134
plain ``ExceptionGroup`` instances and lose the custom subclass.
135135
See `trio#3175 <https://github.com/python-trio/trio/issues/3175>`_ for motivation.
136136

137+
_`ASYNC127`: unmaintained-httpx
138+
The original ``httpx`` package is no longer maintained, which is a problem for a
139+
networking library. Use `httpx2`_ - the continuation of the project, maintained
140+
by Pydantic - to keep getting security updates. It uses the same API, so
141+
migration is usually just a matter of replacing ``httpx`` with ``httpx2`` in
142+
imports. This rule triggers on any import of ``httpx``.
143+
137144
Blocking sync calls in async functions
138145
======================================
139146

140147
Our 2xx lint rules warn you to use the async equivalent for slow sync calls which
141148
would otherwise block the event loop (and therefore cause performance problems,
142149
or even deadlock).
143150

151+
.. _httpx2: https://httpx2.pydantic.dev/
144152
.. _httpx.Client: https://www.python-httpx.org/api/#client
145-
.. _httpx.AsyncClient: https://www.python-httpx.org/api/#asyncclient
153+
.. _httpx2.Client: https://httpx2.pydantic.dev/api/#client
154+
.. _httpx2.AsyncClient: https://httpx2.pydantic.dev/api/#asyncclient
146155
.. _urllib3: https://github.com/urllib3/urllib3
147156
.. _aiofiles: https://pypi.org/project/aiofiles/
148157
.. _anyio: https://github.com/agronholm/anyio
@@ -151,16 +160,17 @@ _`ASYNC200` : blocking-configured-call
151160
User-configured error for blocking sync calls in async functions.
152161
Does nothing by default, see :ref:`async200-blocking-calls` for how to configure it.
153162

154-
ASYNC210 : blocking-http-call
155-
Sync HTTP call in async function, use `httpx.AsyncClient`_.
156-
This and the other :ref:`ASYNC21x <ASYNC211>` checks look for usage of `urllib3`_ and `httpx.Client`_, and recommend using `httpx.AsyncClient`_ as that's the largest http client supporting anyio/trio.
163+
_`ASYNC210` : blocking-http-call
164+
Sync HTTP call in async function, use `httpx2.AsyncClient`_.
165+
This and the other :ref:`ASYNC21x <ASYNC211>` checks look for usage of `urllib3`_, `httpx.Client`_, and `httpx2.Client`_, and recommend using `httpx2.AsyncClient`_ as that's the largest http client supporting anyio/trio.
166+
Sync calls through the unmaintained ``httpx`` package are detected the same way as ``httpx2`` ones, but the recommendation is always `httpx2.AsyncClient`_ - see :ref:`ASYNC127 <ASYNC127>`.
157167

158168
_`ASYNC211` : blocking-http-call-pool
159-
Likely sync HTTP call in async function, use `httpx.AsyncClient`_.
169+
Likely sync HTTP call in async function, use `httpx2.AsyncClient`_.
160170
Looks for `urllib3`_ method calls on pool objects, but only matching on the method signature and not the object.
161171

162-
ASYNC212 : blocking-http-call-httpx
163-
Blocking sync HTTP call on httpx object, use `httpx.AsyncClient`_.
172+
_`ASYNC212` : blocking-http-call-httpx
173+
Blocking sync HTTP call on httpx/httpx2 object, use `httpx2.AsyncClient`_.
164174

165175
ASYNC220 : blocking-create-subprocess
166176
Sync call to :class:`subprocess.Popen` (or equivalent) in async function, use :func:`trio.run_process`/:func:`anyio.run_process`/:ref:`asyncio.create_subprocess_[exec/shell] <asyncio-subprocess>` in a :ref:`taskgroup_nursery`.

docs/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``:
3333
minimum_pre_commit_version: '2.9.0'
3434
repos:
3535
- repo: https://github.com/python-trio/flake8-async
36-
rev: 26.4.2
36+
rev: 26.6.1
3737
hooks:
3838
- id: flake8-async
3939
# args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"]

flake8_async/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939

4040
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
41-
__version__ = "26.4.2"
41+
__version__ = "26.6.1"
4242

4343

4444
# taken from https://github.com/Zac-HD/shed

flake8_async/visitors/visitor2xx.py

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ def visit_blocking_call(self, node: ast.Call):
6262
@error_class
6363
class Visitor21X(Visitor200):
6464
error_codes: Mapping[str, str] = {
65-
"ASYNC210": "Sync HTTP call {} in async function, use `httpx.AsyncClient`.",
65+
"ASYNC210": "Sync HTTP call {} in async function, use `httpx2.AsyncClient`.",
6666
"ASYNC211": (
67-
"Likely sync HTTP call {} in async function, use `httpx.AsyncClient`."
67+
"Likely sync HTTP call {} in async function, use `httpx2.AsyncClient`."
6868
),
6969
}
7070

@@ -77,17 +77,12 @@ def visit_blocking_call(self, node: ast.Call):
7777
http_methods = {"get", "options", "head", "post", "put", "patch", "delete"}
7878
func_name = ast.unparse(node.func)
7979
canonical = self.canonical_name(node.func) or func_name
80-
for http_package in "requests", "httpx":
81-
if get_matching_call(
82-
node,
83-
*http_methods | {"request"},
84-
base=http_package,
85-
imports=self.imports,
86-
):
87-
self.error(node, func_name, error_code="ASYNC210")
88-
return
89-
90-
if canonical in (
80+
if get_matching_call(
81+
node,
82+
*http_methods | {"request"},
83+
base=("requests", "httpx", "httpx2"),
84+
imports=self.imports,
85+
) or canonical in (
9186
"urllib3.request",
9287
"urllib.request.urlopen",
9388
"request.urlopen",
@@ -107,53 +102,61 @@ def visit_blocking_call(self, node: ast.Call):
107102
self.error(node, func_name, error_code="ASYNC211")
108103

109104

105+
httpx_blocking_methods = (
106+
"close",
107+
"delete",
108+
"get",
109+
"head",
110+
"options",
111+
"patch",
112+
"post",
113+
"put",
114+
"request",
115+
"send",
116+
"stream",
117+
)
118+
119+
110120
@error_class
111121
class Visitor212(Visitor200):
112122
error_codes: Mapping[str, str] = {
113123
"ASYNC212": (
114-
"Blocking sync HTTP call {0} on httpx object {1}, use httpx.AsyncClient."
124+
"Blocking sync HTTP call {0} on httpx/httpx2 object {1},"
125+
" use httpx2.AsyncClient."
115126
)
116127
}
117128

118129
def __init__(self, *args: Any, **kwargs: Any):
119-
self.dangerous_classes = (
120-
"httpx.Client",
121-
"urllib3.HTTPConnectionPool",
122-
"urllib3.HTTPSConnectionPool",
123-
"urllib3.PoolManager",
124-
"urllib3.ProxyManager",
125-
"urllib3.connectionpool.ConnectionPool",
126-
"urllib3.connectionpool.HTTPConnectionPool",
127-
"urllib3.connectionpool.HTTPSConnectionPool",
128-
"urllib3.poolmanager.PoolManager",
129-
"urllib3.poolmanager.ProxyManager",
130-
"urllib3.request.RequestMethods",
131-
)
130+
# class -> methods that block. None means all methods block.
131+
self.dangerous_classes: dict[str, tuple[str, ...] | None] = {
132+
"httpx.Client": httpx_blocking_methods,
133+
"httpx2.Client": httpx_blocking_methods,
134+
"urllib3.HTTPConnectionPool": None,
135+
"urllib3.HTTPSConnectionPool": None,
136+
"urllib3.PoolManager": None,
137+
"urllib3.ProxyManager": None,
138+
"urllib3.connectionpool.ConnectionPool": None,
139+
"urllib3.connectionpool.HTTPConnectionPool": None,
140+
"urllib3.connectionpool.HTTPSConnectionPool": None,
141+
"urllib3.poolmanager.PoolManager": None,
142+
"urllib3.poolmanager.ProxyManager": None,
143+
"urllib3.request.RequestMethods": None,
144+
}
132145
super().__init__(*args, **kwargs)
133146
for c in self.dangerous_classes:
134147
self.typed_calls[c] = c
135148

136149
def visit_blocking_call(self, node: ast.Call):
137-
httpx_blocking_methods = (
138-
"close",
139-
"delete",
140-
"get",
141-
"head",
142-
"options",
143-
"patch",
144-
"post",
145-
"put",
146-
"request",
147-
"send",
148-
"stream",
149-
)
150150
if (
151151
isinstance(node.func, ast.Attribute)
152152
and isinstance(node.func.value, ast.Name)
153153
and node.func.value.id in self.variables
154154
and (anno := self.variables.get(node.func.value.id))
155-
and (anno != "httpx.Client" or node.func.attr in httpx_blocking_methods)
156155
and anno in self.dangerous_classes
156+
and (
157+
(blocking_methods := self.dangerous_classes[anno]) is None
158+
or node.func.attr in blocking_methods
159+
)
157160
):
158161
self.error(node, node.func.attr, node.func.value.id)
159162

flake8_async/visitors/visitors.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,30 @@ def base_name(base: ast.expr) -> str:
593593
self.error(node, node.name, node.name)
594594

595595

596+
@error_class
597+
class Visitor127(Flake8AsyncVisitor):
598+
error_codes: Mapping[str, str] = {
599+
"ASYNC127": (
600+
"Use `httpx2` instead of `httpx`, which is no longer maintained,"
601+
" to get security updates."
602+
),
603+
}
604+
605+
@staticmethod
606+
def _is_httpx(module: str) -> bool:
607+
return module == "httpx" or module.startswith("httpx.")
608+
609+
def visit_Import(self, node: ast.Import):
610+
if any(self._is_httpx(name.name) for name in node.names):
611+
self.error(node)
612+
613+
def visit_ImportFrom(self, node: ast.ImportFrom):
614+
# relative imports (level != 0) refer to local modules, not the httpx package,
615+
# and `from . import x` has no module name at all
616+
if node.level == 0 and self._is_httpx(node.module or ""):
617+
self.error(node)
618+
619+
596620
@error_class_cst
597621
class Visitor300(Flake8AsyncVisitor_cst):
598622
error_codes: Mapping[str, str] = {

tests/eval_files/async127.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# type: ignore
2+
# isort: skip
3+
import importlib
4+
5+
import httpx # error: 0
6+
import httpx as foo # error: 0
7+
import httpx.config # error: 0
8+
import httpx, httpx.config # error: 0
9+
from httpx import AsyncClient # error: 0
10+
from httpx import Client as bar # error: 0
11+
from httpx import * # error: 0
12+
from httpx._client import Client # error: 0
13+
14+
# httpx2 is the maintained continuation of httpx, and is what we recommend
15+
import httpx2
16+
import httpx2 as foo2
17+
from httpx2 import AsyncClient
18+
19+
# other modules that happen to share a prefix or name are fine
20+
import httpx_auth
21+
from httpx_auth import Whatever
22+
import foo.httpx
23+
from foo.httpx import bar
24+
25+
# relative imports refer to local modules, not the httpx package
26+
from . import httpx
27+
from .httpx import Client
28+
29+
# ways of sidestepping the check, that we don't attempt to detect
30+
k = importlib.import_module("httpx")

tests/eval_files/async210.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import urllib
33

44
import httpx
5+
import httpx2
56
import requests
67
import urllib3
78

@@ -31,6 +32,16 @@ async def foo():
3132
httpx.delete("") # ASYNC210: 4, 'httpx.delete'
3233
httpx.foo()
3334

35+
httpx2.get("") # ASYNC210: 4, 'httpx2.get'
36+
httpx2.options("") # ASYNC210: 4, 'httpx2.options'
37+
httpx2.head("") # ASYNC210: 4, 'httpx2.head'
38+
httpx2.post("") # ASYNC210: 4, 'httpx2.post'
39+
httpx2.put("") # ASYNC210: 4, 'httpx2.put'
40+
httpx2.patch("") # ASYNC210: 4, 'httpx2.patch'
41+
httpx2.delete("") # ASYNC210: 4, 'httpx2.delete'
42+
httpx2.request("GET", "") # ASYNC210: 4, 'httpx2.request'
43+
httpx2.foo()
44+
3445
urllib3.request() # ASYNC210: 4, 'urllib3.request'
3546
urllib3.request(...) # ASYNC210: 4, 'urllib3.request'
3647
request()

tests/eval_files/async212.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ async def foo_global_client_overriden():
6060
glob_client.send()
6161

6262

63+
# httpx2 (the maintained continuation of httpx) is treated identically
64+
async def foo_httpx2_call():
65+
client = httpx2.Client()
66+
client.close() # ASYNC212: 4, "close", "client"
67+
client.delete() # ASYNC212: 4, "delete", "client"
68+
client.get() # ASYNC212: 4, "get", "client"
69+
client.head() # ASYNC212: 4, "head", "client"
70+
client.options() # ASYNC212: 4, "options", "client"
71+
client.patch() # ASYNC212: 4, "patch", "client"
72+
client.post() # ASYNC212: 4, "post", "client"
73+
client.put() # ASYNC212: 4, "put", "client"
74+
client.request() # ASYNC212: 4, "request", "client"
75+
client.send() # ASYNC212: 4, "send", "client"
76+
client.stream() # ASYNC212: 4, "stream", "client"
77+
78+
client.anything()
79+
client.build_request()
80+
client.is_closed
81+
82+
83+
async def foo_httpx2_ann_par(client: httpx2.Client):
84+
client.send(...) # ASYNC212: 4, "send", "client"
85+
86+
87+
async def foo_httpx2_ann_var():
88+
client: httpx2.Client = ...
89+
client.send() # ASYNC212: 4, "send", "client"
90+
91+
6392
# not implemented
6493
from httpx import Client
6594

tests/test_flake8_async.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ def _parse_eval_file(
540540
"ASYNC123",
541541
"ASYNC125",
542542
"ASYNC126",
543+
"ASYNC127",
543544
"ASYNC300",
544545
"ASYNC400",
545546
"ASYNC912",

0 commit comments

Comments
 (0)