diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0c3b27..b1909356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d04d8b35..2adc6724 100644 --- a/README.md +++ b/README.md @@ -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(...)`. diff --git a/flake8_trio/visitors/flake8triovisitor.py b/flake8_trio/visitors/flake8triovisitor.py index 02ba4206..ee666047 100644 --- a/flake8_trio/visitors/flake8triovisitor.py +++ b/flake8_trio/visitors/flake8triovisitor.py @@ -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: diff --git a/flake8_trio/visitors/visitor2xx.py b/flake8_trio/visitors/visitor2xx.py index 4822135e..ef75beec 100644 --- a/flake8_trio/visitors/visitor2xx.py +++ b/flake8_trio/visitors/visitor2xx.py @@ -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 = { @@ -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): @@ -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" @@ -196,47 +329,9 @@ 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", @@ -244,8 +339,7 @@ def visit_Call(self, node: ast.Call): "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)) @@ -253,47 +347,6 @@ def visit_Call(self, node: ast.Call): ): 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): diff --git a/tests/eval_files/trio212.py b/tests/eval_files/trio212.py new file mode 100644 index 00000000..86465f01 --- /dev/null +++ b/tests/eval_files/trio212.py @@ -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() diff --git a/tests/eval_files/trio232.py b/tests/eval_files/trio232.py index 0bb04ad6..1ec23790 100644 --- a/tests/eval_files/trio232.py +++ b/tests/eval_files/trio232.py @@ -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 = ... @@ -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'