Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
*[CalVer, YY.month.patch](https://calver.org/)*
## Future
- Add TRIO232: blocking sync call on file object.
- Add TRIO212: blocking sync call on httpx.Client object.

## Future
- TRIO103 and TRIO104 no longer triggers when `trio.Cancelled` has been handled in previous except handlers.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pip install flake8-trio
- **TRIO200**: User-configured error for blocking sync calls in async functions. Does nothing by default, see [`trio200-blocking-calls`](#trio200-blocking-calls) for how to configure it.
- **TRIO210**: Sync HTTP call in async function, use `httpx.AsyncClient`.
- **TRIO211**: Likely sync HTTP call in async function, use `httpx.AsyncClient`. Looks for `urllib3` method calls on pool objects, but only matching on the method signature and not the object.
- **TRIO212**: Blocking sync HTTP call on httpx object, use httpx.AsyncClient.
- **TRIO220**: Sync process call in async function, use `await nursery.start(trio.run_process, ...)`.
- **TRIO221**: Sync process call in async function, use `await trio.run_process(...)`.
- **TRIO230**: Sync IO call in async function, use `trio.open_file(...)`.
Expand Down
6 changes: 5 additions & 1 deletion flake8_trio/visitors/flake8triovisitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ def set_state(self, attrs: dict[str, Any], copy: bool = False):
setattr(self, attr, value)

def save_state(self, node: ast.AST, *attrs: str, copy: bool = False):
self.outer[node] = self.get_state(*attrs, copy=copy)
state = self.get_state(*attrs, copy=copy)
if node in self.outer:
self.outer[node].update(state)
else:
self.outer[node] = state

def walk(self, *body: ast.AST) -> Iterable[ast.AST]:
for b in body:
Expand Down
221 changes: 137 additions & 84 deletions flake8_trio/visitors/visitor2xx.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,88 @@ def visit_blocking_call(self, node: ast.Call):
self.error(node, key, blocking_calls[key])


# used by Visitor212 and Visitor232
class TypeTrackerVisitor200(Visitor200):
def __init__(
self, typed_calls: dict[str, str] | None = None, *args: Any, **kwargs: Any
):
super().__init__(*args, **kwargs)
self.typed_calls: dict[str, str] = {} if typed_calls is None else typed_calls
self.variables: dict[str, str] = {} # name: type

def visit_AsyncFunctionDef(
self, node: ast.AsyncFunctionDef | ast.FunctionDef | ast.Lambda
):
super().visit_AsyncFunctionDef(node)

def or_none(node: ast.AST | None):
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.BitOr):
return None
if isinstance(node.left, ast.Constant) and node.left.value is None:
return node.right
if isinstance(node.right, ast.Constant) and node.right.value is None:
return node.left
return None

self.save_state(node, "variables", copy=True)

args = node.args
for arg in *args.args, *args.posonlyargs, *args.kwonlyargs:
annotation = arg.annotation

if (
isinstance(annotation, ast.Subscript)
and isinstance(annotation.value, ast.Name)
and annotation.value.id == "Optional"
):
annotation = annotation.slice
elif res := or_none(annotation):
annotation = res

if not isinstance(annotation, (ast.Name, ast.Attribute)):
continue

self.variables[arg.arg] = ast.unparse(annotation)

visit_FunctionDef = visit_AsyncFunctionDef
visit_Lambda = visit_AsyncFunctionDef

def visit_AnnAssign(self, node: ast.AnnAssign):
if not isinstance(node.target, ast.Name):
return
target = node.target.id
typename = ast.unparse(node.annotation)
self.variables[target] = typename

def visit_Assign(self, node: ast.Assign):
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
return

# `f = open(...)`
if isinstance(node.value, ast.Call) and (
vartype := self.typed_calls.get(ast.unparse(node.value.func), None)
):
self.variables[node.targets[0].id] = vartype

# f = ff (and ff is a variable with known type)
elif isinstance(node.value, ast.Name) and (
value := self.variables.get(node.value.id, None)
):
self.variables[node.targets[0].id] = value

def visit_With(self, node: ast.With):
if len(node.items) != 1:
return
item = node.items[0]
if (
isinstance(item.context_expr, ast.Call)
and isinstance(item.context_expr.func, ast.Name)
and isinstance(item.optional_vars, ast.Name)
and (vartype := self.typed_calls.get(item.context_expr.func.id, None))
):
self.variables[item.optional_vars.id] = vartype


@error_class
class Visitor21X(Visitor200):
error_codes = {
Expand Down Expand Up @@ -107,6 +189,57 @@ def visit_blocking_call(self, node: ast.Call):
self.error(node, func_name, error_code="TRIO211")


@error_class
class Visitor212(TypeTrackerVisitor200):
error_codes = {
"TRIO212": (
"Blocking sync HTTP call {1} on httpx object {0}, use httpx.AsyncClient"
)
}

def __init__(self, *args: Any, **kwargs: Any):
self.dangerous_classes = (
"httpx.Client",
"urllib3.HTTPConnectionPool",
"urllib3.HTTPSConnectionPool",
"urllib3.PoolManager",
"urllib3.ProxyManager",
"urllib3.connectionpool.ConnectionPool",
"urllib3.connectionpool.HTTPConnectionPool",
"urllib3.connectionpool.HTTPSConnectionPool",
"urllib3.poolmanager.PoolManager",
"urllib3.poolmanager.ProxyManager",
"urllib3.request.RequestMethods",
)
super().__init__(
dict(zip(self.dangerous_classes, self.dangerous_classes)), *args, **kwargs
)

def visit_blocking_call(self, node: ast.Call):
httpx_blocking_methods = (
"close",
"delete",
"get",
"head",
"options",
"patch",
"post",
"put",
"request",
"send",
"stream",
)
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id in self.variables
and (anno := self.variables.get(node.func.value.id))
and (anno != "httpx.Client" or node.func.attr in httpx_blocking_methods)
and anno in self.dangerous_classes
):
self.error(node, node.func.attr, node.func.value.id)


# Process invocations 202
@error_class
class Visitor22X(Visitor200):
Expand Down Expand Up @@ -187,7 +320,7 @@ def visit_blocking_call(self, node: ast.Call):


@error_class
class Visitor232(Flake8TrioVisitor):
class Visitor232(TypeTrackerVisitor200):
error_codes = {
"TRIO232": (
"Blocking sync call {1} on file object {0}, wrap the file object"
Expand All @@ -196,104 +329,24 @@ class Visitor232(Flake8TrioVisitor):
}

def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.variables: dict[str, str] = {} # name: type
self.async_function = False

def visit_AsyncFunctionDef(
self, node: ast.AsyncFunctionDef | ast.FunctionDef | ast.Lambda
):
def or_none(node: ast.AST | None):
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.BitOr):
return None
if isinstance(node.left, ast.Constant) and node.left.value is None:
return node.right
if isinstance(node.right, ast.Constant) and node.right.value is None:
return node.left
return None

self.save_state(node, "variables", "async_function", copy=True)
self.async_function = isinstance(node, ast.AsyncFunctionDef)

args = node.args
for arg in *args.args, *args.posonlyargs, *args.kwonlyargs:
annotation = arg.annotation

if (
isinstance(annotation, ast.Subscript)
and isinstance(annotation.value, ast.Name)
and annotation.value.id == "Optional"
):
annotation = annotation.slice
elif res := or_none(annotation):
annotation = res
super().__init__({"open": "io.TextIOWrapper"}, *args, **kwargs)

if not isinstance(annotation, (ast.Name, ast.Attribute)):
continue

self.variables[arg.arg] = ast.unparse(annotation)

visit_FunctionDef = visit_AsyncFunctionDef
visit_Lambda = visit_AsyncFunctionDef

def visit_Call(self, node: ast.Call):
def visit_blocking_call(self, node: ast.Call):
io_file_types = (
"io.TextIOWrapper",
"io.BufferedReader",
"io.BufferedWriter",
"io.BufferedRandom",
)
if (
self.async_function
and isinstance(node.func, ast.Attribute)
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id in self.variables
and (anno := self.variables.get(node.func.value.id))
and (anno in io_file_types or f"io.{anno}" in io_file_types)
):
self.error(node, node.func.attr, node.func.value.id)

def visit_AnnAssign(self, node: ast.AnnAssign):
if not isinstance(node.target, ast.Name):
return
target = node.target.id
typename = ast.unparse(node.annotation)
self.variables[target] = typename

def visit_Assign(self, node: ast.Assign):
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
return

# `f = open(...)`
if (
isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "open"
):
self.variables[
node.targets[0].id
] = "io.TextIOWrapper" # doesn't matter which

# f = ff (and ff is a variable with known type)
elif isinstance(node.value, ast.Name) and (
value := self.variables.get(node.value.id, None)
):
self.variables[node.targets[0].id] = value

def visit_With(self, node: ast.With):
if len(node.items) != 1:
return
item = node.items[0]
if (
isinstance(item.context_expr, ast.Call)
and isinstance(item.context_expr.func, ast.Name)
and item.context_expr.func.id == "open"
and isinstance(item.optional_vars, ast.Name)
):
self.variables[
item.optional_vars.id
] = "io.TextIOWrapper" # doesn't matter which


@error_class
class Visitor24X(Visitor200):
Expand Down
83 changes: 83 additions & 0 deletions tests/eval_files/trio212.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import urllib3


async def foo_call():
client = httpx.Client()
client.close() # TRIO212: 4, "close", "client"
client.delete() # TRIO212: 4, "delete", "client"
client.get() # TRIO212: 4, "get", "client"
client.head() # TRIO212: 4, "head", "client"
client.options() # TRIO212: 4, "options", "client"
client.patch() # TRIO212: 4, "patch", "client"
client.post() # TRIO212: 4, "post", "client"
client.put() # TRIO212: 4, "put", "client"
client.request() # TRIO212: 4, "request", "client"
client.send() # TRIO212: 4, "send", "client"
client.stream() # TRIO212: 4, "stream", "client"

client.anything()
client.build_request()
client.is_closed


async def foo_ann_par(client: httpx.Client):
client.send(...) # TRIO212: 4, "send", "client"


async def foo_ann_var():
client: httpx.Client = ...
client.send() # TRIO212: 4, "send", "client"


glob_client = httpx.Client()


async def foo_global_client():
glob_client.send() # TRIO212: 4, "send", "glob_client"


def sync_global_client():
glob_client.send()


glob_client: int = 4


async def foo_global_client_overriden():
glob_client.send()


# not implemented
from httpx import Client


async def foo_alias_import():
client: Client = Client()
client.send()


# urllib3 pools
async def foo_urllib3():
c: urllib3.HTTPConnectionPool = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.HTTPSConnectionPool = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.PoolManager = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.ProxyManager = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.connectionpool.ConnectionPool = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.connectionpool.HTTPConnectionPool = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.connectionpool.HTTPSConnectionPool = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.poolmanager.PoolManager = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.poolmanager.ProxyManager = ...
c.anything() # TRIO212: 4, "anything", "c"
c: urllib3.request.RequestMethods = ...
c.anything() # TRIO212: 4, "anything", "c"

c: urllib3.anything = ...
c.anything()
6 changes: 3 additions & 3 deletions tests/eval_files/trio232.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ async def global_vars():
g.read() # TRIO232: 4, 'read', 'g'


# If the type is explicitly overriden, it will not error
async def overriden_type(f: TextIOWrapper):
# If the type is explicitly overridden, it will not error
async def overridden_type(f: TextIOWrapper):
f: int = 7
f.read()
f: TextIOWrapper = ...
Expand All @@ -173,7 +173,7 @@ async def overriden_type(f: TextIOWrapper):
# ***** Known unhandled cases *****

# It will error on non-explicit assignments
async def implicit_overriden_type():
async def implicit_overridden_type():
f: TextIOWrapper = ...
f = arbitrary_function()
f.read() # TRIO232: 4, 'read', 'f'
Expand Down