Skip to content

Commit 2b4e252

Browse files
committed
Add ASYNC233 pathlib blocking call rule
1 parent b32a38c commit 2b4e252

5 files changed

Lines changed: 148 additions & 1 deletion

File tree

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Changelog
66

77
26.6.1
88
======
9+
- Add :ref:`ASYNC233 <async233>` blocking-pathlib-call to detect blocking ``pathlib.Path`` I/O methods in async functions. `(issue #396) <https://github.com/python-trio/flake8-async/issues/396>`_
910
- 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>`_
1011
- :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``.
1112

docs/rules.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ ASYNC231 : blocking-fdopen-call
190190
ASYNC232 : blocking-file-call
191191
Blocking sync call on file object, wrap the file object in :func:`trio.wrap_file`/:func:`anyio.wrap_file` to get an async file object.
192192

193+
ASYNC233 : blocking-pathlib-call
194+
Blocking sync call to ``pathlib.Path`` I/O methods in async function, use :class:`trio.Path`/:class:`anyio.Path`. ``asyncio`` users should consider `aiopath <https://pypi.org/project/aiopath>`__ or `anyio`_.
195+
193196
ASYNC240 : blocking-path-usage
194197
Avoid using :mod:`os.path` in async functions, prefer using :class:`trio.Path`/:class:`anyio.Path` objects. ``asyncio`` users should consider `aiopath <https://pypi.org/project/aiopath>`__ or `anyio`_.
195198

flake8_async/visitors/visitor2xx.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
210 looks for usage of HTTP requests from common http libraries.
55
211 additionally matches on object methods whose signature looks like an http request.
66
220&221 looks for subprocess and os calls that should be wrapped.
7-
230&231 looks for os.open and os.fdopen that should be wrapped.
7+
230&231 looks for open and os.fdopen that should be wrapped.
8+
233 looks for pathlib.Path methods that should use an async path object.
89
240 looks for os.path functions that interact with the disk in various ways.
910
250 looks for input() that should be wrapped
1011
"""
@@ -252,6 +253,9 @@ class Visitor23X(Visitor200):
252253
error_codes: Mapping[str, str] = {
253254
"ASYNC230": "Sync call {0} in async function, use `{1}.open_file(...)`.",
254255
"ASYNC231": "Sync call {0} in async function, use `{1}.wrap_file({0})`.",
256+
"ASYNC233": (
257+
"Sync call {0} on pathlib.Path in async function, use `{1}.Path`."
258+
),
255259
"ASYNC230_asyncio": (
256260
"Sync call {0} in async function, "
257261
" use a library such as aiofiles or anyio."
@@ -260,14 +264,104 @@ class Visitor23X(Visitor200):
260264
"Sync call {0} in async function, "
261265
" use a library such as aiofiles or anyio."
262266
),
267+
"ASYNC233_asyncio": (
268+
"Sync call {0} on pathlib.Path in async function, "
269+
" use `asyncio.loop.run_in_executor` or a library such as aiopath "
270+
"or anyio."
271+
),
263272
}
264273

274+
pathlib_path_types = ("pathlib.Path", "pathlib.PosixPath", "pathlib.WindowsPath")
275+
pathlib_path_constructors = pathlib_path_types + tuple(
276+
f"{path_type}.{method}"
277+
for path_type in pathlib_path_types
278+
for method in ("cwd", "home")
279+
)
280+
pathlib_blocking_methods = (
281+
"open",
282+
"read_bytes",
283+
"read_text",
284+
"touch",
285+
"write_bytes",
286+
"write_text",
287+
)
288+
289+
def __init__(self, *args: Any, **kwargs: Any):
290+
super().__init__(*args, **kwargs)
291+
self.pathlib_variables: set[str] = set()
292+
293+
def _is_pathlib_annotation(self, node: ast.AST | None) -> bool:
294+
def or_none(node: ast.AST | None):
295+
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.BitOr):
296+
return None
297+
if isinstance(node.left, ast.Constant) and node.left.value is None:
298+
return node.right
299+
if isinstance(node.right, ast.Constant) and node.right.value is None:
300+
return node.left
301+
return None
302+
303+
if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name):
304+
if node.value.id == "Optional":
305+
node = node.slice
306+
elif res := or_none(node):
307+
node = res
308+
309+
return (
310+
isinstance(node, (ast.Name, ast.Attribute))
311+
and self.canonical_name(node) in self.pathlib_path_types
312+
)
313+
314+
def visit_AsyncFunctionDef(
315+
self, node: ast.AsyncFunctionDef | ast.FunctionDef | ast.Lambda
316+
):
317+
self.save_state(node, "async_function", "pathlib_variables", copy=True)
318+
self.async_function = isinstance(node, ast.AsyncFunctionDef)
319+
320+
args = node.args
321+
for arg in *args.args, *args.posonlyargs, *args.kwonlyargs:
322+
if self._is_pathlib_annotation(arg.annotation):
323+
self.pathlib_variables.add(arg.arg)
324+
325+
visit_FunctionDef = visit_AsyncFunctionDef
326+
visit_Lambda = visit_AsyncFunctionDef
327+
328+
def visit_ClassDef(self, node: ast.ClassDef):
329+
self.save_state(node, "pathlib_variables", copy=True)
330+
331+
def visit_AnnAssign(self, node: ast.AnnAssign):
332+
if isinstance(node.target, ast.Name) and self._is_pathlib_annotation(
333+
node.annotation
334+
):
335+
self.pathlib_variables.add(node.target.id)
336+
337+
def visit_Assign(self, node: ast.Assign):
338+
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
339+
return
340+
if self._is_pathlib_path_expr(node.value) or (
341+
isinstance(node.value, ast.Name) and node.value.id in self.pathlib_variables
342+
):
343+
self.pathlib_variables.add(node.targets[0].id)
344+
265345
def visit_Call(self, node: ast.Call):
266346
canonical = self.canonical_name(node.func)
267347
if canonical in ("trio.wrap_file", "anyio.wrap_file") and len(node.args) == 1:
268348
setattr(node.args[0], "wrapped", True) # noqa: B010
269349
super().visit_Call(node)
270350

351+
def _is_pathlib_path_expr(self, node: ast.expr) -> bool:
352+
if isinstance(node, ast.Name):
353+
return node.id in self.pathlib_variables
354+
if isinstance(node, ast.Call):
355+
return self.canonical_name(node.func) in self.pathlib_path_constructors
356+
return False
357+
358+
def _is_pathlib_blocking_call(self, node: ast.Call) -> bool:
359+
return (
360+
isinstance(node.func, ast.Attribute)
361+
and node.func.attr in self.pathlib_blocking_methods
362+
and self._is_pathlib_path_expr(node.func.value)
363+
)
364+
271365
def visit_blocking_call(self, node: ast.Call):
272366
if getattr(node, "wrapped", False):
273367
return
@@ -277,6 +371,8 @@ def visit_blocking_call(self, node: ast.Call):
277371
error_code = "ASYNC230"
278372
elif canonical == "os.fdopen":
279373
error_code = "ASYNC231"
374+
elif self._is_pathlib_blocking_call(node):
375+
error_code = "ASYNC233"
280376
else:
281377
return
282378
if self.library == ("asyncio",):

tests/eval_files/async233.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# ARG --enable=ASYNC233
2+
# NOASYNCIO # see async233_asyncio.py
3+
import pathlib
4+
from pathlib import Path
5+
from pathlib import PosixPath as UnixPath
6+
7+
import trio
8+
9+
10+
async def foo(path: Path, other_path: pathlib.Path):
11+
path.open() # ASYNC233: 4, 'path.open', "trio"
12+
path.read_text() # ASYNC233: 4, 'path.read_text', "trio"
13+
path.read_bytes() # ASYNC233: 4, 'path.read_bytes', "trio"
14+
path.write_text("content") # ASYNC233: 4, 'path.write_text', "trio"
15+
path.write_bytes(b"content") # ASYNC233: 4, 'path.write_bytes', "trio"
16+
path.touch() # ASYNC233: 4, 'path.touch', "trio"
17+
18+
other_path.read_text() # ASYNC233: 4, 'other_path.read_text', "trio"
19+
Path("foo").read_text() # ASYNC233: 4, "Path('foo').read_text", "trio"
20+
pathlib.Path("foo").read_bytes() # ASYNC233: 4, "pathlib.Path('foo').read_bytes", "trio"
21+
pathlib.Path.cwd().write_text("content") # ASYNC233: 4, 'pathlib.Path.cwd().write_text', "trio"
22+
UnixPath("foo").touch() # ASYNC233: 4, "UnixPath('foo').touch", "trio"
23+
24+
assigned = Path("foo")
25+
assigned.write_bytes(b"content") # ASYNC233: 4, 'assigned.write_bytes', "trio"
26+
27+
await path.read_text()
28+
await trio.wrap_file(path.open())
29+
path.with_suffix(".txt")
30+
31+
32+
def foo_sync(path: Path):
33+
path.read_text()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# ARG --enable=ASYNC233
2+
# NOTRIO # see async233.py
3+
# NOANYIO # see async233.py
4+
# BASE_LIBRARY asyncio
5+
from pathlib import Path
6+
7+
8+
async def foo(path: Path):
9+
path.read_text() # ASYNC233_asyncio: 4, 'path.read_text'
10+
Path("foo").write_text("content") # ASYNC233_asyncio: 4, "Path('foo').write_text"
11+
12+
13+
def foo_sync(path: Path):
14+
path.read_text()

0 commit comments

Comments
 (0)