Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ repos:
hooks:
- id: mypy
# uses py311 syntax, mypy configured for py39
exclude: tests/eval_files/.*_py311.py
exclude: tests/(eval|autofix)_files/.*_py311.py

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.396
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Changelog

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

25.3.1
======
- Add except* support to ASYNC102, 103, 104, 120, 910, 911, 912.

25.2.3
=======
- No longer require ``flake8`` for installation... so if you require support for config files you must install ``flake8-async[flake8]``
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``:
minimum_pre_commit_version: '2.9.0'
repos:
- repo: https://github.com/python-trio/flake8-async
rev: 25.2.3
rev: 25.3.1
hooks:
- id: flake8-async
# args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"]
Expand Down
2 changes: 1 addition & 1 deletion flake8_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@


# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
__version__ = "25.2.3"
__version__ = "25.3.1"


# taken from https://github.com/Zac-HD/shed
Expand Down
4 changes: 3 additions & 1 deletion flake8_async/visitors/visitor102_120.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def visit_AsyncWith(self, node: ast.AsyncWith):
break
self.visit_With(node)

def visit_Try(self, node: ast.Try):
def visit_Try(self, node: ast.Try | ast.TryStar): # type: ignore[name-defined]
self.save_state(
node, "_critical_scope", "_trio_context_managers", "cancelled_caught"
)
Expand All @@ -165,6 +165,8 @@ def visit_Try(self, node: ast.Try):
self._critical_scope = Statement("try/finally", node.lineno, node.col_offset)
self.visit_nodes(node.finalbody)

visit_TryStar = visit_Try

def visit_ExceptHandler(self, node: ast.ExceptHandler):
# if we're inside a critical scope, a nested except should never override that
if self._critical_scope is not None and self._critical_scope.name != "except":
Expand Down
4 changes: 3 additions & 1 deletion flake8_async/visitors/visitor103_104.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def visit_Return(self, node: ast.Return | ast.Yield):
visit_Yield = visit_Return

# Treat Try's as fully covering only if `finally` always raises.
def visit_Try(self, node: ast.Try):
def visit_Try(self, node: ast.Try | ast.TryStar): # type: ignore[name-defined]
self.save_state(node, "cancelled_caught", copy=True)
self.cancelled_caught = set()

Expand All @@ -179,6 +179,8 @@ def visit_Try(self, node: ast.Try):
# but it's fine if we raise in finally
self.visit_nodes(node.finalbody)

visit_TryStar = visit_Try

# Treat if's as fully covering if both `if` and `else` raise.
# `elif` is parsed by the ast as a new if statement inside the else.
def visit_If(self, node: ast.If):
Expand Down
34 changes: 24 additions & 10 deletions flake8_async/visitors/visitor91x.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ def leave_Yield(
# try can jump into any except or into the finally* at any point during it's
# execution so we need to make sure except & finally can handle worst-case
# * unless there's a bare except / except BaseException - not implemented.
def visit_Try(self, node: cst.Try):
def visit_Try(self, node: cst.Try | cst.TryStar):
if not self.async_function:
return
self.save_state(node, "try_state", copy=True)
Expand All @@ -784,39 +784,41 @@ def visit_Try(self, node: cst.Try):
Statement("yield", pos.line, pos.column) # type: ignore
)

def leave_Try_body(self, node: cst.Try):
def leave_Try_body(self, node: cst.Try | cst.TryStar):
# save state at end of try for entering else
self.try_state.try_checkpoint = self.uncheckpointed_statements

# check that all except handlers checkpoint (await or most likely raise)
self.try_state.except_uncheckpointed_statements = set()

def visit_ExceptHandler(self, node: cst.ExceptHandler):
def visit_ExceptHandler(self, node: cst.ExceptHandler | cst.ExceptStarHandler):
# enter with worst case of try
self.uncheckpointed_statements = (
self.try_state.body_uncheckpointed_statements.copy()
)

def leave_ExceptHandler(
self, original_node: cst.ExceptHandler, updated_node: cst.ExceptHandler
) -> cst.ExceptHandler:
self,
original_node: cst.ExceptHandler | cst.ExceptStarHandler,
updated_node: cst.ExceptHandler | cst.ExceptStarHandler,
) -> Any: # not worth creating a TypeVar to handle correctly
self.try_state.except_uncheckpointed_statements.update(
self.uncheckpointed_statements
)
return updated_node

def visit_Try_orelse(self, node: cst.Try):
def visit_Try_orelse(self, node: cst.Try | cst.TryStar):
# check else
# if else runs it's after all of try, so restore state to back then
self.uncheckpointed_statements = self.try_state.try_checkpoint

def leave_Try_orelse(self, node: cst.Try):
def leave_Try_orelse(self, node: cst.Try | cst.TryStar):
# checkpoint if else checkpoints, and all excepts checkpoint
self.uncheckpointed_statements.update(
self.try_state.except_uncheckpointed_statements
)

def visit_Try_finalbody(self, node: cst.Try):
def visit_Try_finalbody(self, node: cst.Try | cst.TryStar):
if node.finalbody:
self.try_state.added = (
self.try_state.body_uncheckpointed_statements.difference(
Expand All @@ -835,14 +837,26 @@ def visit_Try_finalbody(self, node: cst.Try):
):
self.uncheckpointed_statements.update(self.try_state.added)

def leave_Try_finalbody(self, node: cst.Try):
def leave_Try_finalbody(self, node: cst.Try | cst.TryStar):
if node.finalbody:
self.uncheckpointed_statements.difference_update(self.try_state.added)

def leave_Try(self, original_node: cst.Try, updated_node: cst.Try) -> cst.Try:
def leave_Try(
self, original_node: cst.Try | cst.TryStar, updated_node: cst.Try | cst.TryStar
) -> cst.Try | cst.TryStar:
self.restore_state(original_node)
return updated_node

visit_TryStar = visit_Try
leave_TryStar = leave_Try
leave_TryStar_body = leave_Try_body
visit_TryStar_orelse = visit_Try_orelse
leave_TryStar_orelse = leave_Try_orelse
visit_TryStar_finalbody = visit_Try_finalbody
leave_TryStar_finalbody = leave_Try_finalbody
visit_ExceptStarHandler = visit_ExceptHandler
leave_ExceptStarHandler = leave_ExceptHandler

def leave_If_test(self, node: cst.If | cst.IfExp) -> None:
if not self.async_function:
return
Expand Down
97 changes: 97 additions & 0 deletions tests/autofix_files/async91x_py311.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Test for ASYNC91x rules with except* blocks.

ASYNC910: async-function-no-checkpoint
ASYNC911: async-generator-no-checkpoint
ASYNC913: indefinite-loop-no-guaranteed-checkpoint

async912 handled in separate file
"""

# ARG --enable=ASYNC910,ASYNC911,ASYNC913
# AUTOFIX
# ASYNCIO_NO_AUTOFIX
import trio


async def foo(): ...


async def foo_try_except_star_1(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
try:
await foo()
except* ValueError:
...
except* RuntimeError:
raise
else:
await foo()
await trio.lowlevel.checkpoint()


async def foo_try_except_star_2(): # safe
try:
...
except* ValueError:
...
finally:
await foo()


async def foo_try_except_star_3(): # safe
try:
await foo()
except* ValueError:
raise


# Multiple except* handlers - should all guarantee checkpoint
async def foo_try_except_star_4():
try:
await foo()
except* ValueError:
raise
except* TypeError:
raise
except* Exception:
raise


async def try_else_no_raise_in_except(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
try:
...
except* ValueError:
...
else:
await foo()
await trio.lowlevel.checkpoint()


async def try_else_raise_in_except():
try:
...
except* ValueError:
raise
else:
await foo()


async def check_async911(): # ASYNC911: 0, "exit", Statement("yield", lineno+7)
try:
await foo()
except* ValueError:
...
except* RuntimeError:
raise
await trio.lowlevel.checkpoint()
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-7)
await trio.lowlevel.checkpoint()


async def check_async913():
while True: # ASYNC913: 4
await trio.lowlevel.checkpoint()
try:
await foo()
except* ValueError:
# Missing checkpoint
...
33 changes: 33 additions & 0 deletions tests/autofix_files/async91x_py311.py.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
+++
@@ x,6 x,7 @@
raise
else:
await foo()
+ await trio.lowlevel.checkpoint()


async def foo_try_except_star_2(): # safe
@@ x,6 x,7 @@
...
else:
await foo()
+ await trio.lowlevel.checkpoint()


async def try_else_raise_in_except():
@@ x,11 x,14 @@
...
except* RuntimeError:
raise
+ await trio.lowlevel.checkpoint()
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-7)
+ await trio.lowlevel.checkpoint()


async def check_async913():
while True: # ASYNC913: 4
+ await trio.lowlevel.checkpoint()
try:
await foo()
except* ValueError:
29 changes: 29 additions & 0 deletions tests/eval_files/async102_120_py311.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Test for ASYNC102/ASYNC120 with except*

ASYNC102: await-in-finally-or-cancelled

ASYNC120: await-in-except
"""

# type: ignore
# ARG --enable=ASYNC102,ASYNC120
# NOASYNCIO # TODO: support asyncio shields
import trio


async def foo():
try:
...
except* ValueError:
await foo() # ASYNC120: 8, Statement("except", lineno-1)
raise
except* BaseException:
await foo() # ASYNC102: 8, Statement("BaseException", lineno-1)
finally:
await foo() # ASYNC102: 8, Statement("try/finally", lineno-8)

try:
...
except* BaseException:
with trio.move_on_after(30, shield=True):
await foo()
40 changes: 40 additions & 0 deletions tests/eval_files/async103_104_py311.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Test for ASYNC103/ASYNC104 with except* blocks.

ASYNC103: no-reraise-cancelled
ASYNC104: cancelled-not-raised
"""

# ARG --enable=ASYNC103,ASYNC104

try:
...
except* BaseException: # ASYNC103_trio: 8, "BaseException"
...

try:
...
except* BaseException:
raise

try:
...
except* ValueError:
...
except* BaseException: # ASYNC103_trio: 8, "BaseException"
...

try:
...
except* BaseException:
raise ValueError # ASYNC104: 4


def foo():
try:
...
except* BaseException: # ASYNC103_trio: 12, "BaseException"
return # ASYNC104: 8
try:
...
except* BaseException:
raise ValueError # ASYNC104: 8
16 changes: 16 additions & 0 deletions tests/eval_files/async912_py311.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# ASYNC912 can't be tested with the other 91x rules since there's no universal
# cancelscope name across trio/asyncio/anyio - so we need ASYNCIO_NO_ERROR


# ASYNCIO_NO_ERROR
async def foo(): ...


async def check_async912():
with trio.move_on_after(30): # ASYNC912: 9
try:
await foo()
except* ValueError:
# Missing checkpoint
...
await foo()
Loading
Loading