Skip to content

Commit

Permalink
add asyncio/anyio taskgroup support to async111&112 (#241)
Browse files Browse the repository at this point in the history
* add asyncio/anyio taskgroup support to async111, update contributing.md

* add asyncio/anyio taskgroup support to async112
  • Loading branch information
jakkdl authored May 3, 2024
1 parent 504b636 commit 126eab4
Show file tree
Hide file tree
Showing 9 changed files with 148 additions and 21 deletions.
14 changes: 9 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@ You can instead of `error` specify the error code.
With `# ARG` lines you can also specify command-line arguments that should be passed to the plugin when parsing a file. Can be specified multiple times for several different arguments.
Generated tests will by default `--select` the error code of the file, which will enable any visitors that can generate that code (and if those visitors can raise other codes they might be raised too). This can be overridden by adding an `# ARG --select=...` line.

### `# NOANYIO` and `# NOTRIO`
Eval files are also evaluated where ~all instances of "trio" is replaced with "anyio" and `--anyio` is prepended to the argument list, to check for compatibility with the [anyio](https://github.com/agronholm/anyio) library - unless they are marked with `# NOANYIO`.
If a file is marked with `# NOTRIO` it will not replace instances of "trio" with "anyio", and the file will not be run by the normal `eval_files` test generator.
#### `# ANYIO_NO_ERROR`
A file which is marked with this will ignore all `# error` or `# TRIO...` comments when running with anyio. Use when an error is trio-specific and replacing "trio" with "anyio" should silence all errors.
### Library parametrization
Eval files are evaluated with each supported library. It does this by replacing all instances of the `BASE_LIBRARY` ("trio" by default) with the two other libraries, and setting the corresponding flag (`--anyio` or `--asyncio`).
### `# BASE_LIBRARY anyio` / `# BASE_LIBRARY asyncio`
Defaults to `trio`. Used to specify the primary library an eval file is testing.

#### `# ANYIO_NO_ERROR`, `# TRIO_NO_ERROR`, `# ASYNCIO_NO_ERROR`
A file which is marked with this will ignore all `# error` or `# TRIO...` comments when running with anyio. Use when an error is library-specific and replacing all instances means the file should no longer raise any errors.
### `# NOANYIO`, `# NOTRIO`, `#NOASYNCIO`
Disables checking a file with the specified library. Should be used somewhat sparingly, and always have a comment motivating its use.

## Running pytest outside tox
If you don't want to bother with tox to quickly test stuff, you'll need to install the following dependencies:
Expand Down
17 changes: 12 additions & 5 deletions flake8_async/visitors/visitor111.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@
from collections.abc import Mapping


def is_nursery_like(node: ast.expr) -> bool:
return bool(
get_matching_call(node, "open_nursery", base="trio")
or get_matching_call(node, "create_task_group", base="anyio")
or get_matching_call(node, "TaskGroup", base="asyncio")
)


@error_class
class Visitor111(Flake8AsyncVisitor):
error_codes: Mapping[str, str] = {
"ASYNC111": (
"variable {2} is usable within the context manager on line {0}, but that "
"will close before nursery opened on line {1} - this is usually a bug. "
"Nurseries should generally be the inner-most context manager."
"will close before nursery/taskgroup opened on line {1} - this is usually "
"a bug. Nursery/TaskGroup should generally be the inner-most context manager."
),
}

Expand Down Expand Up @@ -48,8 +56,7 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
self.TrioContextManager(
item.context_expr.lineno,
item.optional_vars.id,
get_matching_call(item.context_expr, "open_nursery")
is not None,
is_nursery_like(item.context_expr),
)
)

Expand All @@ -75,7 +82,7 @@ def visit_Call(self, node: ast.Call):
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.attr in ("start", "start_soon")
and node.func.attr in ("start", "start_soon", "create_task")
):
self._nursery_call = None
for i, cm in enumerate(self._context_managers):
Expand Down
24 changes: 18 additions & 6 deletions flake8_async/visitors/visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,29 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
continue
var_name = item.optional_vars.id

# check for trio.open_nursery
# check for trio.open_nursery and anyio.create_task_group
nursery = get_matching_call(
item.context_expr, "open_nursery", base=("trio",)
)
item.context_expr, "open_nursery", base="trio"
) or get_matching_call(item.context_expr, "create_task_group", base="anyio")
start_methods: tuple[str, ...] = ("start", "start_soon")
if nursery is None:
# check for asyncio.TaskGroup
nursery = get_matching_call(
item.context_expr, "TaskGroup", base="asyncio"
)
if nursery is None:
continue
start_methods = ("create_task",)

body_call = node.body[0].value
if isinstance(body_call, ast.Await):
body_call = body_call.value

# `isinstance(..., ast.Call)` is done in get_matching_call
body_call = cast("ast.Call", node.body[0].value)
body_call = cast("ast.Call", body_call)

if (
nursery is not None
and get_matching_call(body_call, "start", "start_soon", base=var_name)
get_matching_call(body_call, *start_methods, base=var_name)
# check for presence of <X> as parameter
and not any(
(isinstance(n, ast.Name) and n.id == var_name)
Expand Down
10 changes: 9 additions & 1 deletion tests/eval_files/async111.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# type: ignore
# ASYNC111: Variable, from context manager opened inside nursery, passed to start[_soon] might be invalidly accessed while in use, due to context manager closing before the nursery. This is usually a bug, and nurseries should generally be the inner-most context manager.
# It's possible there's an equivalent asyncio construction/gotcha, but methods are differently named, so this file will not raise any errors
# ASYNCIO_NO_ERROR # no nurseries in asyncio. Though maybe the bug is relevant for TaskGroups
# nurseries are named taskgroups in asyncio/anyio
# ASYNCIO_NO_ERROR
# ANYIO_NO_ERROR
from typing import Any

import trio
Expand Down Expand Up @@ -242,3 +244,9 @@ def myfun(nursery, bar):
nursery.start(f)

# fmt: on

# visitor does not care to keep track of the type of nursery/taskgroup, so we
# raise errors on .create_task() even if it doesn't exist in trio.
with trio.open_nursery() as nursery:
with open("") as f:
nursery.create_task(f) # error: 28, line-1, line-2, "f", "create_task"
21 changes: 21 additions & 0 deletions tests/eval_files/async111_anyio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# main tests in async111.py
# this only tests anyio.create_task_group in particular
# BASE_LIBRARY anyio
# ASYNCIO_NO_ERROR
# TRIO_NO_ERROR


import anyio


async def bar(*args): ...


async def foo():
async with anyio.create_task_group() as tg:
with open("") as f:
await tg.start(bar, f) # error: 32, lineno-1, lineno-2, "f", "start"
tg.start_soon(bar, f) # error: 31, lineno-2, lineno-3, "f", "start_soon"

# create_task does not exist in anyio, but gets errors anyway
tg.create_task(bar(f)) # type: ignore[attr-defined] # error: 31, lineno-5, lineno-6, "f", "create_task"
23 changes: 23 additions & 0 deletions tests/eval_files/async111_asyncio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# main tests in async111.py
# this only tests asyncio.TaskGroup in particular
# BASE_LIBRARY asyncio
# ANYIO_NO_ERROR
# TRIO_NO_ERROR
# TaskGroup introduced in 3.11, we run typechecks with 3.9
# mypy: disable-error-code=attr-defined


import asyncio


async def bar(*args): ...


async def foo():
async with asyncio.TaskGroup() as tg:
with open("") as f:
tg.create_task(bar(f)) # error: 31, lineno-1, lineno-2, "f", "create_task"

# start[_soon] does not exist in asyncio, but gets errors anyway
await tg.start(bar, f) # error: 32, lineno-4, lineno-5, "f", "start"
tg.start_soon(bar, f) # error: 31, lineno-5, lineno-6, "f", "start_soon"
8 changes: 4 additions & 4 deletions tests/eval_files/async112.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# type: ignore
# ASYNC112: Nursery body with only a call to nursery.start[_soon] and not passing itself as a parameter can be replaced with a regular function call.
# ASYNCIO_NO_ERROR - # TODO: expand check to work with asyncio.TaskGroup
# ANYIO_NO_ERROR - # TODO: expand check to work with anyio.TaskGroup
# ASYNCIO_NO_ERROR
# ANYIO_NO_ERROR
import functools
from functools import partial

Expand Down Expand Up @@ -81,9 +81,9 @@ async def foo():
n.start_soon(lambda n: n + 1)


# body isn't a call to n.start
# body is a call to await n.start
async def foo_1():
with trio.open_nursery(...) as n:
with trio.open_nursery(...) as n: # error: 9, "n"
await n.start(...)


Expand Down
28 changes: 28 additions & 0 deletions tests/eval_files/async112_anyio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# main tests in async112.py
# this only tests anyio.create_task_group in particular
# BASE_LIBRARY anyio
# ASYNCIO_NO_ERROR
# TRIO_NO_ERROR

import anyio


async def bar(*args): ...


async def foo():
async with anyio.create_task_group() as tg: # error: 15, "tg"
await tg.start_soon(bar())

async with anyio.create_task_group() as tg:
await tg.start(bar(tg))

async with anyio.create_task_group() as tg: # error: 15, "tg"
tg.start_soon(bar())

async with anyio.create_task_group() as tg:
tg.start_soon(bar(tg))

# will not trigger on create_task
async with anyio.create_task_group() as tg:
tg.create_task(bar()) # type: ignore[attr-defined]
24 changes: 24 additions & 0 deletions tests/eval_files/async112_asyncio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# main tests in async112.py
# this only tests asyncio.TaskGroup in particular
# BASE_LIBRARY asyncio
# ANYIO_NO_ERROR
# TRIO_NO_ERROR
# TaskGroup introduced in 3.11, we run typechecks with 3.9
# mypy: disable-error-code=attr-defined

import asyncio


async def bar(*args): ...


async def foo():
async with asyncio.TaskGroup() as tg: # error: 15, "tg"
tg.create_task(bar())

async with asyncio.TaskGroup() as tg:
tg.create_task(bar(tg))

# will not trigger on start / start_soon
async with asyncio.TaskGroup() as tg:
tg.start(bar())

0 comments on commit 126eab4

Please sign in to comment.