From 807cc3e530e54fc438488fecffb208cabf8f92e5 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 26 Oct 2023 16:46:52 +0200 Subject: [PATCH 01/18] Adds TC009 for type checking declarations used at runtime Extends TC100/TC200 to deal with type checking declarations Avoids a couple of false positives in TC100/TC101 Avoids contradicting TC004/TC009 errors vs. TC100/TC200 errors --- flake8_type_checking/checker.py | 102 +++++++++++++++++----- flake8_type_checking/constants.py | 1 + flake8_type_checking/types.py | 5 ++ tests/test_tc009.py | 138 ++++++++++++++++++++++++++++++ tests/test_tc100.py | 17 ++-- tests/test_tc101.py | 1 + tests/test_tc200.py | 11 +++ 7 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 tests/test_tc009.py diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 36e77bb..301dc0a 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -28,6 +28,7 @@ TC006, TC007, TC008, + TC009, TC100, TC101, TC200, @@ -53,6 +54,7 @@ def ast_unparse(node: ast.AST) -> str: from typing import Any, Optional, Union from flake8_type_checking.types import ( + Declaration, Flake8Generator, FunctionRangesDict, FunctionScopeNamesDict, @@ -425,10 +427,11 @@ def __init__( # This lets us identify imports that *are* needed at runtime, for TC004 errors. self.type_checking_block_imports: set[tuple[Import, str]] = set() - #: Set of variable names for all declarations defined within a type-checking block + #: Tuple of (node, variable name) for all global declarations within a type-checking block # This lets us avoid false positives for annotations referring to e.g. a TypeAlias - # defined within a type checking block - self.type_checking_block_declarations: set[str] = set() + # defined within a type checking block. We currently ignore function definitions, since + # those should be exceedingly rare inside type checking blocks. + self.type_checking_block_declarations: set[tuple[Declaration, str]] = set() #: Set of all the class names defined within the file # This lets us avoid false positives for classes referring to themselves @@ -492,6 +495,11 @@ def names(self) -> set[str]: """Return unique names.""" return set(self.uses.keys()) + @property + def type_checking_names(self) -> set[str]: + """Return unique names either imported or declared in type checking blocks.""" + return {name for _, name in chain(self.type_checking_block_imports, self.type_checking_block_declarations)} + # -- Map type checking block --------------- def in_type_checking_block(self, lineno: int, col_offset: int) -> bool: @@ -775,7 +783,10 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: if isinstance(element, ast.AnnAssign): self.visit(element.annotation) - self.class_names.add(node.name) + if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): + self.type_checking_block_declarations.add((node, node.name)) + else: + self.class_names.add(node.name) self.generic_visit(node) return node @@ -868,7 +879,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self.add_annotation(node.value, 'alias') if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): - self.type_checking_block_declarations.add(node.target.id) + self.type_checking_block_declarations.add((node, node.target.id)) # if it wasn't a TypeAlias we need to visit the value expression else: @@ -887,7 +898,7 @@ def visit_Assign(self, node: ast.Assign) -> ast.Assign: and isinstance(node.targets[0], ast.Name) and self.in_type_checking_block(node.lineno, node.col_offset) ): - self.type_checking_block_declarations.add(node.targets[0].id) + self.type_checking_block_declarations.add((node, node.targets[0].id)) super().visit_Assign(node) return node @@ -907,7 +918,7 @@ def visit_TypeAlias(self, node: ast.TypeAlias) -> None: self.add_annotation(node.value, 'new-alias') if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): - self.type_checking_block_declarations.add(node.name.id) + self.type_checking_block_declarations.add((node, node.name.id)) def register_function_ranges(self, node: Union[FunctionDef, AsyncFunctionDef]) -> None: """ @@ -1105,6 +1116,8 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None: self.empty_type_checking_blocks, # TC006 self.unquoted_type_in_cast, + # TC009 + self.used_type_checking_declarations, # TC100 self.missing_futures_import, # TC101 @@ -1184,13 +1197,54 @@ def unquoted_type_in_cast(self) -> Flake8Generator: for lineno, col_offset, annotation in self.visitor.unquoted_types_in_casts: yield lineno, col_offset, TC006.format(annotation=annotation), None + def used_type_checking_declarations(self) -> Flake8Generator: + """TC009.""" + for decl, decl_name in self.visitor.type_checking_block_declarations: + if decl_name in self.visitor.uses: + # If we get to here, we're pretty sure that the declaration + # shouldn't actually live inside a type-checking block + + use = self.visitor.uses[decl_name] + + # .. or whether one of the argument names shadows a declaration + use_in_function = False + if use.lineno in self.visitor.function_ranges: + for i in range( + self.visitor.function_ranges[use.lineno]['start'], + self.visitor.function_ranges[use.lineno]['end'], + ): + if ( + i in self.visitor.function_scope_names + and decl_name in self.visitor.function_scope_names[i]['names'] + ): + use_in_function = True + break + + if not use_in_function: + yield decl.lineno, decl.col_offset, TC009.format(name=decl_name), None + def missing_futures_import(self) -> Flake8Generator: """TC100.""" - if ( - not self.visitor.futures_annotation - and {name for _, name in self.visitor.type_checking_block_imports} - self.visitor.names - ): + if self.visitor.futures_annotation: + return + + # if all the symbols imported/declared in type checking blocks are used + # at runtime, then we're covered by TC004 + unused_type_checking_names = self.visitor.type_checking_names - self.visitor.names + if not unused_type_checking_names: + return + + # if any of the symbols imported/declared in type checking blocks are used + # in an annotation outside a type checking block, then we need to emit TC100 + for item in self.visitor.unwrapped_annotations: + if item.annotation not in unused_type_checking_names: + continue + + if self.visitor.in_type_checking_block(item.lineno, item.col_offset): + continue + yield 1, 0, TC100, None + return def futures_excess_quotes(self) -> Flake8Generator: """TC101.""" @@ -1233,11 +1287,13 @@ def futures_excess_quotes(self) -> Flake8Generator: So we don't try to unwrap the annotations as far as possible, we just check if the entire annotation can be unwrapped or not. """ + type_checking_names = self.visitor.type_checking_names + for item in self.visitor.wrapped_annotations: if item.type != 'annotation': # TypeAlias value will not be affected by a futures import continue - if any(import_name in item.names for _, import_name in self.visitor.type_checking_block_imports): + if not item.names.isdisjoint(type_checking_names): continue if any(class_name in item.names for class_name in self.visitor.class_names): @@ -1247,6 +1303,8 @@ def futures_excess_quotes(self) -> Flake8Generator: def missing_quotes(self) -> Flake8Generator: """TC200 and TC007.""" + unused_type_checking_names = self.visitor.type_checking_names - self.visitor.names + for item in self.visitor.unwrapped_annotations: # A new style alias does never need to be wrapped if item.type == 'new-alias': @@ -1258,16 +1316,17 @@ def missing_quotes(self) -> Flake8Generator: if self.visitor.in_type_checking_block(item.lineno, item.col_offset): continue - for _, name in self.visitor.type_checking_block_imports: - if item.annotation == name: - if item.type == 'alias': - error = TC007.format(alias=item.annotation) - else: - error = TC200.format(annotation=item.annotation) - yield item.lineno, item.col_offset, error, None + if item.annotation in unused_type_checking_names: + if item.type == 'alias': + error = TC007.format(alias=item.annotation) + else: + error = TC200.format(annotation=item.annotation) + yield item.lineno, item.col_offset, error, None def excess_quotes(self) -> Flake8Generator: """TC201 and TC008.""" + type_checking_names = self.visitor.type_checking_names + for item in self.visitor.wrapped_annotations: # A new style type alias should never be wrapped if item.type == 'new-alias': @@ -1284,15 +1343,12 @@ def excess_quotes(self) -> Flake8Generator: continue # See comment in futures_excess_quotes - if any(import_name in item.names for _, import_name in self.visitor.type_checking_block_imports): + if not item.names.isdisjoint(type_checking_names): continue if any(class_name in item.names for class_name in self.visitor.class_names): continue - if any(variable_name in item.names for variable_name in self.visitor.type_checking_block_declarations): - continue - if item.type == 'alias': error = TC008.format(alias=item.annotation) else: diff --git a/flake8_type_checking/constants.py b/flake8_type_checking/constants.py index 8bf19b1..207eba0 100644 --- a/flake8_type_checking/constants.py +++ b/flake8_type_checking/constants.py @@ -33,6 +33,7 @@ TC006 = "TC006 Annotation '{annotation}' in typing.cast() should be a string literal" TC007 = "TC007 Type alias '{alias}' needs to be made into a string literal" TC008 = "TC008 Type alias '{alias}' does not need to be a string literal" +TC009 = "TC009 Move declaration '{name}' out of type-checking block. Variable is used for more than type hinting." TC100 = "TC100 Add 'from __future__ import annotations' import" TC101 = "TC101 Annotation '{annotation}' does not need to be a string literal" TC200 = "TC200 Annotation '{annotation}' needs to be made into a string literal" diff --git a/flake8_type_checking/types.py b/flake8_type_checking/types.py index 425c734..2187eab 100644 --- a/flake8_type_checking/types.py +++ b/flake8_type_checking/types.py @@ -4,6 +4,7 @@ if TYPE_CHECKING: import ast + import sys from typing import Any, Generator, Optional, Protocol, Tuple, TypedDict, Union class FunctionRangesDict(TypedDict): @@ -13,6 +14,10 @@ class FunctionRangesDict(TypedDict): class FunctionScopeNamesDict(TypedDict): names: list[str] + if sys.version_info >= (3, 12): + Declaration = Union[ast.ClassDef, ast.AnnAssign, ast.Assign, ast.TypeAlias] + else: + Declaration = Union[ast.ClassDef, ast.AnnAssign, ast.Assign] Import = Union[ast.Import, ast.ImportFrom] Flake8Generator = Generator[Tuple[int, int, str, Any], None, None] diff --git a/tests/test_tc009.py b/tests/test_tc009.py new file mode 100644 index 0000000..635e206 --- /dev/null +++ b/tests/test_tc009.py @@ -0,0 +1,138 @@ +""" +This file tests the TC009 error: + + >> Move declaration out of type-checking block. Variable is used for more than type hinting. + +""" +import sys +import textwrap + +import pytest + +from flake8_type_checking.constants import TC009 +from tests.conftest import _get_error + +examples = [ + # No error + ('', set()), + # Used in file + ( + textwrap.dedent(""" + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + datetime = Any + + x = datetime + """), + {'5:4 ' + TC009.format(name='datetime')}, + ), + # Used in function + ( + textwrap.dedent(""" + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + class date: ... + + def example(): + return date() + """), + {'5:4 ' + TC009.format(name='date')}, + ), + # Used, but only used inside the type checking block + ( + textwrap.dedent(""" + if TYPE_CHECKING: + class date: ... + + CustomType = date + """), + set(), + ), + # Used for typing only + ( + textwrap.dedent(""" + if TYPE_CHECKING: + class date: ... + + def example(*args: date, **kwargs: date): + return + + my_type: Type[date] | date + """), + set(), + ), + ( + textwrap.dedent(""" + from __future__ import annotations + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + class AsyncIterator: ... + + + class Example: + + async def example(self) -> AsyncIterator[list[str]]: + yield 0 + """), + set(), + ), + ( + textwrap.dedent(""" + from typing import TYPE_CHECKING + from weakref import WeakKeyDictionary + + if TYPE_CHECKING: + Any = str + + + d = WeakKeyDictionary["Any", "Any"]() + """), + set(), + ), + ( + textwrap.dedent(""" + if TYPE_CHECKING: + a = int + b: TypeAlias = str + class c(Protocol): ... + class d(TypedDict): ... + + def test_function(a, /, b, *, c, **d): + print(a, b, c, d) + """), + set(), + ), +] + +if sys.version_info >= (3, 12): + examples.append( + ( + textwrap.dedent(""" + if TYPE_CHECKING: + type Foo = int + + x = Foo + """), + {'3:4 ' + TC009.format(name='Foo')}, + ) + ) + examples.append( + ( + textwrap.dedent(""" + if TYPE_CHECKING: + type Foo = int + + x: Foo + """), + set(), + ) + ) + + +@pytest.mark.parametrize(('example', 'expected'), examples) +def test_TC009_errors(example, expected): + assert _get_error(example, error_code_filter='TC009') == expected diff --git a/tests/test_tc100.py b/tests/test_tc100.py index 5c416b6..fd8ee10 100644 --- a/tests/test_tc100.py +++ b/tests/test_tc100.py @@ -16,12 +16,18 @@ examples = [ # No errors ('', set()), + # Unused declaration ('if TYPE_CHECKING:\n\tx = 2', set()), + # Used declaration + ('if TYPE_CHECKING:\n\tx = 2\ny = x + 2', set()), + ('if TYPE_CHECKING:\n\tT = TypeVar("T")\nx: T\ny = T', set()), + # Declaration used only in annotation + ('if TYPE_CHECKING:\n\tT = TypeVar("T")\nx: T', {'1:0 ' + TC100}), # Unused import - ('if TYPE_CHECKING:\n\tfrom typing import Dict', {'1:0 ' + TC100}), - ('if TYPE_CHECKING:\n\tfrom typing import Dict, Any', {'1:0 ' + TC100}), - (f'if TYPE_CHECKING:\n\timport {mod}', {'1:0 ' + TC100}), - (f'if TYPE_CHECKING:\n\tfrom {mod} import constants', {'1:0 ' + TC100}), + ('if TYPE_CHECKING:\n\tfrom typing import Dict', set()), + ('if TYPE_CHECKING:\n\tfrom typing import Dict, Any', set()), + (f'if TYPE_CHECKING:\n\timport {mod}', set()), + (f'if TYPE_CHECKING:\n\tfrom {mod} import constants', set()), # Used imports ('if TYPE_CHECKING:\n\tfrom typing import Dict\nx = Dict', set()), ('if TYPE_CHECKING:\n\tfrom typing import Dict, Any\nx, y = Dict, Any', set()), @@ -35,9 +41,6 @@ ('if TYPE_CHECKING:\n\tfrom typing import Dict\ndef example(x: Dict[str, int] = {}):\n\tpass', {'1:0 ' + TC100}), # Import used for returns ('if TYPE_CHECKING:\n\tfrom typing import Dict\ndef example() -> Dict[str, int]:\n\tpass', {'1:0 ' + TC100}), - # Probably not much point in adding many more test cases, as the logic for TC100 - # is not dependent on the type of annotation assignments; it's purely concerned with - # whether an ast.Import or ast.ImportFrom exists within a type checking block ] diff --git a/tests/test_tc101.py b/tests/test_tc101.py index 93e07c3..767b596 100644 --- a/tests/test_tc101.py +++ b/tests/test_tc101.py @@ -18,6 +18,7 @@ ("if TYPE_CHECKING:\n\timport y\nx: 'y'", set()), ("x: 'Dict[int]'", {'1:3 ' + TC101.format(annotation='Dict[int]')}), ("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict[int]'", set()), + ("if TYPE_CHECKING:\n\tFoo: TypeAlias = Any\nx: 'Foo'", set()), # Basic AnnAssign with type-checking block and exact match ( "from __future__ import annotations\nif TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict'", diff --git a/tests/test_tc200.py b/tests/test_tc200.py index 123bc7b..6e56bd5 100644 --- a/tests/test_tc200.py +++ b/tests/test_tc200.py @@ -15,8 +15,19 @@ ('x: int', set()), ('x: "int"', set()), ('from typing import Dict\nx: Dict[int]', set()), + # Unused import/declaration + ('if TYPE_CHECKING:\n\tfrom typing import Dict', set()), + ('if TYPE_CHECKING:\n\tx = 2', set()), + # Used import/declaration + ('if TYPE_CHECKING:\n\tfrom typing import Dict\nx: Dict = Dict()', set()), + ('if TYPE_CHECKING:\n\tx = 2\ny = x + 2', set()), + ('if TYPE_CHECKING:\n\tT = TypeVar("T")\nx: T\ny = T', set()), + # Import/Declaration used only in annotation ('if TYPE_CHECKING:\n\tfrom typing import Dict\nx: Dict', {'3:3 ' + TC200.format(annotation='Dict')}), + ('if TYPE_CHECKING:\n\tT = TypeVar("T")\nx: T', {'3:3 ' + TC200.format(annotation='T')}), ("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict'", set()), + ('if TYPE_CHECKING:\n\tFoo: TypeAlias = Any\nx: Foo', {'3:3 ' + TC200.format(annotation='Foo')}), + ("if TYPE_CHECKING:\n\tFoo: TypeAlias = Any\nx: 'Foo'", set()), ("if TYPE_CHECKING:\n\tfrom typing import Dict as d\nx: 'd[int]'", set()), ('if TYPE_CHECKING:\n\tfrom typing import Dict\nx: Dict[int]', {'3:3 ' + TC200.format(annotation='Dict')}), ('if TYPE_CHECKING:\n\timport something\nx: something', {'3:3 ' + TC200.format(annotation='something')}), From 5917ee11d2f000e609bda9c283d9c427e7f2dfe8 Mon Sep 17 00:00:00 2001 From: Daverball Date: Thu, 26 Oct 2023 23:30:20 +0200 Subject: [PATCH 02/18] Adds TC009 to README.md --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7268ee7..0db6316 100644 --- a/README.md +++ b/README.md @@ -62,16 +62,17 @@ And depending on which error code range you've opted into, it will tell you ## Error codes -| Code | Description | -|-------|------------------------------------------------------------------------------------| -| TC001 | Move application import into a type-checking block | -| TC002 | Move third-party import into a type-checking block | -| TC003 | Move built-in import into a type-checking block | -| TC004 | Move import out of type-checking block. Import is used for more than type hinting. | -| TC005 | Found empty type-checking block | -| TC006 | Annotation in typing.cast() should be a string literal | -| TC007 | Type alias needs to be made into a string literal | -| TC008 | Type alias does not need to be a string literal | +| Code | Description | +|-------|-------------------------------------------------------------------------------------------| +| TC001 | Move application import into a type-checking block | +| TC002 | Move third-party import into a type-checking block | +| TC003 | Move built-in import into a type-checking block | +| TC004 | Move import out of type-checking block. Import is used for more than type hinting. | +| TC005 | Found empty type-checking block | +| TC006 | Annotation in typing.cast() should be a string literal | +| TC007 | Type alias needs to be made into a string literal | +| TC008 | Type alias does not need to be a string literal | +| TC009 | Move declaration out of type-checking block. Variable is used for more than type hinting. | ## Choosing how to handle forward references From 99b52ffa8b389010ce74bdc22e69f467fd73ca66 Mon Sep 17 00:00:00 2001 From: Daverball Date: Sat, 28 Oct 2023 16:00:35 +0200 Subject: [PATCH 03/18] Implements scopes with symbol tables with variable lookups This symbol lookup also properly supports PEP695 This gets rid of the need for some of our earlier book-keeping, since the variable lookup in the current scope yields a more accurate result Refactored the else case of TC101 into TC201 --- flake8_type_checking/checker.py | 876 ++++++++++++++++++------------ flake8_type_checking/constants.py | 5 +- flake8_type_checking/types.py | 24 +- setup.cfg | 2 +- tests/conftest.py | 1 + tests/test_tc008.py | 17 +- tests/test_tc101.py | 3 +- tests/test_tc201.py | 25 +- 8 files changed, 560 insertions(+), 393 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 301dc0a..2f619f7 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -5,7 +5,8 @@ import os import sys from ast import Index, literal_eval -from contextlib import suppress +from collections import defaultdict +from contextlib import contextmanager, suppress from dataclasses import dataclass from itertools import chain from pathlib import Path @@ -18,7 +19,7 @@ ATTRIBUTE_PROPERTY, ATTRS_DECORATORS, ATTRS_IMPORTS, - GLOBAL_PROPERTY, + DUNDER_ALL_PROPERTY, NAME_RE, TC001, TC002, @@ -33,6 +34,7 @@ TC101, TC200, TC201, + builtin_names, py38, ) @@ -51,17 +53,10 @@ def ast_unparse(node: ast.AST) -> str: if TYPE_CHECKING: from _ast import AsyncFunctionDef, FunctionDef from argparse import Namespace + from collections.abc import Iterator from typing import Any, Optional, Union - from flake8_type_checking.types import ( - Declaration, - Flake8Generator, - FunctionRangesDict, - FunctionScopeNamesDict, - Import, - ImportTypeValue, - Name, - ) + from flake8_type_checking.types import Flake8Generator, Function, HasPosition, Import, ImportTypeValue, Name class AttrsMixin: @@ -142,7 +137,8 @@ class DunderAllMixin: """ if TYPE_CHECKING: - uses: dict[str, ast.AST] + uses: dict[str, list[tuple[ast.AST, Scope]]] + current_scope: Scope def generic_visit(self, node: ast.AST) -> None: # noqa: D102 ... @@ -196,7 +192,10 @@ def visit_Assign(self, node: ast.Assign) -> ast.Assign: def visit_Constant(self, node: ast.Constant) -> ast.Constant: """Map constant as use, if we're inside an __all__ declaration.""" if self.in___all___declaration(node): - self.uses[node.value] = node + # for these it doesn't matter where they are declared, the symbol + # just needs to be available in global scope anywhere + setattr(node, DUNDER_ALL_PROPERTY, True) + self.uses[node.value].append((node, self.current_scope)) return node @@ -362,28 +361,131 @@ def import_type(self) -> ImportTypeValue: return cast('ImportTypeValue', classify_base(self.full_name.partition('.')[0])) -class UnwrappedAnnotation(NamedTuple): - """Represents a single `ast.Name` in an unwrapped annotation.""" +class WrappedAnnotation(NamedTuple): + """Represents a wrapped annotation i.e. a string constant.""" lineno: int col_offset: int annotation: str + names: set[str] + scope: Scope type: Literal['annotation', 'alias', 'new-alias'] -class WrappedAnnotation(NamedTuple): - """Represents a wrapped annotation i.e. a string constant.""" +class UnwrappedAnnotation(NamedTuple): + """Represents a single `ast.Name` in an unwrapped annotation.""" lineno: int col_offset: int annotation: str - names: set[str] + scope: Scope type: Literal['annotation', 'alias', 'new-alias'] +class Symbol(NamedTuple): + """Represents an import/definition/declaration of a variable.""" + + name: str + lineno: int + col_offset: int + type: Literal['import', 'definition', 'declaration', 'argument'] + in_type_checking_block: bool + + def available_at_runtime(self, use: HasPosition | None = None) -> bool: + """Return whether or not this symbol is available at runtime.""" + if self.in_type_checking_block or self.type == 'declaration': + return False + + # we punt in this case, this is to support some use-cases where + # the location of use does not matter, such as __all__ + if use is None: + return True + + if use.lineno < self.lineno: + return False + + if use.lineno == self.lineno and use.col_offset < self.col_offset: + return False + + return True + + +class Scope: + """ + Represents a scope for looking up symbols. + + We currently don't create a new scope for generator expressions, since it + already has a bunch of special cases for accessing symbols that would not + be accessible inside a different kind of scope and it may go away entirely + when comprehension inlining becomes a thing and it no longer generates a + new stack frame. + """ + + def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | None = None): + #: The ast.AST node that created this scope + self.node = node + + #: Map of symbol name to a list of imports/definitions/declarations + # This also includes the scoping of the symbol so we can look up if + # it is available at runtime/type checking time. + self.symbols: dict[str, list[Symbol]] = defaultdict(list) + + #: The outer scope, this will be `None` for the global scope + # We use this to traverse up to the outer scopes when looking up + # symbols + self.parent = parent + + #: The name of the class if this is a scope created by a class definition + # classes are not real scopes, i.e. they don't propagate symbols + # to inner-scopes, so we need to treat them differently in lookup + # the class name itself is also special, since it's available in methods + # but not in the class body itself, so we record it, so we can special-case + # it in symbol lookups + self.class_name = node.name if isinstance(node, ast.ClassDef) else None + + def lookup(self, symbol_name: str, use: HasPosition | None = None, runtime_only: bool = True) -> Symbol | None: + """ + Simulate a symbol lookup. + + If a symbol is redefined multiple times in the same block we don't try + to return the symbol closest to the use-site, we just return the first + one we find, since we don't really care what symbol we find currently. + """ + for symbol in self.symbols.get(symbol_name, ()): + if runtime_only and not symbol.available_at_runtime(use): + continue + + # we just return the first symbol we find + return symbol + + parent = self.parent + if runtime_only: + # if the symbol matches our class name we return at this point + # technically if the class definition is a redefinition of the + # the same symbol_name it could still exist at runtime, but it's + # probably an actual mistake at that point and an annotation should + # be quoted to ensure the correct type is assigned + if symbol_name == self.class_name: + return None + + # skip class scopes when looking up symbols in parent scopes + # they're only available inside the class scope itself + while parent is not None and parent.class_name is not None: + parent = parent.parent + + # we're done looking up and didn't find anything + if parent is None: + return None + + return parent.lookup(symbol_name, use, runtime_only) + + class ImportVisitor(DunderAllMixin, AttrsMixin, FastAPIMixin, PydanticMixin, ast.NodeVisitor): """Map all imports outside of type-checking blocks.""" + #: The currently active scope + current_scope: Scope + def __init__( self, cwd: Path, @@ -420,22 +522,11 @@ def __init__( # then use the import type to yield the error with the appropriate type self.imports: dict[str, ImportName] = {} - #: List of all names and ids, except type declarations - self.uses: dict[str, ast.AST] = {} - - #: Tuple of (node, import name) for all import defined within a type-checking block - # This lets us identify imports that *are* needed at runtime, for TC004 errors. - self.type_checking_block_imports: set[tuple[Import, str]] = set() + #: List of scopes including all the symbols within + self.scopes: list[Scope] = [] - #: Tuple of (node, variable name) for all global declarations within a type-checking block - # This lets us avoid false positives for annotations referring to e.g. a TypeAlias - # defined within a type checking block. We currently ignore function definitions, since - # those should be exceedingly rare inside type checking blocks. - self.type_checking_block_declarations: set[tuple[Declaration, str]] = set() - - #: Set of all the class names defined within the file - # This lets us avoid false positives for classes referring to themselves - self.class_names: set[str] = set() + #: List of all names and ids, except type declarations + self.uses: dict[str, list[tuple[ast.AST, Scope]]] = defaultdict(list) #: All type annotations in the file, without quotes around them self.unwrapped_annotations: list[UnwrappedAnnotation] = [] @@ -453,12 +544,6 @@ def __init__( self.empty_type_checking_blocks: list[tuple[int, int, int]] = [] self.type_checking_blocks: list[tuple[int, int, int]] = [] - #: Function imports and ranges - # Function scopes can tell us if imports that appear in type-checking blocks - # are repeated inside a function. This prevents false TC004 positives. - self.function_scope_names: dict[int, FunctionScopeNamesDict] = {} - self.function_ranges: dict[int, FunctionRangesDict] = {} - #: Set to the alias of TYPE_CHECKING if one is found self.type_checking_alias: Optional[str] = None @@ -470,6 +555,16 @@ def __init__( self.typing_cast_aliases: set[str] = set() self.unquoted_types_in_casts: list[tuple[int, int, str]] = [] + @contextmanager + def create_scope(self, node: ast.Module | ast.ClassDef | Function) -> Iterator[Scope]: + """Create a new scope.""" + parent = self.current_scope + scope = Scope(node, parent=parent) + self.scopes.append(scope) + self.current_scope = scope + yield scope + self.current_scope = parent + @property def typing_module_name(self) -> str: """ @@ -495,10 +590,13 @@ def names(self) -> set[str]: """Return unique names.""" return set(self.uses.keys()) - @property - def type_checking_names(self) -> set[str]: - """Return unique names either imported or declared in type checking blocks.""" - return {name for _, name in chain(self.type_checking_block_imports, self.type_checking_block_declarations)} + def type_checking_symbols(self) -> Iterator[Symbol]: + """Yield all the symbols declared inside a type checking block.""" + for scope in self.scopes: + for symbols in scope.symbols.values(): + for symbol in symbols: + if symbol.in_type_checking_block: + yield symbol # -- Map type checking block --------------- @@ -594,43 +692,15 @@ def is_true_when_type_checking(self, node: ast.AST) -> bool | Literal['TYPE_CHEC return False def visit_Module(self, node: ast.Module) -> ast.Module: - """ - Mark global statments. - - We propagate this marking when visiting control flow nodes, that don't affect - scope, such as if/else, try/except. Although for simplicity we don't handle - quite all the possible cases, since we're only interested in type checking blocks - and it's not realistic to encounter these for example inside a TryStar/With/Match. - - If we're serious about handling all the cases it would probably make more sense - to override generic_visit to propagate this property for a sequence of node types - and attributes that contain the statements that should propagate global scope. - """ - for stmt in node.body: - setattr(stmt, GLOBAL_PROPERTY, True) - - self.generic_visit(node) - return node - - def visit_Try(self, node: ast.Try) -> ast.Try: - """Propagate global statements.""" - if getattr(node, GLOBAL_PROPERTY, False): - for stmt in chain(node.body, (s for h in node.handlers for s in h.body), node.orelse, node.finalbody): - setattr(stmt, GLOBAL_PROPERTY, True) - + """Create the global scope.""" + scope = Scope(node) + self.current_scope = scope + self.scopes.append(scope) self.generic_visit(node) return node def visit_If(self, node: ast.If) -> Any: - """ - Look for a TYPE_CHECKING block. - - Also recursively propagate global, since if/else does not affect scope. - """ - if getattr(node, GLOBAL_PROPERTY, False): - for stmt in chain(node.body, getattr(node, 'orelse', ()) or ()): - setattr(stmt, GLOBAL_PROPERTY, True) - + """Look for a TYPE_CHECKING block.""" type_checking_condition = self.is_true_when_type_checking(node.test) == 'TYPE_CHECKING' # If it is, note down the line-number-range where the type-checking block exists @@ -666,16 +736,28 @@ def is_exempt_module(self, module_name: str) -> bool: def add_import(self, node: Import) -> None: # noqa: C901 """Add relevant ast objects to import lists.""" - if self.in_type_checking_block(node.lineno, node.col_offset): + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) + + # Record the imported names as symbols + for name_node in node.names: + if hasattr(name_node, 'asname') and name_node.asname: + name = name_node.asname + else: + name = name_node.name + + self.current_scope.symbols[name].append( + Symbol( + name, + node.lineno, + node.col_offset, + 'import', + in_type_checking_block=in_type_checking_block, + ) + ) + + if in_type_checking_block: # For type checking blocks we want to - # 1. Record annotations for TC2XX errors - # 2. Avoid recording imports for TC1XX errors, by returning early - for name_node in node.names: - if hasattr(name_node, 'asname') and name_node.asname: - name = name_node.asname - else: - name = name_node.name - self.type_checking_block_imports.add((node, name)) + # Avoid recording imports for TC1XX errors, by returning early return None # Skip checking the import if the module is passlisted. @@ -753,9 +835,6 @@ def add_import(self, node: Import) -> None: # noqa: C901 # Add to import names map. This is what we use to match imports to uses self.imports[imp.name] = imp - # Add to function scope names, to help catch false positive TC004 errors. - self._add_function_scope_names(lineno=node.lineno, name=imp.name) - def visit_Import(self, node: ast.Import) -> None: """Append objects to our import map.""" self.add_import(node) @@ -765,30 +844,51 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self.add_import(node) def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: - """Note down class names.""" - has_base_classes = node.bases - all_base_classes_ignored = all( - isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist for base in node.bases + """Create class scope and Note down class names.""" + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) + self.current_scope.symbols[node.name].append( + Symbol( + node.name, + node.lineno, + node.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) ) - affected_by_pydantic_support = self.pydantic_enabled and has_base_classes and not all_base_classes_ignored - affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) - - if affected_by_pydantic_support or affected_by_cattrs_support: - # When pydantic or cattrs support is enabled, treat any class variable - # annotation as being required at runtime. We need to do this, or - # users run the risk of guarding imports to resources that actually are - # required at runtime. This can be pretty scary, since it will crashes - # the application at runtime. - for element in node.body: - if isinstance(element, ast.AnnAssign): - self.visit(element.annotation) - - if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): - self.type_checking_block_declarations.add((node, node.name)) - else: - self.class_names.add(node.name) - self.generic_visit(node) - return node + + with self.create_scope(node) as scope: + has_base_classes = node.bases + all_base_classes_ignored = all( + isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist + for base in node.bases + ) + affected_by_pydantic_support = self.pydantic_enabled and has_base_classes and not all_base_classes_ignored + affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) + + if affected_by_pydantic_support or affected_by_cattrs_support: + # When pydantic or cattrs support is enabled, treat any class variable + # annotation as being required at runtime. We need to do this, or + # users run the risk of guarding imports to resources that actually are + # required at runtime. This can be pretty scary, since it will crashes + # the application at runtime. + for element in node.body: + if isinstance(element, ast.AnnAssign): + self.visit(element.annotation) + + # add PEP695 type parameters to class scope + for type_param in getattr(node, 'type_params', ()): + scope.symbols[type_param.name].append( + Symbol( + type_param.name, + type_param.lineno, + type_param.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) + + self.generic_visit(node) + return node def visit_Name(self, node: ast.Name) -> ast.Name: """Map names.""" @@ -800,9 +900,9 @@ def visit_Name(self, node: ast.Name) -> ast.Name: return node if hasattr(node, ATTRIBUTE_PROPERTY): - self.uses[f'{node.id}.{getattr(node, ATTRIBUTE_PROPERTY)}'] = node + self.uses[f'{node.id}.{getattr(node, ATTRIBUTE_PROPERTY)}'].append((node, self.current_scope)) - self.uses[node.id] = node + self.uses[node.id].append((node, self.current_scope)) return node def visit_Constant(self, node: ast.Constant) -> ast.Constant: @@ -810,34 +910,38 @@ def visit_Constant(self, node: ast.Constant) -> ast.Constant: super().visit_Constant(node) return node - def add_annotation(self, node: ast.AST, type: Literal['annotation', 'alias', 'new-alias'] = 'annotation') -> None: + def add_annotation( + self, node: ast.AST, scope: Scope, type: Literal['annotation', 'alias', 'new-alias'] = 'annotation' + ) -> None: """Map all annotations on an AST node.""" if isinstance(node, ast.Ellipsis) or node is None: return if isinstance(node, ast.BinOp): if not isinstance(node.op, ast.BitOr): return - self.add_annotation(node.left, type) - self.add_annotation(node.right, type) + self.add_annotation(node.left, scope, type) + self.add_annotation(node.right, scope, type) elif (py38 and isinstance(node, Index)) or isinstance(node, ast.Attribute): - self.add_annotation(node.value, type) + self.add_annotation(node.value, scope, type) elif isinstance(node, ast.Subscript): - self.add_annotation(node.value, type) + self.add_annotation(node.value, scope, type) if getattr(node.value, 'id', '') != 'Literal': - self.add_annotation(node.slice, type) + self.add_annotation(node.slice, scope, type) elif isinstance(node, (ast.Tuple, ast.List)): for n in node.elts: - self.add_annotation(n, type) + self.add_annotation(n, scope, type) elif isinstance(node, ast.Constant) and isinstance(node.value, str): # Register annotation value setattr(node, ANNOTATION_PROPERTY, True) self.wrapped_annotations.append( - WrappedAnnotation(node.lineno, node.col_offset, node.value, set(NAME_RE.findall(node.value)), type) + WrappedAnnotation( + node.lineno, node.col_offset, node.value, set(NAME_RE.findall(node.value)), scope, type + ) ) elif isinstance(node, ast.Name): # Register annotation value setattr(node, ANNOTATION_PROPERTY, True) - self.unwrapped_annotations.append(UnwrappedAnnotation(node.lineno, node.col_offset, node.id, type)) + self.unwrapped_annotations.append(UnwrappedAnnotation(node.lineno, node.col_offset, node.id, scope, type)) @staticmethod def set_child_node_attribute(node: Any, attr: str, val: Any) -> Any: @@ -866,127 +970,133 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: an annotation as well, but we have to keep in mind that the RHS will not automatically become a ForwardRef with a future import, like a true annotation would. """ - self.add_annotation(node.annotation) + self.add_annotation(node.annotation, self.current_scope) if node.value is None: return - # node is an explicit TypeAlias assignment - if isinstance(node.target, ast.Name) and ( - (isinstance(node.annotation, ast.Name) and node.annotation.id == 'TypeAlias') - or (isinstance(node.annotation, ast.Constant) and node.annotation.value == 'TypeAlias') - ): - self.add_annotation(node.value, 'alias') + if isinstance(node.target, ast.Name): + self.current_scope.symbols[node.target.id].append( + Symbol( + node.target.id, + node.target.lineno, + node.target.col_offset, + ( + # AnnAssign can omit the RHS, in which case it's just a declaration + # and doesn't result in a variable that's available at runtime + 'definition' + if node.value + else 'declaration' + ), + in_type_checking_block=self.in_type_checking_block(node.lineno, node.col_offset), + ) + ) - if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): - self.type_checking_block_declarations.add((node, node.target.id)) + # node is an explicit TypeAlias assignment + if (isinstance(node.annotation, ast.Name) and node.annotation.id == 'TypeAlias') or ( + isinstance(node.annotation, ast.Constant) and node.annotation.value == 'TypeAlias' + ): + self.add_annotation(node.value, self.current_scope, 'alias') + return # if it wasn't a TypeAlias we need to visit the value expression - else: - self.visit(node.value) + self.visit(node.value) def visit_Assign(self, node: ast.Assign) -> ast.Assign: - """ - Keep track of any top-level variable declarations inside type-checking blocks. + """Keep track of variable definitions.""" + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) - For simplicity we only accept single value assignments, i.e. there should only be one - target and it should be an `ast.Name`. - """ - if ( - getattr(node, GLOBAL_PROPERTY, False) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - and self.in_type_checking_block(node.lineno, node.col_offset) - ): - self.type_checking_block_declarations.add((node, node.targets[0].id)) + for target in node.targets: + for name in getattr(target, 'elts', [target]): + if not hasattr(name, 'id'): + continue + + self.current_scope.symbols[name.id].append( + Symbol( + name.id, + name.lineno, + name.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) super().visit_Assign(node) return node - if sys.version_info >= (3, 12): - - def visit_TypeAlias(self, node: ast.TypeAlias) -> None: - """ - Remove all type aliases. - - Keep track of any type aliases declared inside a type checking block using the new - `type Alias = value` syntax. We need to keep in mind that the RHS using this syntax - will always become a ForwardRef, so none of the names are needed at runtime, so we - don't visit the RHS and also have to treat the annotation differently from a regular - annotation when emitting errors. - """ - self.add_annotation(node.value, 'new-alias') - - if getattr(node, GLOBAL_PROPERTY, False) and self.in_type_checking_block(node.lineno, node.col_offset): - self.type_checking_block_declarations.add((node, node.name.id)) - - def register_function_ranges(self, node: Union[FunctionDef, AsyncFunctionDef]) -> None: + def visit_Global(self, node: ast.Global) -> ast.Global: """ - Note down the start and end line number of a function. + Treat global statements like a normal assignment. - We use the start and end line numbers to prevent raising false TC004 - positives in examples like this: - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from pandas import DataFrame + We don't check if the symbol exists in the global scope, that isn't our job. + """ + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) - MyType = DataFrame | str + for name in node.names: + if not hasattr(name, 'id'): + continue - x: MyType + self.current_scope.symbols[name].append( + Symbol( + name, + node.lineno, + node.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) - def some_unrelated_function(): - from pandas import DataFrame - return DataFrame() + return node - where it could seem like the first pandas import is actually used - at runtime, but in fact, it's not. + def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.Nonlocal: """ - end_lineno = cast('int', node.end_lineno) - for i in range(node.lineno, end_lineno + 1): - self.function_ranges[i] = {'start': node.lineno, 'end': end_lineno + 1} + Treat nonlocal statements like a normal assignment. - def _add_function_scope_names(self, lineno: int, name: str) -> None: + We don't check if the symbol exists in the outer scope, that isn't our job. """ - Add function names to our function scope map. - - Given this code: + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - import requests - - ... + for name in node.names: + if not hasattr(name, 'id'): + continue - The `import requests` import will not be in scope at runtime and cannot be used. - To avoid this happening, this plugin ships the TC004 error which warns users if they - attempt to use it: + self.current_scope.symbols[name].append( + Symbol( + name, + node.lineno, + node.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) - TC004: Move import out of type-checking block. Import is used for more than type hinting + return node - The problem is that this is pretty prone to false positives. Some of the things that could happen are: + if sys.version_info >= (3, 12): - 1. The user could import `requests` *again* within the scope of the function - 2. The user could override the name of the import with a function argument name - Moreover, there are several classes of function arguments. - They could do it with: - - an arg - - a kwarg - - a posonly arg - - a kwonly args - 3. The user could override the name with a variable assignment - 4. ? + def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + """ + Remove all type aliases. - This function maps some of these sources of false positives to mitigate them. - """ - if lineno not in self.function_scope_names: - self.function_scope_names[lineno] = {'names': [name]} - else: - self.function_scope_names[lineno]['names'].append(name) + Keep track of any type aliases declared inside a type checking block using the new + `type Alias = value` syntax. We need to keep in mind that the RHS using this syntax + will always become a ForwardRef, so none of the names are needed at runtime, so we + don't visit the RHS and also have to treat the annotation differently from a regular + annotation when emitting errors. + """ + self.add_annotation(node.value, self.current_scope, 'new-alias') + + self.current_scope.symbols[node.name.id].append( + Symbol( + node.name.id, + node.lineno, + node.col_offset, + 'definition', + in_type_checking_block=self.in_type_checking_block(node.lineno, node.col_offset), + ) + ) - def register_function_annotations(self, node: Union[FunctionDef, AsyncFunctionDef]) -> None: + def register_function_annotations(self, node: Function) -> None: """ Map all annotations in a function signature. @@ -997,42 +1107,88 @@ def register_function_annotations(self, node: Union[FunctionDef, AsyncFunctionDe And we also note down the start and end line number for the function. """ - for path in [node.args.args, node.args.kwonlyargs, node.args.posonlyargs]: - for argument in path: - # Map annotations - if hasattr(argument, 'annotation') and argument.annotation: - self.add_annotation(argument.annotation) - - # Map argument names - self._add_function_scope_names(lineno=node.lineno, name=argument.arg) + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) + + # some of the symbols/annotations need to be added to the parent scope + parent_scope = self.current_scope.parent + assert parent_scope is not None + + for argument in chain(node.args.args, node.args.kwonlyargs, node.args.posonlyargs): + # Map annotations + if hasattr(argument, 'annotation') and argument.annotation: + self.add_annotation(argument.annotation, parent_scope) + + # Map argument names + self.current_scope.symbols[argument.arg].append( + Symbol( + argument.arg, + argument.lineno, + argument.col_offset, + 'argument', + in_type_checking_block=in_type_checking_block, + ) + ) - path_: str - for path_ in ['kwarg', 'vararg']: - if arg := getattr(node.args, path_, None): + for path in ('kwarg', 'vararg'): + if arg := getattr(node.args, path, None): # Map annotations if getattr(arg, 'annotation', None): - self.add_annotation(arg.annotation) + self.add_annotation(arg.annotation, parent_scope) # Map argument names if name := getattr(arg, 'arg', None): - self._add_function_scope_names(lineno=node.lineno, name=name) - - if hasattr(node, 'returns') and node.returns: - self.add_annotation(node.returns) + self.current_scope.symbols[name].append( + Symbol( + name, arg.lineno, arg.col_offset, 'argument', in_type_checking_block=in_type_checking_block + ) + ) + + # add PEP695 type parameters to function scope + for type_param in getattr(node, 'type_params', ()): + self.current_scope.symbols[type_param.name].append( + Symbol( + type_param.name, + type_param.lineno, + type_param.col_offset, + # we should be able to treat type vars like arguments + 'argument', + in_type_checking_block=in_type_checking_block, + ) + ) - self.register_function_ranges(node) + if returns := getattr(node, 'returns', None): + self.add_annotation(returns, parent_scope) + + if name := getattr(node, 'name', None): + parent_scope.symbols[name].append( + Symbol( + name, + node.lineno, + node.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) def visit_FunctionDef(self, node: FunctionDef) -> None: """Remove and map function argument- and return annotations.""" - super().visit_FunctionDef(node) - self.register_function_annotations(node) - self.generic_visit(node) + with self.create_scope(node): + super().visit_FunctionDef(node) + self.register_function_annotations(node) + self.generic_visit(node) def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None: """Remove and map function argument- and return annotations.""" - super().visit_AsyncFunctionDef(node) - self.register_function_annotations(node) - self.generic_visit(node) + with self.create_scope(node): + super().visit_AsyncFunctionDef(node) + self.register_function_annotations(node) + self.generic_visit(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + """Remove and map argument symbols.""" + with self.create_scope(node): + self.register_function_annotations(node) + self.generic_visit(node) def register_unquoted_type_in_typing_cast(self, node: ast.Call) -> None: """Find typing.cast() calls with the type argument unquoted.""" @@ -1071,6 +1227,8 @@ class TypingOnlyImportsChecker: __slots__ = [ 'cwd', 'strict_mode', + 'builtin_names', + 'used_type_checking_names', 'visitor', 'generators', 'future_option_enabled', @@ -1080,6 +1238,14 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None: self.cwd = Path(os.getcwd()) self.strict_mode = getattr(options, 'type_checking_strict', False) + # we use the same option as pyflakes to extend the list of builtins + self.builtin_names = builtin_names + additional_builtins = getattr(options, 'builtins', []) + if additional_builtins: + self.builtin_names.union(additional_builtins) + + self.used_type_checking_names: set[str] = set() + exempt_modules = getattr(options, 'type_checking_exempt_modules', []) pydantic_enabled = getattr(options, 'type_checking_pydantic_enabled', False) pydantic_enabled_baseclass_passlist = getattr(options, 'type_checking_pydantic_enabled_baseclass_passlist', []) @@ -1110,21 +1276,19 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None: self.generators = [ # TC001 - TC003 self.unused_imports, - # TC004 - self.used_type_checking_imports, + # TC004, TC009 this needs to run before TC100/TC200/TC007 + self.used_type_checking_symbols, # TC005 self.empty_type_checking_blocks, # TC006 self.unquoted_type_in_cast, - # TC009 - self.used_type_checking_declarations, # TC100 self.missing_futures_import, # TC101 self.futures_excess_quotes, - # TC200, TC006 + # TC200, TC007 self.missing_quotes, - # TC201, TC007 + # TC201, TC008 self.excess_quotes, ] @@ -1160,32 +1324,48 @@ def unused_imports(self) -> Flake8Generator: node = error_specific_imports.pop(import_name.import_name) yield node.lineno, node.col_offset, error.format(module=import_name.import_name), None - def used_type_checking_imports(self) -> Flake8Generator: - """TC004.""" - for _import, import_name in self.visitor.type_checking_block_imports: - if import_name in self.visitor.uses: - # If we get to here, we're pretty sure that the import - # shouldn't actually live inside a type-checking block - - use = self.visitor.uses[import_name] - - # .. or whether there is another duplicate import inside the function scope - # (if the use is in a function scope) - use_in_function = False - if use.lineno in self.visitor.function_ranges: - for i in range( - self.visitor.function_ranges[use.lineno]['start'], - self.visitor.function_ranges[use.lineno]['end'], - ): - if ( - i in self.visitor.function_scope_names - and import_name in self.visitor.function_scope_names[i]['names'] - ): - use_in_function = True - break - - if not use_in_function: - yield _import.lineno, 0, TC004.format(module=import_name), None + def used_type_checking_symbols(self) -> Flake8Generator: + """TC004 and TC009.""" + for symbol in self.visitor.type_checking_symbols(): + if symbol.name in self.builtin_names: + # this symbol is always available at runtime + continue + + uses = self.visitor.uses.get(symbol.name) + if not uses: + # the symbol is not used at runtime so we're fine + continue + + for use, scope in uses: + if symbol.type not in ('import', 'definition'): + # only imports and definitions can be moved around + continue + + if getattr(use, DUNDER_ALL_PROPERTY, False): + # this is actually a quoted name, so it should exist + # as long as it's in the scope at all, we don't need + # to take the position into account + lookup_from = None + else: + lookup_from = use + + if scope.lookup(symbol.name, lookup_from): + # the symbol is available at runtime so we're fine + continue + + if symbol.type == 'import': + msg = TC004.format(module=symbol.name) + col_offset = 0 + else: + msg = TC009.format(name=symbol.name) + col_offset = symbol.col_offset + + yield symbol.lineno, col_offset, msg, None + + self.used_type_checking_names.add(symbol.name) + + # no need to check the other uses, since the error is on the symbol + break def empty_type_checking_blocks(self) -> Flake8Generator: """TC005.""" @@ -1197,52 +1377,33 @@ def unquoted_type_in_cast(self) -> Flake8Generator: for lineno, col_offset, annotation in self.visitor.unquoted_types_in_casts: yield lineno, col_offset, TC006.format(annotation=annotation), None - def used_type_checking_declarations(self) -> Flake8Generator: - """TC009.""" - for decl, decl_name in self.visitor.type_checking_block_declarations: - if decl_name in self.visitor.uses: - # If we get to here, we're pretty sure that the declaration - # shouldn't actually live inside a type-checking block - - use = self.visitor.uses[decl_name] - - # .. or whether one of the argument names shadows a declaration - use_in_function = False - if use.lineno in self.visitor.function_ranges: - for i in range( - self.visitor.function_ranges[use.lineno]['start'], - self.visitor.function_ranges[use.lineno]['end'], - ): - if ( - i in self.visitor.function_scope_names - and decl_name in self.visitor.function_scope_names[i]['names'] - ): - use_in_function = True - break - - if not use_in_function: - yield decl.lineno, decl.col_offset, TC009.format(name=decl_name), None - def missing_futures_import(self) -> Flake8Generator: """TC100.""" if self.visitor.futures_annotation: return - # if all the symbols imported/declared in type checking blocks are used - # at runtime, then we're covered by TC004 - unused_type_checking_names = self.visitor.type_checking_names - self.visitor.names - if not unused_type_checking_names: - return - # if any of the symbols imported/declared in type checking blocks are used # in an annotation outside a type checking block, then we need to emit TC100 for item in self.visitor.unwrapped_annotations: - if item.annotation not in unused_type_checking_names: + if item.type != 'annotation': + # aliases are unaffected by futures import + continue + + if item.annotation in self.builtin_names: + # this symbol is always available at runtime + continue + + if item.annotation in self.used_type_checking_names: + # this symbol already caused a TC004/TC009 continue if self.visitor.in_type_checking_block(item.lineno, item.col_offset): continue + if item.scope.lookup(item.annotation, item): + # the symbol is available at runtime, so we're fine + continue + yield 1, 0, TC100, None return @@ -1255,68 +1416,34 @@ def futures_excess_quotes(self) -> Flake8Generator: continue yield item.lineno, item.col_offset, TC101.format(annotation=item.annotation), None - else: - """ - If we have no futures import and we have no imports inside a type-checking block, things get more tricky: - - When you annotate something like this: - - `x: Dict[int]` - - You receive an ast.AnnAssign element with a subscript containing the int as it's own unit. It means you - have a separation between the `Dict` and the `int`, and the Dict can be matched against a `Dict` import. - - However, when you annotate something inside quotes, like this: - - `x: 'Dict[int]'` - - The annotation is *not* broken down into its components, but rather returns an ast.Constant with a string - value representation of the annotation. In other words, you get one element, with the value `'Dict[int]'`. - - We use a RegEx to extract all the variable names in the annotation into a set we can match against, but - unlike with unwrapped annotations we don't put them all into separate entries, because there would be false - positives for annotations like the one above, since int does not need to be wrapped, but Dict might, but - the inverse would be true for something like: - - `x: 'set[Pattern[str]]'` - - Which could be turned into the following and still be fine: - - `x: set['Pattern[str]'] - - So we don't try to unwrap the annotations as far as possible, we just check if the entire - annotation can be unwrapped or not. - """ - type_checking_names = self.visitor.type_checking_names - - for item in self.visitor.wrapped_annotations: - if item.type != 'annotation': # TypeAlias value will not be affected by a futures import - continue - - if not item.names.isdisjoint(type_checking_names): - continue - - if any(class_name in item.names for class_name in self.visitor.class_names): - continue - - yield item.lineno, item.col_offset, TC101.format(annotation=item.annotation), None + # If no futures imports are present, then we use the generic excess_quotes function + # since the logic is the same as TC201 def missing_quotes(self) -> Flake8Generator: """TC200 and TC007.""" - unused_type_checking_names = self.visitor.type_checking_names - self.visitor.names - for item in self.visitor.unwrapped_annotations: # A new style alias does never need to be wrapped if item.type == 'new-alias': continue + if item.annotation in self.builtin_names: + # this symbol is always available at runtime + continue + + if item.annotation in self.used_type_checking_names: + # this symbol already caused a TC004/TC009 + continue + # Annotations inside `if TYPE_CHECKING:` blocks do not need to be wrapped # unless they're used before definition, which is already covered by other # flake8 rules (and also static type checkers) if self.visitor.in_type_checking_block(item.lineno, item.col_offset): continue - if item.annotation in unused_type_checking_names: + if item.scope.lookup(item.annotation, item, runtime_only=False) and not item.scope.lookup( + item.annotation, item, runtime_only=True + ): + # the symbol is only available for type checking if item.type == 'alias': error = TC007.format(alias=item.annotation) else: @@ -1324,9 +1451,7 @@ def missing_quotes(self) -> Flake8Generator: yield item.lineno, item.col_offset, error, None def excess_quotes(self) -> Flake8Generator: - """TC201 and TC008.""" - type_checking_names = self.visitor.type_checking_names - + """TC101, TC201 and TC008.""" for item in self.visitor.wrapped_annotations: # A new style type alias should never be wrapped if item.type == 'new-alias': @@ -1342,11 +1467,47 @@ def excess_quotes(self) -> Flake8Generator: if '[' in item.annotation or '.' in item.annotation: continue - # See comment in futures_excess_quotes - if not item.names.isdisjoint(type_checking_names): - continue + """ + With wrapped annotations, things get more tricky: - if any(class_name in item.names for class_name in self.visitor.class_names): + When you annotate something like this: + + `x: Dict[int]` + + You receive an ast.AnnAssign element with a subscript containing the int as it's own unit. It means you + have a separation between the `Dict` and the `int`, and the Dict can be matched against a `Dict` import. + + However, when you annotate something inside quotes, like this: + + `x: 'Dict[int]'` + + The annotation is *not* broken down into its components, but rather returns an ast.Constant with a string + value representation of the annotation. In other words, you get one element, with the value `'Dict[int]'`. + + We use a RegEx to extract all the variable names in the annotation into a set we can match against, but + unlike with unwrapped annotations we don't put them all into separate entries, because there would be false + positives for annotations like the one above, since int does not need to be wrapped, but Dict might, but + the inverse would be true for something like: + + `x: 'set[Pattern[str]]'` + + Which could be turned into the following and still be fine: + + `x: set['Pattern[str]'] + + So we don't try to unwrap the annotations as far as possible, we just check if the entire + annotation can be unwrapped or not. + """ + + if any( + name not in self.builtin_names + and ( + item.scope.lookup(name, item, runtime_only=False) is not None + and item.scope.lookup(name, item, runtime_only=True) is None + ) + for name in item.names + ): + # if any of the symbols are only available at type checking time we can't unwrap continue if item.type == 'alias': @@ -1354,6 +1515,9 @@ def excess_quotes(self) -> Flake8Generator: else: error = TC201.format(annotation=item.annotation) + if not self.visitor.futures_annotation: + yield item.lineno, item.col_offset, TC101.format(annotation=item.annotation), None + yield item.lineno, item.col_offset, error, None @property diff --git a/flake8_type_checking/constants.py b/flake8_type_checking/constants.py index 207eba0..8436af5 100644 --- a/flake8_type_checking/constants.py +++ b/flake8_type_checking/constants.py @@ -1,3 +1,4 @@ +import builtins import re import sys @@ -5,7 +6,7 @@ ATTRIBUTE_PROPERTY = '_flake8-type-checking__parent' ANNOTATION_PROPERTY = '_flake8-type-checking__is_annotation' -GLOBAL_PROPERTY = '_flake8-type-checking__is_global' +DUNDER_ALL_PROPERTY = '_flake8-type-checking__in__all__' NAME_RE = re.compile(r'(?= (4, 0, 0) +# Based off of what pyflakes does +builtin_names = set(dir(builtins)) | {'__file__', '__builtins__', '__annotations__', 'WindowsError'} # Error codes TC001 = "TC001 Move application import '{module}' into a type-checking block" diff --git a/flake8_type_checking/types.py b/flake8_type_checking/types.py index 2187eab..1507b0c 100644 --- a/flake8_type_checking/types.py +++ b/flake8_type_checking/types.py @@ -4,20 +4,9 @@ if TYPE_CHECKING: import ast - import sys - from typing import Any, Generator, Optional, Protocol, Tuple, TypedDict, Union + from typing import Any, Generator, Optional, Protocol, Tuple, Union - class FunctionRangesDict(TypedDict): - start: int - end: int - - class FunctionScopeNamesDict(TypedDict): - names: list[str] - - if sys.version_info >= (3, 12): - Declaration = Union[ast.ClassDef, ast.AnnAssign, ast.Assign, ast.TypeAlias] - else: - Declaration = Union[ast.ClassDef, ast.AnnAssign, ast.Assign] + Function = Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda] Import = Union[ast.Import, ast.ImportFrom] Flake8Generator = Generator[Tuple[int, int, str, Any], None, None] @@ -25,5 +14,14 @@ class Name(Protocol): asname: Optional[str] name: str + class HasPosition(Protocol): + @property + def lineno(self) -> int: + pass + + @property + def col_offset(self) -> int: + pass + ImportTypeValue = Literal['APPLICATION', 'THIRD_PARTY', 'BUILTIN', 'FUTURE'] diff --git a/setup.cfg b/setup.cfg index bdda6c8..5f8a1ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ exclude = __pycache__, per-file-ignores = flake8_type_checking/checker.py:SIM114 - flake8_type_checking/types.py:D101 + flake8_type_checking/types.py:D tests/**.py:D103,D205,D400,D102,D101 [mypy] diff --git a/tests/conftest.py b/tests/conftest.py index 77e89d1..b5d3811 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg mock_options.select = [error_code_filter] mock_options.extend_select = None # defaults + mock_options.builtins = [] mock_options.extended_default_select = [] mock_options.enable_extensions = [] mock_options.type_checking_pydantic_enabled = False diff --git a/tests/test_tc008.py b/tests/test_tc008.py index 0868324..2f047d0 100644 --- a/tests/test_tc008.py +++ b/tests/test_tc008.py @@ -51,15 +51,14 @@ set(), ), ( - # avoid false positive for annotations that make - # use of a newly defined class + # this used to yield false negatives but works now, yay textwrap.dedent(''' class Foo(Protocol): pass x: TypeAlias = 'Foo | None' '''), - set(), + {'5:15 ' + TC008.format(alias='Foo | None')}, ), ( # Regression test for Issue #168 @@ -72,19 +71,19 @@ class Foo(Protocol): set(), ), ( - # Inverse regression test for Issue #168 - # The declarations are inside a Protocol so they should not - # count towards declarations inside a type checking block + # Regression test for Issue #168 + # The runtime declaration are inside a Protocol so they should not + # affect the outcome textwrap.dedent(''' if TYPE_CHECKING: + Foo: TypeAlias = str | int + else: class X(Protocol): Foo: str | int Bar: TypeAlias = 'Foo' '''), - { - '6:17 ' + TC008.format(alias='Foo'), - }, + set(), ), ] diff --git a/tests/test_tc101.py b/tests/test_tc101.py index 767b596..e4fc07e 100644 --- a/tests/test_tc101.py +++ b/tests/test_tc101.py @@ -16,7 +16,8 @@ ("x: 'int'", {'1:3 ' + TC101.format(annotation='int')}), ("from __future__ import annotations\nx: 'int'", {'2:3 ' + TC101.format(annotation='int')}), ("if TYPE_CHECKING:\n\timport y\nx: 'y'", set()), - ("x: 'Dict[int]'", {'1:3 ' + TC101.format(annotation='Dict[int]')}), + # this used to return an error, but it's prone to false positives + ("x: 'dict[int]'", set()), ("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict[int]'", set()), ("if TYPE_CHECKING:\n\tFoo: TypeAlias = Any\nx: 'Foo'", set()), # Basic AnnAssign with type-checking block and exact match diff --git a/tests/test_tc201.py b/tests/test_tc201.py index 3e77310..a202ad4 100644 --- a/tests/test_tc201.py +++ b/tests/test_tc201.py @@ -15,6 +15,7 @@ # this used to emit an error before fixing #164 if we wanted to handle # this case once again we could add a whitelist of subscriptable types ("x: 'Dict[int]'", set()), + ("from typing import Dict\nx: 'Dict'", {'2:3 ' + TC201.format(annotation='Dict')}), ("from __future__ import annotations\nx: 'int'", {'2:3 ' + TC201.format(annotation='int')}), ("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict'", set()), ("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict[int]'", set()), @@ -77,15 +78,14 @@ def foo(self) -> 'X': set(), ), ( - # avoid false positive for annotations that make - # use of a newly defined class + # this used to yield false negatives but works now, yay textwrap.dedent(''' class Foo(Protocol): pass x: 'Foo | None' '''), - set(), + {'5:3 ' + TC201.format(annotation='Foo | None')}, ), ( # Regression test for Issue #168 @@ -110,15 +110,21 @@ def bar(*args: 'P.args', **kwargs: 'P.kwargs') -> None: set(), ), ( - # Inverse regression test for Issue #168 - # The declarations are inside a Protocol so they should not - # count towards declarations inside a type checking block + # Regression test for Issue #168 + # The runtime declarations are inside a different scope, so + # they should not affect the outcome in the global scope # This used to raise errors for P.args and P.kwargs and # ideally it still would, but it would require more complex # logic in order to avoid false positives, so for now we # put up with the false negatives here textwrap.dedent(''' if TYPE_CHECKING: + Foo = str | int + Bar: TypeAlias = Foo | None + T = TypeVar('T') + Ts = TypeVarTuple('Ts') + P = ParamSpec('P') + else: class X(Protocol): Foo = str | int Bar: TypeAlias = Foo | None @@ -136,12 +142,7 @@ def foo(a: 'T', *args: Unpack['Ts']) -> None: def bar(*args: 'P.args', **kwargs: 'P.kwargs') -> None: pass '''), - { - '10:3 ' + TC201.format(annotation='Foo | None'), - '11:3 ' + TC201.format(annotation='Bar | None'), - '14:11 ' + TC201.format(annotation='T'), - '14:30 ' + TC201.format(annotation='Ts'), - }, + set(), ), ( # Regression test for type checking only module attributes From f80388354f02a437a7bd9b9bf881ed3fe0101238 Mon Sep 17 00:00:00 2001 From: Daverball Date: Sat, 28 Oct 2023 18:06:47 +0200 Subject: [PATCH 04/18] Adds regression tests for PEP695 and fixes related scoping issues. --- flake8_type_checking/checker.py | 143 +++++++++++++++++++------------- tests/test_tc100.py | 21 +++++ tests/test_tc101.py | 26 +++--- tests/test_tc200.py | 20 +++++ tests/test_tc201.py | 20 +++++ 5 files changed, 161 insertions(+), 69 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 2f619f7..aa4dbff 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -419,9 +419,12 @@ class Scope: be accessible inside a different kind of scope and it may go away entirely when comprehension inlining becomes a thing and it no longer generates a new stack frame. + + For ClassDef/FunctionDef/AsyncFunctionDef we create a tiny virtual scope + for the head to properly handle PEP695 parameter scopes. """ - def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | None = None): + def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | None = None, is_head: bool = False): #: The ast.AST node that created this scope self.node = node @@ -435,6 +438,9 @@ def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | N # symbols self.parent = parent + #: For function scopes/class scopes whether it is just for the head or also the body + self.is_head = is_head + #: The name of the class if this is a scope created by a class definition # classes are not real scopes, i.e. they don't propagate symbols # to inner-scopes, so we need to treat them differently in lookup @@ -468,6 +474,16 @@ def lookup(self, symbol_name: str, use: HasPosition | None = None, runtime_only: if symbol_name == self.class_name: return None + if parent is not None: + # because of our virtual scope for function definition headers + # we also need to check the parent scope for a class name + if self.is_head and symbol_name == parent.class_name: + return None + + # if the next scope up is a head scope we don't want to skip it + if parent.is_head: + return parent.lookup(symbol_name, use, runtime_only) + # skip class scopes when looking up symbols in parent scopes # they're only available inside the class scope itself while parent is not None and parent.class_name is not None: @@ -556,10 +572,10 @@ def __init__( self.unquoted_types_in_casts: list[tuple[int, int, str]] = [] @contextmanager - def create_scope(self, node: ast.Module | ast.ClassDef | Function) -> Iterator[Scope]: + def create_scope(self, node: ast.ClassDef | Function, is_head: bool = True) -> Iterator[Scope]: """Create a new scope.""" parent = self.current_scope - scope = Scope(node, parent=parent) + scope = Scope(node, parent=parent, is_head=is_head) self.scopes.append(scope) self.current_scope = scope yield scope @@ -845,6 +861,9 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: """Create class scope and Note down class names.""" + for expr in node.decorator_list: + self.visit(expr) + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) self.current_scope.symbols[node.name].append( Symbol( @@ -856,28 +875,10 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: ) ) - with self.create_scope(node) as scope: - has_base_classes = node.bases - all_base_classes_ignored = all( - isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist - for base in node.bases - ) - affected_by_pydantic_support = self.pydantic_enabled and has_base_classes and not all_base_classes_ignored - affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) - - if affected_by_pydantic_support or affected_by_cattrs_support: - # When pydantic or cattrs support is enabled, treat any class variable - # annotation as being required at runtime. We need to do this, or - # users run the risk of guarding imports to resources that actually are - # required at runtime. This can be pretty scary, since it will crashes - # the application at runtime. - for element in node.body: - if isinstance(element, ast.AnnAssign): - self.visit(element.annotation) - + with self.create_scope(node, is_head=True) as head_scope: # add PEP695 type parameters to class scope for type_param in getattr(node, 'type_params', ()): - scope.symbols[type_param.name].append( + head_scope.symbols[type_param.name].append( Symbol( type_param.name, type_param.lineno, @@ -886,9 +887,33 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: in_type_checking_block=in_type_checking_block, ) ) - - self.generic_visit(node) - return node + for head_expr in chain(node.bases, node.keywords): + self.visit(head_expr) + + with self.create_scope(node, is_head=False): + has_base_classes = node.bases + all_base_classes_ignored = all( + isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist + for base in node.bases + ) + affected_by_pydantic_support = ( + self.pydantic_enabled and has_base_classes and not all_base_classes_ignored + ) + affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) + + if affected_by_pydantic_support or affected_by_cattrs_support: + # When pydantic or cattrs support is enabled, treat any class variable + # annotation as being required at runtime. We need to do this, or + # users run the risk of guarding imports to resources that actually are + # required at runtime. This can be pretty scary, since it will crashes + # the application at runtime. + for element in node.body: + if isinstance(element, ast.AnnAssign): + self.visit(element.annotation) + + for stmt in node.body: + self.visit(stmt) + return node def visit_Name(self, node: ast.Name) -> ast.Name: """Map names.""" @@ -1109,16 +1134,30 @@ def register_function_annotations(self, node: Function) -> None: """ in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) - # some of the symbols/annotations need to be added to the parent scope - parent_scope = self.current_scope.parent - assert parent_scope is not None + # some of the symbols/annotations need to be added to the head scope + head_scope = self.current_scope.parent + assert head_scope is not None + assert head_scope.is_head + + # add PEP695 type parameters to function head scope + for type_param in getattr(node, 'type_params', ()): + head_scope.symbols[type_param.name].append( + Symbol( + type_param.name, + type_param.lineno, + type_param.col_offset, + # we should be able to treat type vars like arguments + 'argument', + in_type_checking_block=in_type_checking_block, + ) + ) for argument in chain(node.args.args, node.args.kwonlyargs, node.args.posonlyargs): # Map annotations if hasattr(argument, 'annotation') and argument.annotation: - self.add_annotation(argument.annotation, parent_scope) + self.add_annotation(argument.annotation, head_scope) - # Map argument names + # argument names go into the function scope not the head scope self.current_scope.symbols[argument.arg].append( Symbol( argument.arg, @@ -1133,9 +1172,9 @@ def register_function_annotations(self, node: Function) -> None: if arg := getattr(node.args, path, None): # Map annotations if getattr(arg, 'annotation', None): - self.add_annotation(arg.annotation, parent_scope) + self.add_annotation(arg.annotation, head_scope) - # Map argument names + # argument names go into the function scope not the head scope if name := getattr(arg, 'arg', None): self.current_scope.symbols[name].append( Symbol( @@ -1143,24 +1182,11 @@ def register_function_annotations(self, node: Function) -> None: ) ) - # add PEP695 type parameters to function scope - for type_param in getattr(node, 'type_params', ()): - self.current_scope.symbols[type_param.name].append( - Symbol( - type_param.name, - type_param.lineno, - type_param.col_offset, - # we should be able to treat type vars like arguments - 'argument', - in_type_checking_block=in_type_checking_block, - ) - ) - if returns := getattr(node, 'returns', None): - self.add_annotation(returns, parent_scope) + self.add_annotation(returns, head_scope) if name := getattr(node, 'name', None): - parent_scope.symbols[name].append( + head_scope.symbols[name].append( Symbol( name, node.lineno, @@ -1172,21 +1198,21 @@ def register_function_annotations(self, node: Function) -> None: def visit_FunctionDef(self, node: FunctionDef) -> None: """Remove and map function argument- and return annotations.""" - with self.create_scope(node): + with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): super().visit_FunctionDef(node) self.register_function_annotations(node) self.generic_visit(node) def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None: """Remove and map function argument- and return annotations.""" - with self.create_scope(node): + with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): super().visit_AsyncFunctionDef(node) self.register_function_annotations(node) self.generic_visit(node) def visit_Lambda(self, node: ast.Lambda) -> None: """Remove and map argument symbols.""" - with self.create_scope(node): + with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): self.register_function_annotations(node) self.generic_visit(node) @@ -1397,15 +1423,18 @@ def missing_futures_import(self) -> Flake8Generator: # this symbol already caused a TC004/TC009 continue + # Annotations inside `if TYPE_CHECKING:` blocks do not need to be wrapped + # unless they're used before definition, which is already covered by other + # flake8 rules (and also static type checkers) if self.visitor.in_type_checking_block(item.lineno, item.col_offset): continue - if item.scope.lookup(item.annotation, item): - # the symbol is available at runtime, so we're fine - continue - - yield 1, 0, TC100, None - return + if item.scope.lookup(item.annotation, item, runtime_only=False) and not item.scope.lookup( + item.annotation, item, runtime_only=True + ): + # the symbol is only available for type checking + yield 1, 0, TC100, None + return def futures_excess_quotes(self) -> Flake8Generator: """TC101.""" diff --git a/tests/test_tc100.py b/tests/test_tc100.py index fd8ee10..167540f 100644 --- a/tests/test_tc100.py +++ b/tests/test_tc100.py @@ -7,6 +7,8 @@ One thing to note: futures imports should always be at the top of a file, so we only need to check one line. """ +import sys +import textwrap import pytest @@ -43,6 +45,25 @@ ('if TYPE_CHECKING:\n\tfrom typing import Dict\ndef example() -> Dict[str, int]:\n\tpass', {'1:0 ' + TC100}), ] +if sys.version_info >= (3, 12): + # PEP695 tests + examples += [ + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from .types import T + + def foo[T](a: T) -> T: ... + + type Foo[T] = T | None + + class Bar[T](Sequence[T]): + x: T + """), + set(), + ) + ] + @pytest.mark.parametrize(('example', 'expected'), examples) def test_TC100_errors(example, expected): diff --git a/tests/test_tc101.py b/tests/test_tc101.py index e4fc07e..949690f 100644 --- a/tests/test_tc101.py +++ b/tests/test_tc101.py @@ -132,21 +132,23 @@ def foo(self) -> 'X': ] if sys.version_info >= (3, 12): - examples.append( + # PEP695 tests + examples += [ ( - # Make sure we didn't introduce any regressions while solving #167 - # using new type alias syntax - # TODO: Make sure we actually need to wrap Foo if we use future - textwrap.dedent(''' - from __future__ import annotations - if TYPE_CHECKING: - from foo import Foo + textwrap.dedent(""" + def foo[T](a: 'T') -> 'T': + pass - type x = 'Foo' - '''), - set(), + class Bar[T](Set['T']): + x: 'T' + """), + { + '2:14 ' + TC101.format(annotation='T'), + '2:22 ' + TC101.format(annotation='T'), + '6:7 ' + TC101.format(annotation='T'), + }, ) - ) + ] @pytest.mark.parametrize(('example', 'expected'), examples) diff --git a/tests/test_tc200.py b/tests/test_tc200.py index 6e56bd5..f6010d5 100644 --- a/tests/test_tc200.py +++ b/tests/test_tc200.py @@ -2,6 +2,7 @@ File tests TC200: Annotation should be wrapped in quotes """ +import sys import textwrap import pytest @@ -126,6 +127,25 @@ class FooDict(TypedDict): ), ] +if sys.version_info >= (3, 12): + # PEP695 tests + examples += [ + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from .types import T + + def foo[T](a: T) -> T: ... + + type Foo[T] = T | None + + class Bar[T](Sequence[T]): + x: T + """), + set(), + ) + ] + @pytest.mark.parametrize(('example', 'expected'), examples) def test_TC200_errors(example, expected): diff --git a/tests/test_tc201.py b/tests/test_tc201.py index a202ad4..7a59b1f 100644 --- a/tests/test_tc201.py +++ b/tests/test_tc201.py @@ -2,6 +2,7 @@ File tests TC201: Annotation is wrapped in unnecessary quotes """ +import sys import textwrap import pytest @@ -155,6 +156,25 @@ def bar(*args: 'P.args', **kwargs: 'P.kwargs') -> None: ), ] +if sys.version_info >= (3, 12): + # PEP695 tests + examples += [ + ( + textwrap.dedent(""" + def foo[T](a: 'T') -> 'T': + pass + + class Bar[T]: + x: 'T' + """), + { + '2:14 ' + TC201.format(annotation='T'), + '2:22 ' + TC201.format(annotation='T'), + '6:7 ' + TC201.format(annotation='T'), + }, + ) + ] + @pytest.mark.parametrize(('example', 'expected'), examples) def test_TC201_errors(example, expected): From ef8ea611a9f97f9991fa60c136162a616dee09f7 Mon Sep 17 00:00:00 2001 From: Daverball Date: Sat, 28 Oct 2023 19:06:26 +0200 Subject: [PATCH 05/18] Adds some regression tests for #131 --- tests/test_tc004.py | 43 +++++++++++++++++++++++++++++++++++++++++++ tests/test_tc009.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/tests/test_tc004.py b/tests/test_tc004.py index dda847f..5d3e57b 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -127,6 +127,49 @@ def test_function(a, /, b, *, c, **d): """), set(), ), + # regression test for #131 + # a common pattern for inheriting from generics that aren't runtime subscriptable + ( + textwrap.dedent(""" + from wtforms import Field + + if TYPE_CHECKING: + BaseField = Field[int] + else: + BaseField = Field + + class IntegerField(BaseField): + pass + """), + set(), + ), + # Regression test for #131 + # handle scopes correctly + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from a import Foo + + def foo(): + if TYPE_CHECKING: + from b import Foo + else: + Foo = object + + bar: Foo + return bar + + class X: + if TYPE_CHECKING: + from b import Foo + else: + Foo = object + + bar: Foo + + """), + set(), + ), ] diff --git a/tests/test_tc009.py b/tests/test_tc009.py index 635e206..613cf25 100644 --- a/tests/test_tc009.py +++ b/tests/test_tc009.py @@ -106,6 +106,34 @@ def test_function(a, /, b, *, c, **d): """), set(), ), + # Regression test for #131 + # handle scopes correctly + ( + textwrap.dedent(""" + if TYPE_CHECKING: + Foo: something + + def foo(): + if TYPE_CHECKING: + Foo: something_else + else: + Foo = object + + bar: Foo + return bar + + class X: + if TYPE_CHECKING: + class Foo(Protocol): + pass + else: + Foo = object + + bar: Foo + + """), + set(), + ), ] if sys.version_info >= (3, 12): From 2fdcab20606a4fe72e4a463833a4d3bfd2f30141 Mon Sep 17 00:00:00 2001 From: Daverball Date: Sat, 28 Oct 2023 19:18:14 +0200 Subject: [PATCH 06/18] Fixes regression test, we actually need to use the symbol at runtime --- tests/test_tc004.py | 4 ++-- tests/test_tc009.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tc004.py b/tests/test_tc004.py index 5d3e57b..fc6bd2f 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -156,7 +156,7 @@ def foo(): else: Foo = object - bar: Foo + bar: Foo = Foo() return bar class X: @@ -165,7 +165,7 @@ class X: else: Foo = object - bar: Foo + bar: Foo = Foo() """), set(), diff --git a/tests/test_tc009.py b/tests/test_tc009.py index 613cf25..eebdd1c 100644 --- a/tests/test_tc009.py +++ b/tests/test_tc009.py @@ -119,7 +119,7 @@ def foo(): else: Foo = object - bar: Foo + bar: Foo = Foo() return bar class X: @@ -129,7 +129,7 @@ class Foo(Protocol): else: Foo = object - bar: Foo + bar: Foo = Foo() """), set(), From 38810c84a72583902f27ab7917ac9096e3823b5d Mon Sep 17 00:00:00 2001 From: Daverball Date: Sat, 28 Oct 2023 22:06:30 +0200 Subject: [PATCH 07/18] Fixes scope when visiting child nodes of function definitions --- flake8_type_checking/checker.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index aa4dbff..5b6ac82 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -581,6 +581,14 @@ def create_scope(self, node: ast.ClassDef | Function, is_head: bool = True) -> I yield scope self.current_scope = parent + @contextmanager + def change_scope(self, scope: Scope) -> Iterator[None]: + """Change to a different scope.""" + old_scope = self.current_scope + self.current_scope = scope + yield + self.current_scope = old_scope + @property def typing_module_name(self) -> str: """ @@ -876,7 +884,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: ) with self.create_scope(node, is_head=True) as head_scope: - # add PEP695 type parameters to class scope + # add PEP695 type parameters to class head scope for type_param in getattr(node, 'type_params', ()): head_scope.symbols[type_param.name].append( Symbol( @@ -1182,6 +1190,10 @@ def register_function_annotations(self, node: Function) -> None: ) ) + # we need to visit the arguments in the head scope instead of the body scope + with self.change_scope(head_scope): + self.visit(node.args) + if returns := getattr(node, 'returns', None): self.add_annotation(returns, head_scope) @@ -1196,25 +1208,36 @@ def register_function_annotations(self, node: Function) -> None: ) ) + if isinstance(node, ast.Lambda): + self.visit(node.body) + else: + for stmt in node.body: + self.visit(stmt) + def visit_FunctionDef(self, node: FunctionDef) -> None: """Remove and map function argument- and return annotations.""" + for expr in node.decorator_list: + self.visit(expr) + with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): super().visit_FunctionDef(node) self.register_function_annotations(node) - self.generic_visit(node) def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None: """Remove and map function argument- and return annotations.""" + for expr in node.decorator_list: + self.visit(expr) + with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): super().visit_AsyncFunctionDef(node) self.register_function_annotations(node) - self.generic_visit(node) + + self.visit(node.args) def visit_Lambda(self, node: ast.Lambda) -> None: """Remove and map argument symbols.""" with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): self.register_function_annotations(node) - self.generic_visit(node) def register_unquoted_type_in_typing_cast(self, node: ast.Call) -> None: """Find typing.cast() calls with the type argument unquoted.""" From 48fbeda52be155cd455efa7650e26258109be9d4 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Nov 2023 09:37:52 +0100 Subject: [PATCH 08/18] Cleans up redundant head scope for ClassDef. Fixes runtime symbol lookup into outer scopes. Treats NamedExpr like definitions. Improves scope handling of comprehensions and if expressions. --- flake8_type_checking/checker.py | 198 ++++++++++++++++++++++++++------ flake8_type_checking/types.py | 1 + 2 files changed, 166 insertions(+), 33 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 5b6ac82..fa8607f 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -56,7 +56,15 @@ def ast_unparse(node: ast.AST) -> str: from collections.abc import Iterator from typing import Any, Optional, Union - from flake8_type_checking.types import Flake8Generator, Function, HasPosition, Import, ImportTypeValue, Name + from flake8_type_checking.types import ( + Comprehension, + Flake8Generator, + Function, + HasPosition, + Import, + ImportTypeValue, + Name, + ) class AttrsMixin: @@ -420,8 +428,9 @@ class Scope: when comprehension inlining becomes a thing and it no longer generates a new stack frame. - For ClassDef/FunctionDef/AsyncFunctionDef we create a tiny virtual scope - for the head to properly handle PEP695 parameter scopes. + For FunctionDef/AsyncFunctionDef we create a tiny virtual scope for the + head containing only the function signature to properly handle PEP695 + type parameter scopes. """ def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | None = None, is_head: bool = False): @@ -438,7 +447,11 @@ def __init__(self, node: ast.Module | ast.ClassDef | Function, parent: Scope | N # symbols self.parent = parent - #: For function scopes/class scopes whether it is just for the head or also the body + #: For function scopes whether it is just for the head or also the body + # This is to deal with the fact that defaults and annotations are part + # of the outer scope, but type params are private to the function without + # leaking outside, so there is a thin faux-scope around the head which + # contains just the type params self.is_head = is_head #: The name of the class if this is a scope created by a class definition @@ -489,6 +502,19 @@ def lookup(self, symbol_name: str, use: HasPosition | None = None, runtime_only: while parent is not None and parent.class_name is not None: parent = parent.parent + # we only propagate the use to the outer scope if we're a head-scope or + # a class scope, this is to deal with the fact that even if a symbol is + # defined after a function definition, it will still be available inside + # the function. If the function is called before the symbol actually + # exists, then an UnboundLocalError is raised, we can't easily detect + # this case, so there's no point in trying to handle it. + # Inversely, in case of a type checking lookup if we're not using a + # futures import the location does matter even in outer scope, since + # annotations are evaluated immediately, so that's why we're doing + # this inside the `if runtime_only` block. + if not self.is_head and not self.class_name: + use = None + # we're done looking up and didn't find anything if parent is None: return None @@ -571,6 +597,9 @@ def __init__( self.typing_cast_aliases: set[str] = set() self.unquoted_types_in_casts: list[tuple[int, int, str]] = [] + #: For tracking which comprehension/IfExp we're currently inside of + self.active_context: Optional[Comprehension | ast.IfExp] = None + @contextmanager def create_scope(self, node: ast.ClassDef | Function, is_head: bool = True) -> Iterator[Scope]: """Create a new scope.""" @@ -883,10 +912,10 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: ) ) - with self.create_scope(node, is_head=True) as head_scope: - # add PEP695 type parameters to class head scope + with self.create_scope(node) as scope: + # add PEP695 type parameters to class scope for type_param in getattr(node, 'type_params', ()): - head_scope.symbols[type_param.name].append( + scope.symbols[type_param.name].append( Symbol( type_param.name, type_param.lineno, @@ -898,30 +927,27 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: for head_expr in chain(node.bases, node.keywords): self.visit(head_expr) - with self.create_scope(node, is_head=False): - has_base_classes = node.bases - all_base_classes_ignored = all( - isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist - for base in node.bases - ) - affected_by_pydantic_support = ( - self.pydantic_enabled and has_base_classes and not all_base_classes_ignored - ) - affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) - - if affected_by_pydantic_support or affected_by_cattrs_support: - # When pydantic or cattrs support is enabled, treat any class variable - # annotation as being required at runtime. We need to do this, or - # users run the risk of guarding imports to resources that actually are - # required at runtime. This can be pretty scary, since it will crashes - # the application at runtime. - for element in node.body: - if isinstance(element, ast.AnnAssign): - self.visit(element.annotation) - - for stmt in node.body: - self.visit(stmt) - return node + has_base_classes = node.bases + all_base_classes_ignored = all( + isinstance(base, ast.Name) and base.id in self.pydantic_enabled_baseclass_passlist + for base in node.bases + ) + affected_by_pydantic_support = self.pydantic_enabled and has_base_classes and not all_base_classes_ignored + affected_by_cattrs_support = self.cattrs_enabled and self.is_attrs_class(node) + + if affected_by_pydantic_support or affected_by_cattrs_support: + # When pydantic or cattrs support is enabled, treat any class variable + # annotation as being required at runtime. We need to do this, or + # users run the risk of guarding imports to resources that actually are + # required at runtime. This can be pretty scary, since it will crashes + # the application at runtime. + for element in node.body: + if isinstance(element, ast.AnnAssign): + self.visit(element.annotation) + + for stmt in node.body: + self.visit(stmt) + return node def visit_Name(self, node: ast.Name) -> ast.Name: """Map names.""" @@ -1040,6 +1066,9 @@ def visit_Assign(self, node: ast.Assign) -> ast.Assign: in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) for target in node.targets: + # each target can either be an ast.Name or an ast.Tuple/ast.List containing + # ast.Names, but there's also assignments to ast.Subscript/ast.Attribute, we + # only need to record new symbols for ast.Name for name in getattr(target, 'elts', [target]): if not hasattr(name, 'id'): continue @@ -1232,13 +1261,116 @@ def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None: super().visit_AsyncFunctionDef(node) self.register_function_annotations(node) - self.visit(node.args) - def visit_Lambda(self, node: ast.Lambda) -> None: """Remove and map argument symbols.""" with self.create_scope(node, is_head=True), self.create_scope(node, is_head=False): self.register_function_annotations(node) + @contextmanager + def set_context(self, node: Comprehension | ast.IfExp) -> Iterator[None]: + """ + Set the active context for ast.NamedExpr/ast.comprehension. + + This is to deal with the fact that comprehensions and ast.IfExp are + evaluated out of order, so in order for our symbol lookups to be a + little bit more accurate we need to attach declarations to the active + context, rather than the node itself. + """ + old_context = self.active_context + self.active_context = node + yield + self.active_context = old_context + + def visit_ListComp(self, node: ast.ListComp) -> None: + """Map symbols in list comprehension.""" + with self.set_context(node): + self.generic_visit(node) + + def visit_SetComp(self, node: ast.SetComp) -> None: + """Map symbols in set comprehension.""" + with self.set_context(node): + self.generic_visit(node) + + def visit_DictComp(self, node: ast.DictComp) -> None: + """Map symbols in dict comprehension.""" + with self.set_context(node): + self.generic_visit(node) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + """Map symbols in generator expressions.""" + with self.set_context(node): + self.generic_visit(node) + + def visit_comprehension(self, node: ast.comprehension) -> None: + """ + Map all the symbols in a comprehension. + + Comprehensions are a bit of a special case, since the expressions + are evaluated out of order, which complicates the symbol lookup. + + We get around that by attaching all targets and all NamedExpr to + the comprehesion rather than themselves. So everyone inside the + comprehension can see the symbols. + + This is technically not quite correct, since inside an individual + if expression the order of symbols still matters. But we don't try + to catch every single case here, we just use this to figure out + if type checking symbols are used at runtime, so it's fine if we're + a little lax here, since there are no annotations inside comprehensions + anyways. + """ + in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) + + assert self.active_context is not None + for name in getattr(node.target, 'elts', [node.target]): + if not hasattr(name, 'id'): + continue + + self.current_scope.symbols[name.id].append( + Symbol( + name.id, + # these symbols can be used in elt/key/value even though + # those appear before the comprehension, so we use the + # start of the expression as the location of the definition + self.active_context.lineno, + self.active_context.col_offset, + 'definition', + in_type_checking_block=in_type_checking_block, + ) + ) + + self.visit(node.iter) + for if_expr in node.ifs: + self.visit(if_expr) + + def visit_IfExp(self, node: ast.IfExp) -> ast.IfExp: + """Set the context for named expressions.""" + with self.set_context(node): + self.generic_visit(node) + return node + + def visit_NamedExpr(self, node: ast.NamedExpr) -> ast.NamedExpr: + """ + Keep track of variable definitions. + + If we're inside a comprehension/IfExp then we treat definitions as if + they occured at the start of the expression to deal with the out of + order evaluation of comprehensions and if expressions. + """ + location_node = self.active_context or node + self.current_scope.symbols[node.target.id].append( + Symbol( + node.target.id, + location_node.lineno, + location_node.col_offset, + 'definition', + in_type_checking_block=self.in_type_checking_block(node.lineno, node.col_offset), + ) + ) + self.visit(node.value) + + return node + def register_unquoted_type_in_typing_cast(self, node: ast.Call) -> None: """Find typing.cast() calls with the type argument unquoted.""" func = node.func diff --git a/flake8_type_checking/types.py b/flake8_type_checking/types.py index 1507b0c..8e2af34 100644 --- a/flake8_type_checking/types.py +++ b/flake8_type_checking/types.py @@ -7,6 +7,7 @@ from typing import Any, Generator, Optional, Protocol, Tuple, Union Function = Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda] + Comprehension = Union[ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp] Import = Union[ast.Import, ast.ImportFrom] Flake8Generator = Generator[Tuple[int, int, str, Any], None, None] From 6b5dfab8452358f2f26c6cbaed72f2c93f1fd994 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Nov 2023 10:00:40 +0100 Subject: [PATCH 09/18] Fixes check (ast.comprehension has no lineno/col_offset) Adds some regression tests for the more complex scoping rules. --- flake8_type_checking/checker.py | 3 +-- tests/test_tc004.py | 47 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index fa8607f..55d545f 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -1319,9 +1319,8 @@ def visit_comprehension(self, node: ast.comprehension) -> None: a little lax here, since there are no annotations inside comprehensions anyways. """ - in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) - assert self.active_context is not None + in_type_checking_block = self.in_type_checking_block(self.active_context.lineno, self.active_context.col_offset) for name in getattr(node.target, 'elts', [node.target]): if not hasattr(name, 'id'): continue diff --git a/tests/test_tc004.py b/tests/test_tc004.py index fc6bd2f..40ca207 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -167,9 +167,54 @@ class X: bar: Foo = Foo() - """), + """), + set(), + ), + # some more complex scope cases where we shouldn't report a + # runtime use of a typing only symbol, because it is shadowed + # by an inline definition + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from a import foo + + (foo for foo in x) + [foo for y in x if (foo := y)] + {{foo for y in x for foo in y}} + {{foo: bar for y, foo in x for bar in y}} + x = foo if (foo := y) else None + + """), set(), ), + # Inverse test for complex cases, we use five different symbols + # since comprehension scopes will probably leak their iterator + # variables in the future, just like regular loops, due to + # comprehension inlining. So we currently treat definitions inside + # a comprehension as if it occured outside. We may change our minds + # about this in the future, but comprehension scopes have a bunch of + # special rules (such as being able to access enclosing class scopes) + # so it's either to not treat them as separate scopes for now. + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from foo import u, w, x, y, z + + (u(a) for a in foo) + [w(a) for a in foo] + {{x(a) for a in foo}} + {{a: y for a in foo}} + x = foo if (foo := z) else None + + """), + { + '3:0 ' + TC004.format(module='u'), + '3:0 ' + TC004.format(module='w'), + '3:0 ' + TC004.format(module='x'), + '3:0 ' + TC004.format(module='y'), + '3:0 ' + TC004.format(module='z'), + }, + ), ] From 393681dc03fedc20d7cc594fbbfd17bfc0437483 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Nov 2023 10:07:25 +0100 Subject: [PATCH 10/18] Extends docstring --- flake8_type_checking/checker.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 55d545f..612c280 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -1062,15 +1062,31 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self.visit(node.value) def visit_Assign(self, node: ast.Assign) -> ast.Assign: - """Keep track of variable definitions.""" + """ + Keep track of variable definitions. + + Assignments are quite complex and can contain multiple targets such as: + + `a = b = c = ...` + + But each target can also be one of many things, such as a single name, a + list of names, a subscript or an attribute. We only care about names and + lists of names, i.e.: + + `a = b, c = ...` + + But not something like: + + `foo[a] = foo.bar = ...` + + """ in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) for target in node.targets: - # each target can either be an ast.Name or an ast.Tuple/ast.List containing - # ast.Names, but there's also assignments to ast.Subscript/ast.Attribute, we - # only need to record new symbols for ast.Name + # each target can either be a single node or an ast.Tuple/ast.List of nodes for name in getattr(target, 'elts', [target]): if not hasattr(name, 'id'): + # if the node isn't an ast.Name we don't record anything continue self.current_scope.symbols[name.id].append( From b9f0e60967edf456336f4bbc75d46ce683e58f23 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Nov 2023 13:29:53 +0100 Subject: [PATCH 11/18] Refactors TC100 into TC200 check to reduce repetition Avoids warnings by removing `ast.Ellipsis` check, which no longer exists as an actual type, it's just a factory for `ast.Constant` now. --- flake8_type_checking/checker.py | 67 ++++++++++----------------------- 1 file changed, 20 insertions(+), 47 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 612c280..b4214b6 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -973,7 +973,7 @@ def add_annotation( self, node: ast.AST, scope: Scope, type: Literal['annotation', 'alias', 'new-alias'] = 'annotation' ) -> None: """Map all annotations on an AST node.""" - if isinstance(node, ast.Ellipsis) or node is None: + if node is None: return if isinstance(node, ast.BinOp): if not isinstance(node.op, ast.BitOr): @@ -1478,13 +1478,11 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None: self.empty_type_checking_blocks, # TC006 self.unquoted_type_in_cast, - # TC100 - self.missing_futures_import, + # TC100, TC200, TC007 + self.missing_quotes_or_futures_import, # TC101 self.futures_excess_quotes, - # TC200, TC007 - self.missing_quotes, - # TC201, TC008 + # TC101, TC201, TC008 self.excess_quotes, ] @@ -1573,16 +1571,13 @@ def unquoted_type_in_cast(self) -> Flake8Generator: for lineno, col_offset, annotation in self.visitor.unquoted_types_in_casts: yield lineno, col_offset, TC006.format(annotation=annotation), None - def missing_futures_import(self) -> Flake8Generator: - """TC100.""" - if self.visitor.futures_annotation: - return + def missing_quotes_or_futures_import(self) -> Flake8Generator: + """TC100, TC200 and TC007.""" + encountered_missing_quotes = False - # if any of the symbols imported/declared in type checking blocks are used - # in an annotation outside a type checking block, then we need to emit TC100 for item in self.visitor.unwrapped_annotations: - if item.type != 'annotation': - # aliases are unaffected by futures import + # A new style alias does never need to be wrapped + if item.type == 'new-alias': continue if item.annotation in self.builtin_names: @@ -1603,8 +1598,17 @@ def missing_futures_import(self) -> Flake8Generator: item.annotation, item, runtime_only=True ): # the symbol is only available for type checking - yield 1, 0, TC100, None - return + if item.type == 'alias': + error = TC007.format(alias=item.annotation) + else: + encountered_missing_quotes = True + error = TC200.format(annotation=item.annotation) + yield item.lineno, item.col_offset, error, None + + # if any of the symbols imported/declared in type checking blocks are used + # in an annotation outside a type checking block, then we need to emit TC100 + if encountered_missing_quotes and not self.visitor.futures_annotation: + yield 1, 0, TC100, None def futures_excess_quotes(self) -> Flake8Generator: """TC101.""" @@ -1618,37 +1622,6 @@ def futures_excess_quotes(self) -> Flake8Generator: # If no futures imports are present, then we use the generic excess_quotes function # since the logic is the same as TC201 - def missing_quotes(self) -> Flake8Generator: - """TC200 and TC007.""" - for item in self.visitor.unwrapped_annotations: - # A new style alias does never need to be wrapped - if item.type == 'new-alias': - continue - - if item.annotation in self.builtin_names: - # this symbol is always available at runtime - continue - - if item.annotation in self.used_type_checking_names: - # this symbol already caused a TC004/TC009 - continue - - # Annotations inside `if TYPE_CHECKING:` blocks do not need to be wrapped - # unless they're used before definition, which is already covered by other - # flake8 rules (and also static type checkers) - if self.visitor.in_type_checking_block(item.lineno, item.col_offset): - continue - - if item.scope.lookup(item.annotation, item, runtime_only=False) and not item.scope.lookup( - item.annotation, item, runtime_only=True - ): - # the symbol is only available for type checking - if item.type == 'alias': - error = TC007.format(alias=item.annotation) - else: - error = TC200.format(annotation=item.annotation) - yield item.lineno, item.col_offset, error, None - def excess_quotes(self) -> Flake8Generator: """TC101, TC201 and TC008.""" for item in self.visitor.wrapped_annotations: From 50ba61f4de9156dce0b94db7e7d54d7adfd1b366 Mon Sep 17 00:00:00 2001 From: Daverball Date: Wed, 8 Nov 2023 22:21:23 +0100 Subject: [PATCH 12/18] Fixes scoping regression tests We actually need individual symbols for both tests, the comprehensions will leak their loop variables, so we need to use different ones for each expression to ensure we properly test that each one defines a new symbol that is accessible from within the entire expression. --- tests/test_tc004.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/test_tc004.py b/tests/test_tc004.py index 40ca207..a3fdbb9 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -170,37 +170,37 @@ class X: """), set(), ), - # some more complex scope cases where we shouldn't report a + # Some more complex scope cases where we shouldn't report a # runtime use of a typing only symbol, because it is shadowed - # by an inline definition + # by an inline definition. We use five different symbols + # since comprehension scopes will probably leak their iterator + # variables in the future, just like regular loops, due to + # comprehension inlining. So we currently treat definitions inside + # a comprehension as if it occured outside. We may change our minds + # about this in the future, but comprehension scopes have a bunch of + # special rules (such as being able to access enclosing class scopes) + # so it's either to not treat them as separate scopes for now. ( textwrap.dedent(""" if TYPE_CHECKING: - from a import foo + from foo import v, w, x, y, z - (foo for foo in x) - [foo for y in x if (foo := y)] - {{foo for y in x for foo in y}} - {{foo: bar for y, foo in x for bar in y}} - x = foo if (foo := y) else None + (v for v in foo) + [w for bar in foo if (w := bar)] + {{x for bar in foo for x in bar}} + {{y: baz for y, bar in foo for baz in y}} + foo = z if (z := bar) else None """), set(), ), - # Inverse test for complex cases, we use five different symbols - # since comprehension scopes will probably leak their iterator - # variables in the future, just like regular loops, due to - # comprehension inlining. So we currently treat definitions inside - # a comprehension as if it occured outside. We may change our minds - # about this in the future, but comprehension scopes have a bunch of - # special rules (such as being able to access enclosing class scopes) - # so it's either to not treat them as separate scopes for now. + # Inverse test for complex cases ( textwrap.dedent(""" if TYPE_CHECKING: - from foo import u, w, x, y, z + from foo import v, w, x, y, z - (u(a) for a in foo) + (v(a) for a in foo) [w(a) for a in foo] {{x(a) for a in foo}} {{a: y for a in foo}} @@ -208,7 +208,7 @@ class X: """), { - '3:0 ' + TC004.format(module='u'), + '3:0 ' + TC004.format(module='v'), '3:0 ' + TC004.format(module='w'), '3:0 ' + TC004.format(module='x'), '3:0 ' + TC004.format(module='y'), From d54b13c712294d51633e9a9ac2d167d6d2cb71fb Mon Sep 17 00:00:00 2001 From: Daverball Date: Mon, 13 Nov 2023 08:40:12 +0100 Subject: [PATCH 13/18] Moves one of the regression tests from TC004 to TC009 It would never have emitted a TC004, even if we made a mistake it would always have emitted a TC009. I've added an inverse test to convince ourselves that this is true. --- tests/test_tc004.py | 16 ------ tests/test_tc009.py | 120 +++++++++++++++++++++++++++----------------- 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/tests/test_tc004.py b/tests/test_tc004.py index a3fdbb9..ef42fac 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -127,22 +127,6 @@ def test_function(a, /, b, *, c, **d): """), set(), ), - # regression test for #131 - # a common pattern for inheriting from generics that aren't runtime subscriptable - ( - textwrap.dedent(""" - from wtforms import Field - - if TYPE_CHECKING: - BaseField = Field[int] - else: - BaseField = Field - - class IntegerField(BaseField): - pass - """), - set(), - ), # Regression test for #131 # handle scopes correctly ( diff --git a/tests/test_tc009.py b/tests/test_tc009.py index eebdd1c..23999d7 100644 --- a/tests/test_tc009.py +++ b/tests/test_tc009.py @@ -18,92 +18,92 @@ # Used in file ( textwrap.dedent(""" - from typing import TYPE_CHECKING + from typing import TYPE_CHECKING - if TYPE_CHECKING: - datetime = Any + if TYPE_CHECKING: + datetime = Any - x = datetime - """), + x = datetime + """), {'5:4 ' + TC009.format(name='datetime')}, ), # Used in function ( textwrap.dedent(""" - from typing import TYPE_CHECKING + from typing import TYPE_CHECKING - if TYPE_CHECKING: - class date: ... + if TYPE_CHECKING: + class date: ... - def example(): - return date() - """), + def example(): + return date() + """), {'5:4 ' + TC009.format(name='date')}, ), # Used, but only used inside the type checking block ( textwrap.dedent(""" - if TYPE_CHECKING: - class date: ... + if TYPE_CHECKING: + class date: ... - CustomType = date - """), + CustomType = date + """), set(), ), # Used for typing only ( textwrap.dedent(""" - if TYPE_CHECKING: - class date: ... + if TYPE_CHECKING: + class date: ... - def example(*args: date, **kwargs: date): - return + def example(*args: date, **kwargs: date): + return - my_type: Type[date] | date - """), + my_type: Type[date] | date + """), set(), ), ( textwrap.dedent(""" - from __future__ import annotations + from __future__ import annotations - from typing import TYPE_CHECKING + from typing import TYPE_CHECKING - if TYPE_CHECKING: - class AsyncIterator: ... + if TYPE_CHECKING: + class AsyncIterator: ... - class Example: + class Example: - async def example(self) -> AsyncIterator[list[str]]: - yield 0 - """), + async def example(self) -> AsyncIterator[list[str]]: + yield 0 + """), set(), ), ( textwrap.dedent(""" - from typing import TYPE_CHECKING - from weakref import WeakKeyDictionary + from typing import TYPE_CHECKING + from weakref import WeakKeyDictionary - if TYPE_CHECKING: - Any = str + if TYPE_CHECKING: + Any = str - d = WeakKeyDictionary["Any", "Any"]() - """), + d = WeakKeyDictionary["Any", "Any"]() + """), set(), ), ( textwrap.dedent(""" - if TYPE_CHECKING: - a = int - b: TypeAlias = str - class c(Protocol): ... - class d(TypedDict): ... - - def test_function(a, /, b, *, c, **d): - print(a, b, c, d) - """), + if TYPE_CHECKING: + a = int + b: TypeAlias = str + class c(Protocol): ... + class d(TypedDict): ... + + def test_function(a, /, b, *, c, **d): + print(a, b, c, d) + """), set(), ), # Regression test for #131 @@ -131,9 +131,39 @@ class Foo(Protocol): bar: Foo = Foo() - """), + """), + set(), + ), + # regression test for #131 + # a common pattern for inheriting from generics that aren't runtime subscriptable + ( + textwrap.dedent(""" + from wtforms import Field + + if TYPE_CHECKING: + BaseField = Field[int] + else: + BaseField = Field + + class IntegerField(BaseField): + pass + """), set(), ), + # inverse regression test for #131 + # here we forgot the else so it will complain about BaseField + ( + textwrap.dedent(""" + from wtforms import Field + + if TYPE_CHECKING: + BaseField = Field[int] + + class IntegerField(BaseField): + pass + """), + {'5:4 ' + TC009.format(name='BaseField')}, + ), ] if sys.version_info >= (3, 12): From 0e48ae89b35d61c05fe49ec3aa577f1b4adf125e Mon Sep 17 00:00:00 2001 From: Daverball Date: Mon, 13 Nov 2023 08:58:43 +0100 Subject: [PATCH 14/18] Fixes TC004/TC009 emitting too many errors when symbols are redefined. --- flake8_type_checking/checker.py | 9 ++++++++- tests/test_tc004.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index b4214b6..11523c4 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -1543,9 +1543,16 @@ def used_type_checking_symbols(self) -> Flake8Generator: else: lookup_from = use - if scope.lookup(symbol.name, lookup_from): + if scope.lookup(symbol.name, lookup_from, runtime_only=True): # the symbol is available at runtime so we're fine continue + elif scope.lookup(symbol.name, lookup_from, runtime_only=False) is not symbol: + # we are being shadowed so no need to emit an error, we can emit an error + # for the shadowed name instead, this relies more heavily on giving us the + # closest match when looking up symbols, so we may sometimes get this wrong + # in cases where the symbol has been redefined within the same scope. But + # the most important case is nested scopes, so this is probably fine. + continue if symbol.type == 'import': msg = TC004.format(module=symbol.name) diff --git a/tests/test_tc004.py b/tests/test_tc004.py index ef42fac..f4624b7 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -154,6 +154,33 @@ class X: """), set(), ), + # Inverse Regression test for #131 + # handle scopes correctly, so we should get an error for the imports + # in the inner scopes, but not one for the outer scope. + ( + textwrap.dedent(""" + if TYPE_CHECKING: + from a import Foo + + def foo(): + if TYPE_CHECKING: + from b import Foo + + bar: Foo = Foo() + return bar + + class X: + if TYPE_CHECKING: + from b import Foo + + bar: Foo = Foo() + + """), + { + '7:0 ' + TC004.format(module='Foo'), + '14:0 ' + TC004.format(module='Foo'), + }, + ), # Some more complex scope cases where we shouldn't report a # runtime use of a typing only symbol, because it is shadowed # by an inline definition. We use five different symbols From f256c675bc9e948d03098bc5c2acab44d56b681f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Lilleb=C3=B8=20Gundersen?= Date: Sat, 25 Nov 2023 10:53:33 +0100 Subject: [PATCH 15/18] chore: Update pre-commit hooks --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ee2748..2a54689 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,10 @@ repos: - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.11.0 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-ast - id: check-added-large-files @@ -34,7 +34,7 @@ repos: 'flake8-type-checking==2.0.6', ] - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade args: [ "--py36-plus", "--py37-plus", "--py38-plus", '--keep-runtime-typing' ] @@ -43,7 +43,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.5.1 + rev: v1.7.1 hooks: - id: mypy args: From 885932130f4bb4808b028bf7fe410c9d6347cc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Lilleb=C3=B8=20Gundersen?= Date: Sat, 25 Nov 2023 10:53:51 +0100 Subject: [PATCH 16/18] chore: Run new black formatter version --- tests/test_errors.py | 1 + tests/test_should_warn.py | 1 + tests/test_tc004.py | 1 + tests/test_tc005.py | 1 + tests/test_tc006.py | 1 + tests/test_tc007.py | 1 + tests/test_tc008.py | 23 +++++++++++------------ tests/test_tc009.py | 1 + tests/test_tc100.py | 1 + tests/test_tc101.py | 1 + tests/test_tc200.py | 1 + tests/test_tc201.py | 1 + 12 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/test_errors.py b/tests/test_errors.py index 2ef0ea9..3d57672 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,4 +1,5 @@ """Contains special test cases that fall outside the scope of remaining test files.""" + import textwrap import pytest diff --git a/tests/test_should_warn.py b/tests/test_should_warn.py index cc8f888..20d62a3 100644 --- a/tests/test_should_warn.py +++ b/tests/test_should_warn.py @@ -3,6 +3,7 @@ The behavior of TC100 and TC200 errors should be opt-in. """ + import os import re from importlib.metadata import version diff --git a/tests/test_tc004.py b/tests/test_tc004.py index f4624b7..c5c0dd6 100644 --- a/tests/test_tc004.py +++ b/tests/test_tc004.py @@ -4,6 +4,7 @@ >> Move import out of type-checking block. Import is used for more than type hinting. """ + import textwrap import pytest diff --git a/tests/test_tc005.py b/tests/test_tc005.py index c4abe4c..885f7ff 100644 --- a/tests/test_tc005.py +++ b/tests/test_tc005.py @@ -6,6 +6,7 @@ Sometimes auto-formatting tools for removing redundant imports (i.e. Pycharms) will leave behind empty type-checking blocks. This just flags them as redundant. """ + import textwrap import pytest diff --git a/tests/test_tc006.py b/tests/test_tc006.py index f35329e..3d14fa3 100644 --- a/tests/test_tc006.py +++ b/tests/test_tc006.py @@ -8,6 +8,7 @@ significant overhead in hot paths. This can be avoided by quoting the type so that it isn't resolved at runtime. """ + import textwrap import pytest diff --git a/tests/test_tc007.py b/tests/test_tc007.py index b93bdf5..6b736e8 100644 --- a/tests/test_tc007.py +++ b/tests/test_tc007.py @@ -2,6 +2,7 @@ File tests TC007: Type alias should be wrapped in quotes """ + import sys import textwrap diff --git a/tests/test_tc008.py b/tests/test_tc008.py index 2f047d0..e0d9dd2 100644 --- a/tests/test_tc008.py +++ b/tests/test_tc008.py @@ -2,6 +2,7 @@ File tests TC008: Type alias is wrapped in unnecessary quotes """ + from __future__ import annotations import sys @@ -88,23 +89,21 @@ class X(Protocol): ] if sys.version_info >= (3, 12): - examples.extend( - [ - ( - # new style type alias should never be wrapped - textwrap.dedent(''' + examples.extend([ + ( + # new style type alias should never be wrapped + textwrap.dedent(''' if TYPE_CHECKING: type Foo = 'str' type Bar = 'Foo' '''), - { - '3:15 ' + TC008.format(alias='str'), - '5:11 ' + TC008.format(alias='Foo'), - }, - ) - ] - ) + { + '3:15 ' + TC008.format(alias='str'), + '5:11 ' + TC008.format(alias='Foo'), + }, + ) + ]) @pytest.mark.parametrize(('example', 'expected'), examples) diff --git a/tests/test_tc009.py b/tests/test_tc009.py index 23999d7..b41e5e5 100644 --- a/tests/test_tc009.py +++ b/tests/test_tc009.py @@ -4,6 +4,7 @@ >> Move declaration out of type-checking block. Variable is used for more than type hinting. """ + import sys import textwrap diff --git a/tests/test_tc100.py b/tests/test_tc100.py index 167540f..00897c1 100644 --- a/tests/test_tc100.py +++ b/tests/test_tc100.py @@ -7,6 +7,7 @@ One thing to note: futures imports should always be at the top of a file, so we only need to check one line. """ + import sys import textwrap diff --git a/tests/test_tc101.py b/tests/test_tc101.py index 949690f..d016684 100644 --- a/tests/test_tc101.py +++ b/tests/test_tc101.py @@ -2,6 +2,7 @@ File tests TC101: Annotation is wrapped in unnecessary quotes """ + import sys import textwrap diff --git a/tests/test_tc200.py b/tests/test_tc200.py index f6010d5..b0e4364 100644 --- a/tests/test_tc200.py +++ b/tests/test_tc200.py @@ -2,6 +2,7 @@ File tests TC200: Annotation should be wrapped in quotes """ + import sys import textwrap diff --git a/tests/test_tc201.py b/tests/test_tc201.py index 7a59b1f..2fe1ea5 100644 --- a/tests/test_tc201.py +++ b/tests/test_tc201.py @@ -2,6 +2,7 @@ File tests TC201: Annotation is wrapped in unnecessary quotes """ + import sys import textwrap From fe4c06b96e5accef7c1dac22e3a0bdfbce4db59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Lilleb=C3=B8=20Gundersen?= Date: Sat, 25 Nov 2023 11:01:12 +0100 Subject: [PATCH 17/18] chore: Update lockfile --- poetry.lock | 108 ++++++++++++++++++++++++++-------------------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/poetry.lock b/poetry.lock index 41c3b0f..7999a44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,20 +24,21 @@ files = [ [[package]] name = "asttokens" -version = "2.4.0" +version = "2.4.1" description = "Annotate AST trees with source code positions" optional = false python-versions = "*" files = [ - {file = "asttokens-2.4.0-py2.py3-none-any.whl", hash = "sha256:cf8fc9e61a86461aa9fb161a14a0841a03c405fa829ac6b202670b3495d2ce69"}, - {file = "asttokens-2.4.0.tar.gz", hash = "sha256:2e0171b991b2c959acc6c49318049236844a5da1d65ba2672c4880c1c894834e"}, + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, ] [package.dependencies] six = ">=1.12.0" [package.extras] -test = ["astroid", "pytest"] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] [[package]] name = "backcall" @@ -171,13 +172,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.3" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -185,13 +186,13 @@ test = ["pytest (>=6)"] [[package]] name = "executing" -version = "2.0.0" +version = "2.0.1" description = "Get the currently executing AST node of a frame, and other information" optional = false -python-versions = "*" +python-versions = ">=3.5" files = [ - {file = "executing-2.0.0-py2.py3-none-any.whl", hash = "sha256:06df6183df67389625f4e763921c6cf978944721abf3e714000200aab95b0657"}, - {file = "executing-2.0.0.tar.gz", hash = "sha256:0ff053696fdeef426cda5bd18eacd94f82c91f49823a2e9090124212ceea9b08"}, + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, ] [package.extras] @@ -199,19 +200,19 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth [[package]] name = "filelock" -version = "3.12.4" +version = "3.13.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, - {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] -typing = ["typing-extensions (>=4.7.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "flake8" @@ -245,13 +246,13 @@ flake8 = ">=3.6" [[package]] name = "identify" -version = "2.5.30" +version = "2.5.32" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, - {file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, + {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, + {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, ] [package.extras] @@ -393,13 +394,13 @@ testing = ["docopt", "pytest (<6.0.0)"] [[package]] name = "pexpect" -version = "4.8.0" +version = "4.9.0" description = "Pexpect allows easy control of interactive console applications." optional = false python-versions = "*" files = [ - {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, - {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, ] [package.dependencies] @@ -418,13 +419,13 @@ files = [ [[package]] name = "platformdirs" -version = "3.11.0" +version = "4.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, - {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, + {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, + {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, ] [package.extras] @@ -448,13 +449,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.4.0" +version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" files = [ - {file = "pre_commit-3.4.0-py2.py3-none-any.whl", hash = "sha256:96d529a951f8b677f730a7212442027e8ba53f9b04d217c4c67dc56c393ad945"}, - {file = "pre_commit-3.4.0.tar.gz", hash = "sha256:6bbd5129a64cad4c0dfaeeb12cd8f7ea7e15b77028d985341478c8af3c759522"}, + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, ] [package.dependencies] @@ -466,13 +467,13 @@ virtualenv = ">=20.10.0" [[package]] name = "prompt-toolkit" -version = "3.0.39" +version = "3.0.41" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, - {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, + {file = "prompt_toolkit-3.0.41-py3-none-any.whl", hash = "sha256:f36fe301fafb7470e86aaf90f036eef600a3210be4decf461a5b1ca8403d3cb2"}, + {file = "prompt_toolkit-3.0.41.tar.gz", hash = "sha256:941367d97fc815548822aa26c2a269fdc4eb21e9ec05fc5d447cf09bad5d75f0"}, ] [package.dependencies] @@ -527,27 +528,28 @@ files = [ [[package]] name = "pygments" -version = "2.16.1" +version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "7.4.2" +version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, - {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] @@ -637,17 +639,17 @@ files = [ [[package]] name = "setuptools" -version = "68.2.2" +version = "69.0.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, - {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, + {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, + {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] @@ -694,18 +696,18 @@ files = [ [[package]] name = "traitlets" -version = "5.11.2" +version = "5.13.0" description = "Traitlets Python configuration system" optional = false python-versions = ">=3.8" files = [ - {file = "traitlets-5.11.2-py3-none-any.whl", hash = "sha256:98277f247f18b2c5cabaf4af369187754f4fb0e85911d473f72329db8a7f4fae"}, - {file = "traitlets-5.11.2.tar.gz", hash = "sha256:7564b5bf8d38c40fa45498072bf4dc5e8346eb087bbf1e2ae2d8774f6a0f078e"}, + {file = "traitlets-5.13.0-py3-none-any.whl", hash = "sha256:baf991e61542da48fe8aef8b779a9ea0aa38d8a54166ee250d5af5ecf4486619"}, + {file = "traitlets-5.13.0.tar.gz", hash = "sha256:9b232b9430c8f57288c1024b34a8f0251ddcc47268927367a0dd3eeaca40deb5"}, ] [package.extras] docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] -test = ["argcomplete (>=3.0.3)", "mypy (>=1.5.1)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.6.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] [[package]] name = "typing-extensions" @@ -720,19 +722,19 @@ files = [ [[package]] name = "virtualenv" -version = "20.24.5" +version = "20.24.7" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, - {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, + {file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, + {file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<4" +platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] @@ -740,13 +742,13 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "wcwidth" -version = "0.2.8" +version = "0.2.12" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" files = [ - {file = "wcwidth-0.2.8-py2.py3-none-any.whl", hash = "sha256:77f719e01648ed600dfa5402c347481c0992263b81a027344f3e1ba25493a704"}, - {file = "wcwidth-0.2.8.tar.gz", hash = "sha256:8705c569999ffbb4f6a87c6d1b80f324bd6db952f5eb0b95bc07517f4c1813d4"}, + {file = "wcwidth-0.2.12-py2.py3-none-any.whl", hash = "sha256:f26ec43d96c8cbfed76a5075dac87680124fa84e0855195a6184da9c187f133c"}, + {file = "wcwidth-0.2.12.tar.gz", hash = "sha256:f01c104efdf57971bcb756f054dd58ddec5204dd15fa31d6503ea57947d97c02"}, ] [metadata] From 2f478a4838412bd53b4d0fc3724728ef40d95dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sondre=20Lilleb=C3=B8=20Gundersen?= Date: Sat, 25 Nov 2023 15:01:33 +0100 Subject: [PATCH 18/18] chore: Update version from 2.5.1 to 2.6.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 202938a..7bab3ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'flake8-type-checking' -version = "2.5.1" +version = "2.6.0" description = 'A flake8 plugin for managing type-checking imports & forward references' homepage = 'https://github.com/snok' repository = 'https://github.com/snok/flake8-type-checking'