Skip to content

Commit 2e35771

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix_release_stuff
2 parents 25010ac + ea58c7a commit 2e35771

18 files changed

+450
-38
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ ci:
99

1010
repos:
1111
- repo: https://github.com/astral-sh/ruff-pre-commit
12-
rev: v0.7.3
12+
rev: v0.7.4
1313
hooks:
1414
- id: ruff
1515
args: [--fix]
@@ -45,7 +45,7 @@ repos:
4545
exclude: tests/eval_files/.*_py311.py
4646

4747
- repo: https://github.com/RobertCraigie/pyright-python
48-
rev: v1.1.388
48+
rev: v1.1.389
4949
hooks:
5050
- id: pyright
5151
# ignore warnings about new version being available, no other warnings

docs/changelog.rst

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,32 @@ Changelog
44

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

7-
Future
8-
======
9-
- :ref:`ASYNC101 <async101>` and :ref:`ASYNC119 <async119>` are now silenced for decorators in :ref:`transform-async-generator-decorators`
7+
24.11.4
8+
=======
9+
- :ref:`ASYNC100 <async100>` once again ignores :func:`trio.open_nursery` and :func:`anyio.create_task_group`, unless we find a call to ``.start_soon()``.
10+
11+
24.11.3
12+
=======
13+
- Revert :ref:`ASYNC100 <async100>` ignoring :func:`trio.open_nursery` and :func:`anyio.create_task_group` due to it not viewing ``.start_soon()`` as introducing a :ref:`cancel point <cancel_point>`.
14+
15+
24.11.2
16+
=======
17+
- Fix crash in ``Visitor91x`` on ``async with a().b():``.
18+
19+
24.11.1
20+
=======
21+
- :ref:`ASYNC100 <async100>` now ignores :func:`trio.open_nursery` and :func:`anyio.create_task_group`
22+
as cancellation sources, because they are :ref:`schedule points <schedule_point>` but not
23+
:ref:`cancellation points <cancel_point>`.
24+
- :ref:`ASYNC101 <async101>` and :ref:`ASYNC119 <async119>` are now silenced for decorators in :ref:`transform-async-generator-decorators`.
1025

1126
24.10.2
1227
=======
1328
- :ref:`ASYNC102 <async102>` now also warns about ``await()`` inside ``__aexit__``.
1429

1530
24.10.1
1631
=======
17-
- Add :ref:`ASYNC123 <async123>` bad-exception-group-flattening
32+
- Add :ref:`ASYNC123 <async123>` bad-exception-group-flattening.
1833

1934
24.9.5
2035
======

docs/glossary.rst

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ Exception classes:
8888

8989
Checkpoint
9090
----------
91-
Checkpoints are points where the async backend checks for cancellation and
92-
can switch which task is running, in an ``await``, ``async for``, or ``async with``
91+
Checkpoints are points where the async backend checks for :ref:`cancellation <cancel_point>` and
92+
:ref:`can switch which task is running <schedule_point>`, in an ``await``, ``async for``, or ``async with``
9393
expression. Regular checkpoints can be important for both performance and correctness.
9494

9595
Trio has extensive and detailed documentation on the concept of
@@ -99,11 +99,11 @@ functions defined by Trio will either checkpoint or raise an exception when
9999
iteration, and when exhausting the iterator, and ``async with`` will checkpoint
100100
on at least one of enter/exit.
101101

102+
The one exception is :func:`trio.open_nursery` and :func:`anyio.create_task_group`. They do not checkpoint on entry, and on exit they insert a :ref:`schedule point <schedule_point>`. However, if sub-tasks are cancelled they will be propagated on exit, so if you're starting tasks you can usually treat the exit as a :ref:`cancel point <cancel_point>`.
103+
102104
asyncio does not place any guarantees on if or when asyncio functions will
103105
checkpoint. This means that enabling and adhering to :ref:`ASYNC91x <ASYNC910>`
104-
will still not guarantee checkpoints.
105-
106-
For anyio it will depend on the current backend.
106+
will still not guarantee checkpoints on asyncio (even if used via anyio).
107107

108108
When using Trio (or an AnyIO library that people might use on Trio), it can be
109109
very helpful to ensure that your own code adheres to the same guarantees as
@@ -116,6 +116,33 @@ To insert a checkpoint with no other side effects, you can use
116116
:func:`trio.lowlevel.checkpoint`/:func:`anyio.lowlevel.checkpoint`/:func:`asyncio.sleep(0)
117117
<asyncio.sleep>`
118118

119+
.. _schedule_point:
120+
121+
Schedule Point
122+
--------------
123+
A schedule point is half of a full :ref:`checkpoint`, which allows the async backend to switch the running task, but doesn't check for cancellation (the other half is a :ref:`cancel_point`).
124+
While you are unlikely to need one, they are available as :func:`trio.lowlevel.cancel_shielded_checkpoint`/:func:`anyio.lowlevel.cancel_shielded_checkpoint`, and equivalent to
125+
126+
.. code-block:: python
127+
128+
from trio import CancelScope, lowlevel
129+
# or
130+
# from anyio import CancelScope, lowlevel
131+
132+
with CancelScope(shield=True):
133+
await lowlevel.checkpoint()
134+
135+
asyncio does not have any direct equivalents due to their cancellation model being different.
136+
137+
138+
.. _cancel_point:
139+
140+
Cancel Point
141+
------------
142+
A schedule point is half of a full :ref:`checkpoint`, which will raise :ref:`cancelled` if the enclosing cancel scope has been cancelled, but does not allow the scheduler to switch to a different task (the other half is a :ref:`schedule_point`).
143+
While you are unlikely to need one, they are available as :func:`trio.lowlevel.checkpoint_if_cancelled`/:func:`anyio.lowlevel.checkpoint_if_cancelled`.
144+
Users of asyncio might want to use :meth:`asyncio.Task.cancelled`.
145+
119146
.. _channel_stream_queue:
120147

121148
Channel / Stream / Queue

docs/rules.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ _`ASYNC100` : cancel-scope-no-checkpoint
1313
A :ref:`timeout_context` does not contain any :ref:`checkpoints <checkpoint>`.
1414
This makes it pointless, as the timeout can only be triggered by a checkpoint.
1515
This check also treats ``yield`` as a checkpoint, since checkpoints can happen in the caller we yield to.
16+
:func:`trio.open_nursery` and :func:`anyio.create_task_group` are excluded, as they are :ref:`schedule points <schedule_point>` but not :ref:`cancel points <cancel_point>` (unless they have tasks started in them).
1617
See :ref:`ASYNC912 <async912>` which will in addition guarantee checkpoints on every code path.
1718

1819
_`ASYNC101` : yield-in-cancel-scope

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: 24.10.2
36+
rev: 24.11.4
3737
hooks:
3838
- id: flake8-async
3939
# args: [--enable=ASYNC, --disable=ASYNC9, --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__ = "24.10.2"
41+
__version__ = "24.11.4"
4242

4343

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

flake8_async/visitors/helpers.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -337,15 +337,20 @@ def build_cst_matcher(attr: str) -> m.BaseExpression:
337337
return m.Attribute(value=build_cst_matcher(body), attr=m.Name(value=tail))
338338

339339

340-
def identifier_to_string(attr: cst.Name | cst.Attribute) -> str | None:
341-
if isinstance(attr, cst.Name):
342-
return attr.value
343-
if not isinstance(attr.value, (cst.Attribute, cst.Name)):
344-
return None
345-
lhs = identifier_to_string(attr.value)
346-
if lhs is None:
347-
return None
348-
return lhs + "." + attr.attr.value
340+
def identifier_to_string(node: cst.CSTNode) -> str | None:
341+
"""Convert a simple identifier to a string.
342+
343+
If the node is composed of anything but cst.Name + cst.Attribute it returns None.
344+
"""
345+
if isinstance(node, cst.Name):
346+
return node.value
347+
if (
348+
isinstance(node, cst.Attribute)
349+
and (lhs := identifier_to_string(node.value)) is not None
350+
):
351+
return lhs + "." + node.attr.value
352+
353+
return None
349354

350355

351356
def with_has_call(

flake8_async/visitors/visitor91x.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
flatten_preserving_comments,
2626
fnmatch_qualified_name_cst,
2727
func_has_decorator,
28+
identifier_to_string,
2829
iter_guaranteed_once_cst,
2930
with_has_call,
3031
)
@@ -285,6 +286,7 @@ def __init__(self, *args: Any, **kwargs: Any):
285286
# ASYNC100
286287
self.has_checkpoint_stack: list[bool] = []
287288
self.node_dict: dict[cst.With, list[AttributeCall]] = {}
289+
self.taskgroup_has_start_soon: dict[str, bool] = {}
288290

289291
# --exception-suppress-context-manager
290292
self.suppress_imported_as: list[str] = []
@@ -299,13 +301,31 @@ def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
299301
and self.library != ("asyncio",)
300302
)
301303

302-
def checkpoint(self) -> None:
303-
self.uncheckpointed_statements = set()
304+
def checkpoint_cancel_point(self) -> None:
304305
self.has_checkpoint_stack = [True] * len(self.has_checkpoint_stack)
306+
# don't need to look for any .start_soon() calls
307+
self.taskgroup_has_start_soon.clear()
308+
309+
def checkpoint_schedule_point(self) -> None:
310+
self.uncheckpointed_statements = set()
311+
312+
def checkpoint(self) -> None:
313+
self.checkpoint_schedule_point()
314+
self.checkpoint_cancel_point()
305315

306316
def checkpoint_statement(self) -> cst.SimpleStatementLine:
307317
return checkpoint_statement(self.library[0])
308318

319+
def visit_Call(self, node: cst.Call) -> None:
320+
# [Nursery/TaskGroup].start_soon introduces a cancel point
321+
if (
322+
isinstance(node.func, cst.Attribute)
323+
and isinstance(node.func.value, cst.Name)
324+
and node.func.attr.value == "start_soon"
325+
and node.func.value.value in self.taskgroup_has_start_soon
326+
):
327+
self.taskgroup_has_start_soon[node.func.value.value] = True
328+
309329
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
310330
# Semi-crude approach to handle `from contextlib import suppress`.
311331
# It does not handle the identifier being overridden, or assigned
@@ -341,16 +361,21 @@ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
341361
"safe_decorator",
342362
"async_function",
343363
"uncheckpointed_statements",
364+
# comp_unknown does not need to be saved
344365
"loop_state",
345366
"try_state",
346367
"has_checkpoint_stack",
347-
"suppress_imported_as",
368+
# node_dict is cleaned up and don't need to be saved
369+
"taskgroup_has_start_soon",
370+
"suppress_imported_as", # a copy is saved, but state is not reset
348371
copy=True,
349372
)
350-
self.uncheckpointed_statements = set()
351-
self.has_checkpoint_stack = []
352373
self.has_yield = self.safe_decorator = False
374+
self.uncheckpointed_statements = set()
353375
self.loop_state = LoopState()
376+
# try_state is reset upon entering try
377+
self.has_checkpoint_stack = []
378+
self.taskgroup_has_start_soon = {}
354379

355380
self.async_function = (
356381
node.asynchronous is not None
@@ -440,8 +465,8 @@ def leave_Return(
440465
):
441466
self.add_statement = self.checkpoint_statement()
442467
# avoid duplicate error messages
443-
self.uncheckpointed_statements = set()
444-
# we don't treat it as a checkpoint for ASYNC100
468+
# but don't see it as a cancel point for ASYNC100
469+
self.checkpoint_schedule_point()
445470

446471
# return original node to avoid problems with identity equality
447472
assert original_node.deep_equals(updated_node)
@@ -491,12 +516,47 @@ def _is_exception_suppressing_context_manager(self, node: cst.With) -> bool:
491516
is not None
492517
)
493518

519+
def _checkpoint_with(self, node: cst.With, entry: bool):
520+
"""Conditionally checkpoints entry/exit of With.
521+
522+
If the `with` only contains calls to open_nursery/create_task_group, it's a
523+
schedule point but not a cancellation point, so we treat it as a checkpoint
524+
for async91x but not for async100.
525+
526+
Saves the name of the taskgroup/nursery if entry is set
527+
"""
528+
if not getattr(node, "asynchronous", None):
529+
return
530+
531+
for item in node.items:
532+
if isinstance(item.item, cst.Call) and identifier_to_string(
533+
item.item.func
534+
) in (
535+
"trio.open_nursery",
536+
"anyio.create_task_group",
537+
):
538+
if item.asname is not None and isinstance(item.asname.name, cst.Name):
539+
# save the nursery/taskgroup to see if it has a `.start_soon`
540+
if entry:
541+
self.taskgroup_has_start_soon[item.asname.name.value] = False
542+
elif self.taskgroup_has_start_soon.pop(
543+
item.asname.name.value, False
544+
):
545+
self.checkpoint()
546+
return
547+
else:
548+
self.checkpoint()
549+
break
550+
else:
551+
# only taskgroup/nursery calls
552+
self.checkpoint_schedule_point()
553+
494554
# Async context managers can reasonably checkpoint on either or both of entry and
495555
# exit. Given that we can't tell which, we assume "both" to avoid raising a
496556
# missing-checkpoint warning when there might in fact be one (i.e. a false alarm).
497557
def visit_With_body(self, node: cst.With):
498-
if getattr(node, "asynchronous", None):
499-
self.checkpoint()
558+
self.save_state(node, "taskgroup_has_start_soon", copy=True)
559+
self._checkpoint_with(node, entry=True)
500560

501561
# if this might suppress exceptions, we cannot treat anything inside it as
502562
# checkpointing.
@@ -548,15 +608,16 @@ def leave_With(self, original_node: cst.With, updated_node: cst.With):
548608
for res in self.node_dict[original_node]:
549609
self.error(res.node, error_code="ASYNC912")
550610

611+
self.node_dict.pop(original_node, None)
612+
551613
# if exception-suppressing, restore all uncheckpointed statements from
552614
# before the `with`.
553615
if self._is_exception_suppressing_context_manager(original_node):
554616
prev_checkpoints = self.uncheckpointed_statements
555617
self.restore_state(original_node)
556618
self.uncheckpointed_statements.update(prev_checkpoints)
557619

558-
if getattr(original_node, "asynchronous", None):
559-
self.checkpoint()
620+
self._checkpoint_with(original_node, entry=False)
560621
return updated_node
561622

562623
# error if no checkpoint since earlier yield or function entry
@@ -569,7 +630,7 @@ def leave_Yield(
569630

570631
# Treat as a checkpoint for ASYNC100, since the context we yield to
571632
# may checkpoint.
572-
self.has_checkpoint_stack = [True] * len(self.has_checkpoint_stack)
633+
self.checkpoint_cancel_point()
573634

574635
if self.check_function_exit(original_node) and self.should_autofix(
575636
original_node

flake8_async/visitors/visitors.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import ast
66
from typing import TYPE_CHECKING, Any, cast
77

8-
import libcst as cst
9-
108
from .flake8asyncvisitor import Flake8AsyncVisitor, Flake8AsyncVisitor_cst
119
from .helpers import (
1210
disabled_by_default,
@@ -20,6 +18,8 @@
2018
if TYPE_CHECKING:
2119
from collections.abc import Mapping
2220

21+
import libcst as cst
22+
2323
LIBRARIES = ("trio", "anyio", "asyncio")
2424

2525

@@ -460,8 +460,7 @@ def visit_CompIf(self, node: cst.CSTNode):
460460

461461
def visit_Call(self, node: cst.Call):
462462
if (
463-
isinstance(node.func, (cst.Name, cst.Attribute))
464-
and identifier_to_string(node.func) == "asyncio.create_task"
463+
identifier_to_string(node.func) == "asyncio.create_task"
465464
and not self.safe_to_create_task
466465
):
467466
self.error(node)

tests/autofix_files/async100.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,13 @@ async def fn(timeout):
130130
if condition():
131131
return
132132
await trio.sleep(1)
133+
134+
135+
async def dont_crash_on_non_name_or_attr_call():
136+
async with contextlib.asynccontextmanager(agen_fn)():
137+
...
138+
139+
140+
async def another_weird_with_call():
141+
async with a().b():
142+
...

0 commit comments

Comments
 (0)