8000 TRIO112: replace one-line nursery with regular function call by jakkdl · Pull Request #34 · python-trio/flake8-async · GitHub
[go: up one dir, main page]

Skip to content

TRIO112: replace one-line nursery with regular function call #34

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 10, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog
*[CalVer, YY.month.patch](https://calver.org/)*

## Future
- add TRIO112, 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.

## 22.8.4
- Fix TRIO108 raising errors on yields in some sync code.
- TRIO109 now skips all decorated functions to avoid false alarms
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ pip install flake8-trio
Checkpoints are `await`, `async for`, and `async with` (on one of enter/exit).
- **TRIO109**: Async function definition with a `timeout` parameter - use `trio.[fail/move_on]_[after/at]` instead
- **TRIO110**: `while <condition>: await trio.sleep()` should be replaced by a `trio.Event`.
- **TRIO112**: 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.
146 changes: 94 additions & 52 deletions flake8_trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@

import ast
import tokenize
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Set, Union
from typing import (
Any,
Dict,
Iterable,
List,
NamedTuple,
Optional,
Set,
Tuple,
Union,
cast,
)

# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
__version__ = "22.8.4"
Expand Down Expand Up @@ -44,6 +55,7 @@
"`trio.[fail/move_on]_[after/at]` instead"
),
"TRIO110": "`while <condition>: await trio.sleep()` should be replaced by a `trio.Event`.",
"TRIO112": "Redundant nursery {}, consider replacing with a regular function call",
}


Expand All @@ -63,32 +75,21 @@ def __eq__(self, other: Any) -> bool:
)


HasLineInfo = Union[ast.expr, ast.stmt, ast.arg, ast.excepthandler, Statement]


class TrioScope:
def __init__(self, node: ast.Call, funcname: str):
self.node = node
self.funcname = funcname
self.variable_name: Optional[str] = None
self.shielded: bool = False
self.has_timeout: bool = True
HasLineCol = Union[ast.expr, ast.stmt, ast.arg, ast.excepthandler, Statement]

# scope.shield is assigned to in visit_Assign

if self.funcname == "CancelScope":
self.has_timeout = False
for kw in node.keywords:
# Only accepts constant values
if kw.arg == "shield" and isinstance(kw.value, ast.Constant):
self.shielded = kw.value.value
# sets to True even if timeout is explicitly set to inf
if kw.arg == "deadline":
self.has_timeou 8000 t = True

def __str__(self):
# Not supporting other ways of importing trio, per TRIO106
return f"trio.{self.funcname}"
def get_matching_call(
node: ast.AST, *names: str, base: str = "trio"
) -> Optional[Tuple[ast.Call, str]]:
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == base
and node.func.attr in names
):
return node, node.func.attr
return None


class Error:
Expand Down Expand Up @@ -157,7 +158,7 @@ def visit_nodes(
for node in arg:
visit(node)

def error(self, error: str, node: HasLineInfo, *args: object):
def error(self, error: str, node: HasLineCol, *args: object):
if not self.suppress_errors:
self._problems.append(Error(error, node.lineno, node.col_offset, *args))

Expand All @@ -177,18 +178,6 @@ def walk(self, *body: ast.AST) -> Iterable[ast.AST]:
yield from ast.walk(b)


def get_trio_scope(node: ast.AST, *names: str) -> Optional[TrioScope]:
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "trio"
and node.func.attr in names
):
return TrioScope(node, node.func.attr)
return None


def has_decorator(decorator_list: List[ast.expr], *names: str):
for dec in decorator_list:
if (isinstance(dec, ast.Name) and dec.id in names) or (
Expand All @@ -211,6 +200,7 @@ def __init__(self):
def visit_With(self, node: Union[ast.With, ast.AsyncWith]):
# 100
self.check_for_trio100(node)
self.check_for_trio112(node)

# 101 for rest of function
outer = self.get_state("_yield_is_error")
Expand All @@ -219,7 +209,7 @@ def visit_With(self, node: Union[ast.With, ast.AsyncWith]):
if not self._safe_decorator:
for item in (i.context_expr for i in node.items):
if (
get_trio_scope(item, "open_nursery", *cancel_scope_names)
get_matching_call(item, "open_nursery", *cancel_scope_names)
is not None
):
self._yield_is_error = True
Expand All @@ -234,14 +224,14 @@ def visit_With(self, node: Union[ast.With, ast.AsyncWith]):

# ---- 100 ----
def check_for_trio100(self, node: Union[ast.With, ast.AsyncWith]):
# Context manager with no `await` call within
# Context manager with no `await trio.X` call within
for item in (i.context_expr for i in node.items):
call = get_trio_scope(item, *cancel_scope_names)
call = get_matching_call(item, *cancel_scope_names)
if call and not any(
isinstance(x, checkpoint_node_types) and x != node
for x in ast.walk(node)
):
self.error("TRIO100", item, str(call))
self.error("TRIO100", item, f"trio.{call[1]}")

# ---- 101 ----
def visit_FunctionDef(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]):
Expand All @@ -258,7 +248,7 @@ def visit_FunctionDef(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]):

# ---- 101, 109 ----
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
self.check_109(node)
self.check_for_trio109(node)
self.visit_FunctionDef(node)

# ---- 101 ----
Expand All @@ -269,7 +259,7 @@ def visit_Yield(self, node: ast.Yield):
self.generic_visit(node)

# ---- 109 ----
def check_109(self, node: ast.AsyncFunctionDef):
def check_for_trio109(self, node: ast.AsyncFunctionDef):
if node.decorator_list:
return
args = node.args
Expand All @@ -290,18 +280,48 @@ def visit_Import(self, node: ast.Import):

# ---- 110 ----
def visit_While(self, node: ast.While):
self.check_for_110(node)
self.check_for_trio110(node)
self.generic_visit(node)

def check_for_110(self, node: ast.While):
def check_for_trio110(self, node: ast.While):
if (
len(node.body) == 1
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Await)
and get_trio_scope(node.body[0].value.value, "sleep", "sleep_until")
and get_matching_call(node.body[0].value.value, "sleep", "sleep_until")
):
self.error("TRIO110", node)

# if with has a withitem `trio.open_nursery() as <X>`,
# and the body is only a single expression <X>.start[_soon](),
# and does not pass <X> as a parameter to the expression
def check_for_trio112(self, node: Union[ast.With, ast.AsyncWith]):
# body is single expression
if len(node.body) != 1 or not isinstance(node.body[0], ast.Expr):
return
for item in node.items:
# get variable name <X>
if not isinstance(item.optional_vars, ast.Name):
continue
var_name = item.optional_vars.id

# check for trio.open_nursery
nursery = get_matching_call(item.context_expr, "open_nursery")

# isinstance(..., ast.Call) is done in get_matching_call
body_call = cast(ast.Call, node.body[0].value)
Comment on lines +311 to +312
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use an assert rather than a cast() here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert is actually not possible here, the isinstance call is done later inside get_matching_call (which if it inside there evaluates false propagates to the condition calling it being false).

the easiest alternative is a redundant isinstance in the condition

I suppose the cast is incorrect in that body_call isn't actually a Call yet, but is for brevity/convenience later.


if (
nursery is not None
and get_matching_call(body_call, "start", "start_soon", base=var_name)
# check for presence of <X> as parameter
and not any(
(isinstance(n, ast.Name) and n.id == var_name)
for n in self.walk(*body_call.args, *body_call.keywords)
)
):
self.error("TRIO112", item.context_expr, var_name)


def critical_except(node: ast.ExceptHandler) -> Optional[Statement]:
def has_exception(node: Optional[ast.expr]) -> str:
Expand Down Expand Up @@ -333,10 +353,30 @@ def has_exception(node: Optional[ast.expr]) -> str:


class Visitor102(Flake8TrioVisitor):
class TrioScope:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_matching_call (previously get_trio_scope) no longer constructs a TrioScope, since only Visitor102 actually cared about it. So moved it to a subclass

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this isn't an affirmative reason to move it, so I'd keep the diff a bit smaller by leaving it where it was. On the other hand, nor is there any real reason to move it back!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha. I liked subclassing it for people reading it later on not encountering it through a commit diff, fun tension

def __init__(self, node: ast.Call, funcname: str):
self.node = node
self.funcname = funcname
self.variable_name: Optional[str] = None
self.shielded: bool = False
self.has_timeout: bool = True

# scope.shielded is assigned to in visit_Assign

if self.funcname == "CancelScope":
self.has_timeout = False
for kw in node.keywords:
# Only accepts constant values
if kw.arg == "shield" and isinstance(kw.value, ast.Constant):
self.shielded = kw.value.value
# sets to True even if timeout is explicitly set to inf
if kw.arg == "deadline":
self.has_timeout = True

def __init__(self):
super().__init__()
self._critical_scope: Optional[Statement] = None
self._trio_context_managers: List[TrioScope] = []
self._trio_context_managers: List[Visitor102.TrioScope] = []
self._safe_decorator = False

# if we're inside a finally, and not inside a context_manager, and we're not
Expand Down Expand Up @@ -364,17 +404,19 @@ def visit_With(self, node: Union[ast.With, ast.AsyncWith]):

# Check for a `with trio.<scope_creater>`
for item in node.items:
trio_scope = get_trio_scope(
call = get_matching_call(
item.context_expr, "open_nursery", *cancel_scope_names
)
if trio_scope is None:
if call is None:
continue

self._trio_context_managers.append(trio_scope)
has_context_manager = True
trio_scope = self.TrioScope(*call)
# check if it's saved in a variable
if isinstance(item.optional_vars, ast.Name):
trio_scope.variable_name = item.optional_vars.id

self._trio_context_managers.append(trio_scope)
has_context_manager = True
break

self.generic_visit(node)
Expand Down
88 changes: 88 additions & 0 deletions tests/trio112.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import trio
import trio as noterror

# error
with trio.open_nursery() as n: # error: 5, "n"
n.start(...)

with trio.open_nursery(...) as nurse: # error: 5, "nurse"
nurse.start_soon(...)

with trio.open_nursery() as n: # error: 5, "n"
n.start_soon(n=7)


async def foo():
async with trio.open_nursery() as n: # error: 15, "n"
n.start(...)


# weird ones with multiple `withitem`s
# but if split among several `with` they'd all be treated as error (or TRIO111), so
# treating as error for now.
with trio.open_nursery() as n, trio.open("") as n: # error: 5, "n"
n.start(...)

with open("") as o, trio.open_nursery() as n: # error: 20, "n"
n.start(o)

with trio.open_nursery() as n, trio.open_nursery() as nurse: # error: 31, "nurse"
nurse.start(n.start(...))

with trio.open_nursery() as n, trio.open_nursery() as n: # error: 5, "n" # error: 31, "n"
n.start(...)

# safe if passing variable as parameter
with trio.open_nursery() as n:
n.start(..., n, ...)

with trio.open_nursery() as n:
n.start(..., foo=n + 7, bar=...)

with trio.open_nursery() as n:
n.start(foo=tuple(tuple(tuple(tuple(n)))))

# safe if multiple lines
with trio.open_nursery() as n:
...
n.start_soon(...)

with trio.open_nursery() as n:
n.start_soon(...)
...

# fmt: off
with trio.open_nursery() as n:
n.start_soon(...) ; ...
# fmt: on

# n as a parameter to lambda is in fact not using it, but we don't parse
with trio.open_nursery() as n:
n.start_soon(lambda n: n + 1)

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


# not *trio*.open_nursery
with noterror.open_nursery(...) as n:
n.start(...)

# not trio.*open_nursery*
with trio.not_error(...) as n:
n.start(...)

p = trio.not_error
# not *n*.start[_soon]
with trio.open_nursery() as n:
p.start(...)

# not n.*start[_soon]*
with trio.open_nursery() as n:
n.start_never(...)

# redundant nursery, not handled
with trio.open_nursery():
pass
0