diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 459feef..66f0d0e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,45 +4,45 @@ on: release: types: [published, edited] +permissions: {} + jobs: build-and-publish-test: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: "3.10" - - name: Load cached Poetry installation - uses: actions/cache@v5 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - # This path assumes the workflow is run on an ubuntu runner - path: ~/.local - key: key-poetry-0 - - uses: snok/install-poetry@v1 + python-version: "3.11" + - uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 - name: Publish package shell: bash - run: | + # TODO: Switch to trusted publishing + run: | # zizmor: ignore[use-trusted-publishing] poetry config repositories.custom 'https://test.pypi.org/legacy/' poetry config pypi-token.custom ${{ secrets.TEST_PYPI_TOKEN }} poetry publish --build --no-interaction --repository custom build-and-publish: needs: build-and-publish-test runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: "3.10" - - name: Load cached Poetry installation - uses: actions/cache@v5 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - # This path assumes the workflow is run on an ubuntu runner - path: ~/.local - key: key-poetry-0 - - uses: snok/install-poetry@v1 + python-version: "3.11" + - uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 - name: Publish package shell: bash - run: | + # TODO: Switch to trusted publishing + run: | # zizmor: ignore[use-trusted-publishing] echo "Using default repository (PyPi)" poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} poetry publish --build --no-interaction diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 1c20dcd..da6855f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,54 +6,66 @@ on: branches: - main +permissions: {} + jobs: linting: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.10" - - uses: actions/cache@v4 + python-version: "3.11" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: cache-venv with: path: .venv - key: venv-6 # increment to reset + key: venv-7 # increment to reset - run: | python -m venv .venv --upgrade-deps source .venv/bin/activate pip install pre-commit if: steps.cache-venv.outputs.cache-hit != 'true' - - uses: actions/cache@v4 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: pre-commit-cache with: path: ~/.cache/pre-commit - key: ${{ hashFiles('**/pre-commit-config.yaml') }}-5 + key: ${{ hashFiles('**/pre-commit-config.yaml') }}-6 - run: | source .venv/bin/activate pre-commit run --all-files test: runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: setup-python with: python-version: "${{ matrix.python-version }}" - - uses: actions/cache@v5 + # FIXME: Remove once 3.15 is released + allow-prereleases: true + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: poetry-cache with: path: | ~/.local .venv - key: ${{ hashFiles('**/poetry.lock') }}-${{ steps.setup-python.outputs.python-version }}-9 + key: ${{ hashFiles('**/poetry.lock') }}-${{ steps.setup-python.outputs.python-version }}-10 - name: Install Poetry - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: virtualenvs-create: false version: latest @@ -73,7 +85,7 @@ jobs: coverage run -m pytest tests coverage xml coverage report - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: files: ./coverage.xml fail_ci_if_error: true diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..473ba78 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,33 @@ +# https://docs.zizmor.sh/integrations/#github-actions +name: Zizmor (GitHub Actions Security) + +on: + push: + paths: + - '.github/workflows/**' + pull_request: + paths: + - '.github/workflows/**' + +permissions: {} + +concurrency: + group: zizmor-${{ github.ref }} + cancel-in-progress: true + +jobs: + zizmor: + runs-on: ubuntu-latest + permissions: + contents: read # Required for private repos + actions: read # Required for private repos + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + advanced-security: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da0053c..e18ff44 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 25.12.0 + rev: 26.5.1 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks @@ -19,7 +19,7 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 + rev: 7.4.0 hooks: - id: flake8 additional_dependencies: [ @@ -31,19 +31,19 @@ repos: 'flake8-pytest-style', 'flake8-docstrings', 'flake8-printf-formatting', - 'flake8-type-checking==2.9.1', + 'flake8-type-checking==3.2.0', ] - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade - args: [ "--py39-plus", '--keep-runtime-typing' ] + args: [ "--py311-plus", '--keep-runtime-typing' ] - repo: https://github.com/pycqa/isort - rev: 7.0.0 + rev: 9.0.1 hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v2.3.1 hooks: - id: mypy additional_dependencies: @@ -55,3 +55,7 @@ repos: always_run: true pass_filenames: false args: ['-p', 'flake8_type_checking'] + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.30.1 + hooks: + - id: zizmor diff --git a/README.md b/README.md index a516789..378c995 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Lets you know which imports to move in or out of [type-checking](https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING) blocks. The plugin assumes that the imports you only use for type hinting -*are not* required at runtime. When imports aren't strictly required at runtime, it means we can guard them. +*are not* required at runtime. When imports aren't strictly required at runtime, it means we can guard them (or with Python +3.15+ we can make them lazy). Guarding imports provides 3 major benefits: @@ -39,6 +40,14 @@ if TYPE_CHECKING: x: "pandas.DataFrame" ``` +Or with Python 3.15+ it can also become this: + +```python +lazy import pandas + +x: pandas.DataFrame +``` + More examples can be found in the [examples](#examples) section.
@@ -118,6 +127,42 @@ enable-extensions = TC, TC2 # or TC1 If you are unsure which `TC` range to pick, see the [rationale](#rationale) for more info. +## Lazy imports + +While the plugin recognizes and supports lazy imports, it currently +offers no rule like TC004 that tell you to turn a lazy import back +into a regular import. This is mostly because the only time a lazy +import for sure shouldn't be lazy is when it is directly accessed +in the global scope. + +[`flake8-lazy`](https://flake8-lazy.readthedocs.io/en/latest/) +already exists and helps flag exactly those cases and others +so `flake8-type-checking` will not be able to provide anything that +plugin does not already give you, beyond TC001, TC002 and TC003 +telling you which imports could benefit from being lazy in addition +to the ones `flake8-lazy` tells you about. + +Runtime introspection of `__annotations__` is a corner case where +a lazy import might resolve during module import time, but since it +is not obvious at which point during the module's execution that +introspection happens, a lazy import could still help break import +cycles, so there is no obvious rule for when a lazy import is +redundant. + +For the same reason `flake8-type-checking` will never recommend +replacing an existing type checking block with a lazy import, +since there is no way to guarantee that the two are equivalent, +since lazy imports will always get triggered when `__annotations__` +are introspected, regardless of whether a format like +`Format.FORWARDREF` is used. So lazy imports can still result in +additional overhead compared to type checking blocks in some +scenarios. It is not a trade-off the plugin can make for you. + +By default `flake8-type-checking` will never recommend turning +imports into lazy imports, see the [configuration](#configuration) +for how to enable Python 3.15+ mode, where some of the error +messages will reference lazy imports as an alternative solution. + ## Installation ```shell @@ -132,10 +177,10 @@ These options are configurable, and can be set in your flake8 config. If your code is targeting Python 3.14+ you no longer need to wrap annotations in quotes or add a future import. So in this case it's -recommended to add `type-checking-p314plus = true` to your flake8 +recommended to add `type-checking-py314plus = true` to your flake8 configuration and select the `TC1` rules. -- **setting name**: `type-checking-p314plus` +- **setting name**: `type-checking-py314plus` - **type**: `bool` ```ini @@ -143,6 +188,28 @@ configuration and select the `TC1` rules. type-checking-py314plus = true # default false ``` +## Python 3.15+ + +If your code is targeting Python 3.15+ you may want to use lazy +imports instead of moving them into a type checking block. So +TC001, TC002 and TC003 contain additional text to help guide +users to this alternate solution. This setting also implies +`type-checking-py314-plus`. + +Lazy imports are detected and special-cased, even without enabling +this setting and are treated as a valid alternative to a type +checking block, regardless of whether the target version will +actually treat them as such, so this setting currently only +changes the error messages users will see. + +- **setting name**: `type-checking-p315plus` +- **type**: `bool` + +```ini +[flake8] +type-checking-py315plus = true # default false +``` + ### Typing modules If you re-export `typing` or `typing_extensions` members from a compatibility @@ -194,6 +261,27 @@ imports that *can* be moved. type-checking-strict = true # default false ``` +### Report typing-only uses of `__lazy_modules__` + +The plugin, by default, will never report TC00[1-3] errors +for imports covered by `__lazy_modules__`, since starting with +Python 3.15 these will work the same as `lazy import ..` and +`lazy from .. import ..` statements. + +If you want to preserve the import time reduction for older Python +versions, you may not want this and instead want to move the import +into a type checking block, you can tell the plugin to ignore +`__lazy_modules__` declarations, which will allow these imports +to report TC00[1-3] errors. + +- **setting name**: `type-checking-ignore-dunder-lazy-modules` +- **type**: `bool` + +```ini +[flake8] +type-checking-ignore-dunder-lazy-modules = true # default false +``` + ### Force `from __future__ import annotations` import The plugin, by default, will only report a TC100 error, if annotations diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 2d22250..e8e9b2b 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -20,6 +20,7 @@ ATTRIBUTE_PROPERTY, ATTRS_DECORATORS, BINOP_OPERAND_PROPERTY, + LAZY_SUFFIX, MISSING, TC001, TC002, @@ -81,42 +82,39 @@ def visit_annotated_value(self, node: ast.expr) -> None: def visit(self, node: ast.AST) -> None: """Visit relevant child nodes on an annotation.""" - if node is None: - return - if isinstance(node, ast.BinOp): - if not isinstance(node.op, ast.BitOr): - return - setattr(node.left, BINOP_OPERAND_PROPERTY, True) - setattr(node.right, BINOP_OPERAND_PROPERTY, True) - self.visit(node.left) - self.visit(node.right) - elif isinstance(node, ast.Attribute): - self.visit(node.value) - elif isinstance(node, ast.Subscript): - self.visit(node.value) - if self.is_typing(node.value, 'Literal'): - return - elif self.is_typing(node.value, 'Annotated') and isinstance( - node.slice, - (ast.Tuple, ast.List), - ): - if node.slice.elts: - elts_iter = iter(node.slice.elts) - # only visit the first element like a type expression - self.visit_annotated_type(next(elts_iter)) - for value_node in elts_iter: - self.visit_annotated_value(value_node) - else: - self.visit(node.slice) - elif isinstance(node, (ast.Tuple, ast.List)): - for n in node.elts: - self.visit(n) - elif isinstance(node, ast.Starred) and isinstance(node.ctx, ast.Load): - self.visit(node.value) - elif isinstance(node, ast.Constant) and isinstance(node.value, str): - self.visit_annotation_string(node) - elif isinstance(node, ast.Name): - self.visit_annotation_name(node) + match node: + case ast.BinOp(op=ast.BitOr()): + setattr(node.left, BINOP_OPERAND_PROPERTY, True) + setattr(node.right, BINOP_OPERAND_PROPERTY, True) + self.visit(node.left) + self.visit(node.right) + case ast.Attribute(): + self.visit(node.value) + case ast.Subscript(): + self.visit(node.value) + if self.is_typing(node.value, 'Literal'): + return + elif self.is_typing(node.value, 'Annotated') and isinstance( + node.slice, + (ast.Tuple, ast.List), + ): + if node.slice.elts: + elts_iter = iter(node.slice.elts) + # only visit the first element like a type expression + self.visit_annotated_type(next(elts_iter)) + for value_node in elts_iter: + self.visit_annotated_value(value_node) + else: + self.visit(node.slice) + case ast.Tuple(elts=elements) | ast.List(elts=elements): + for element in elements: + self.visit(element) + case ast.Starred(ctx=ast.Load()): + self.visit(node.value) + case ast.Constant(value=str()): + self.visit_annotation_string(node) + case ast.Name(): + self.visit_annotation_name(node) class AttrsMixin: @@ -171,30 +169,19 @@ def generic_visit(self, node: ast.AST) -> None: # noqa: D102 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.__all___assignments: list[tuple[int, int]] = [] - - def in___all___declaration(self, node: ast.Constant) -> bool: - """ - Indicate whether a node is a sub-node of an __all__ assignment node. + self._in__all__declaration = False - We want to avoid raising TC001 errors when imports are defined - as strings, like this: - - This is a little tricky though. We can't just add string definitions - to our 'uses' map, since that will generate false positives elsewhere. - Instead we need this helper to tell us when *not* to ignore constants. - """ - if not self.__all___assignments: - return False - if not isinstance(getattr(node, 'value', ''), str): - return False - return any( - (assignment[0] is not None and node.lineno is not None and assignment[1] is not None) - and (assignment[0] <= node.lineno <= assignment[1]) - for assignment in self.__all___assignments - ) + @contextmanager + def in__all__declaration(self) -> Iterator[None]: + """Mark all subsequently visited nodes as being part of `__all__`.""" + original = self._in__all__declaration + self._in__all__declaration = True + try: + yield + finally: + self._in__all__declaration = original - def visit_Assign(self, node: ast.Assign) -> ast.Assign: + def visit_Assign(self, node: ast.Assign) -> None: """ Make sure we keep track of all __all__ assignments. @@ -210,21 +197,199 @@ def visit_Assign(self, node: ast.Assign) -> ast.Assign: So we need to look at the assign element, and inspect both the target(s) and value. """ - if len(node.targets) == 1 and getattr(node.targets[0], 'id', '') == '__all__': - self.__all___assignments.append((node.targets[0].lineno, node.value.end_lineno or node.targets[0].lineno)) - - self.generic_visit(node) - return node + if ( + self.current_scope.parent is None + and len(node.targets) == 1 + and getattr(node.targets[0], 'id', '') == '__all__' + ): + with self.in__all__declaration(): + super().visit_Assign(node) # type: ignore[misc] + else: + super().visit_Assign(node) # type: ignore[misc] - def visit_Constant(self, node: ast.Constant) -> ast.Constant: + def visit_Constant(self, node: ast.Constant) -> None: """Map constant as use, if we're inside an __all__ declaration.""" - if self.in___all___declaration(node): + if self._in__all__declaration: # for these it doesn't matter where they are declared, the symbol # just needs to be available in global scope anywhere, we handle # this by special casing `ast.Constant` when we look for used type # checking symbols self.uses[node.value].append((node, self.current_scope)) # type: ignore[index] - return node + + super().visit_Constant(node) # type: ignore[misc] + + +class DunderLazyModulesMixin: + """ + Contains the necessary logic for handling `__lazy_modules__`. + + In Python 3.15+ all modules listed in `__lazy_modules__` will turn + any matching import statement following the declaration into a lazy + import. This is the backwards-compatible version of the new syntax, + that allows newer Python versions to have lazy imports without breaking + older versions. + """ + + if TYPE_CHECKING: + lazy_modules: set[str] + ignore_dunder_lazy_modules: bool + current_scope: Scope + + def generic_visit(self, node: ast.AST) -> None: # noqa: D102 + ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._in__lazy_modules__declaration = False + + @contextmanager + def in__lazy_modules__declaration(self) -> Iterator[None]: + """ + Mark all subsequently visited nodes as being part of `__lazy_modules__`. + + It also clears `lazy_modules`, so past assignments don't bleed + into new assignments. + """ + self.lazy_modules.clear() + original = self._in__lazy_modules__declaration + self._in__lazy_modules__declaration = True + try: + yield + finally: + self._in__lazy_modules__declaration = original + + def visit_Assign(self, node: ast.Assign) -> None: + """ + Make sure we keep track of all __lazy_modules__ assignments. + + We would do this in visit_Name, except the name attribute for the assignment's + target's end_lineno only spans the assignment line, not the whole assignment: + + ^^^^^^^^ this is all the ast.target for __lazy_modules__ spans + __lazy_modules__ = [ < + 'one', < + 'two', < + 'three' < \ + ] <-- This is the node.value + + So we need to look at the assign element, and inspect both the target(s) and value. + """ + if ( + # For simplicity we implement this setting by ignoring __lazy_modules__ + # altogether, since we currently don't flag these any differently than + # regular imports, when they're only used for type checking. + not self.ignore_dunder_lazy_modules + and self.current_scope.parent is None + and len(node.targets) == 1 + and getattr(node.targets[0], 'id', '') == '__lazy_modules__' + ): + with self.in__lazy_modules__declaration(): + self.generic_visit(node) + else: + self.generic_visit(node) + + def visit_Constant(self, node: ast.Constant) -> None: + """Record all module names in __lazy_import__ declarations.""" + if self._in__lazy_modules__declaration and isinstance(node.value, str): + self.lazy_modules.add(node.value) + + def relative_import_level_minus_one(self, expr: ast.AST) -> int | None: + """ + Determine the relative import level of a flake8-lazy style relative import. + + This expects being handed an expression of the form: + + __spec__.parent + + Or: + + spec__.parent.rsplit(".", 1)[0] + + Or the type-safe version: + + (spec__.parent or "").rsplit(".", 1)[0] + + Based on any of these expression we need to determine the + level of the relative import this is supposed to target. + + In order to avoid adding one to the integer we retrieve from + the AST only to subtract it again in the code that uses it, + we directly return the level minus one. + + For any other expression this will return `None`. + """ + match expr: + # Simple case for a single level + # Matches `__spec__.parent` + case ast.Attribute( + value=ast.Name(id='__spec__'), + attr='parent', + ): + return 0 + + # Complex case for any higher level + case ast.Subscript( + value=ast.Call( + func=ast.Attribute( + value=( + # Matches `spec__.parent.rsplit(".", 1)[0]` + ast.Attribute(value=ast.Name(id='__spec__'), attr='parent') + # Matches `(spec__.parent or "").rsplit(".", 1)[0]` + | ast.BoolOp( + op=ast.Or(), + values=[ + ast.Attribute(value=ast.Name(id='__spec__'), attr='parent'), + ast.Constant(value=''), + ], + ) + ), + attr='rsplit', + ), + args=[ + ast.Constant(value='.'), + ast.Constant(value=int() as level_minus_one), + ], + keywords=[], + ), + slice=ast.Constant(value=0), + ): + return level_minus_one + + case _: + return None + + def visit_JoinedStr(self, node: ast.JoinedStr) -> None: + """ + Record all flake8-lazy style relative import names. + + The motivation behind this style is that flake8 does not provide + us with any sort of information about the project's structure and + since `__lazy_modules__` needs to contain the absolute name in order + to match the relative name, there is no way for us statically link + the two together, unless we use this f-string based approach. + """ + if not self._in__lazy_modules__declaration: + return + + match node: + case ast.JoinedStr( + values=[ + ast.FormattedValue( + value=expr, + conversion=-1, + format_spec=None, + ), + ast.Constant(value=str() as module), + ], + ) if module.startswith('.'): + level_minus_one = self.relative_import_level_minus_one(expr) + if level_minus_one is None: + return + + if level_minus_one: + module = '.' * level_minus_one + module + + self.lazy_modules.add(module) class PydanticMixin: @@ -334,7 +499,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.sqlalchemy_annotation_visitor = SQLAlchemyAnnotationVisitor(self) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - """Remove all annotations assigments.""" + """Handle all `Mapped[...]` style annotations.""" if ( self.sqlalchemy_enabled # We only need to special case runtime use of `Mapped` @@ -363,72 +528,73 @@ def handle_sqlalchemy_annotation(self, node: ast.AST) -> None: `Mapped` names, then we will record a runtime use of that symbol, since we know `Mapped` always needs to resolve. """ - if isinstance(node, ast.Constant): - # we only need to handle annotations like `"Mapped[...]"` - if not isinstance(node.value, str) or '[' not in node.value: - return - - annotation = node.value.strip() - if not annotation.endswith(']'): - return - - mapped_name, inner = annotation.split('[', 1) - # strip trailing `]` from inner - inner = inner[:-1] - if not self.is_mapped(mapped_name): - return - - # record a use for the first part of the name - used_name, *_ = mapped_name.split('.', 1) - self.uses[used_name].append((node, self.current_scope)) - - # add all names contained in the inner part of the annotation - # since this is not as strict as an actual runtime use, we don't - # care if we record too much here - visitor = StringAnnotationVisitor(self) - visitor.parse_and_visit_string_annotation(inner) - self.soft_uses.update(visitor.names) - return - - # we only need to handle annotations like `Mapped[...]` - if not isinstance(node, ast.Subscript): - return - - # simple case only needs to check mapped_aliases - if isinstance(node.value, ast.Name): - if not self.is_mapped(node.value): - return - - # record a use for the name - self.uses[node.value.id].append((node.value, self.current_scope)) - - # complex case for dotted names - elif isinstance(node.value, ast.Attribute): - dotted_name = node.value.attr - before_dot = node.value.value - while isinstance(before_dot, ast.Attribute): - dotted_name = f'{before_dot.attr}.{dotted_name}' - before_dot = before_dot.value - # there should be no subscripts between the attributes - if not isinstance(before_dot, ast.Name): - return - - # map the module if it's mapped otherwise use it as is - module = self.lookup_full_name(before_dot) or before_dot.id - dotted_name = f'{module}.{dotted_name}' - if dotted_name not in self.sqlalchemy_mapped_dotted_names: + match node: + # Handle annotations like `"Mapped[...]"` + case ast.Constant(value=str() as annotation): + if '[' not in annotation: + return + + annotation = annotation.strip() + if not annotation.endswith(']'): + return + + mapped_name, inner = annotation.split('[', 1) + # strip trailing `]` from inner + inner = inner[:-1] + if not self.is_mapped(mapped_name): + return + + # record a use for the first part of the name + used_name, *_ = mapped_name.split('.', 1) + self.uses[used_name].append((node, self.current_scope)) + + # add all names contained in the inner part of the annotation + # since this is not as strict as an actual runtime use, we don't + # care if we record too much here + visitor = StringAnnotationVisitor(self) + visitor.parse_and_visit_string_annotation(inner) + self.soft_uses.update(visitor.names) + + # Handle annotations like `Mapped[...]` + case ast.Subscript( + value=ast.Name() as name, + slice=wrapped, + ) if self.is_mapped(name): + + # record a use for the name + self.uses[name.id].append((name, self.current_scope)) + + # visit the wrapped annotations to update the mapped names + self.sqlalchemy_annotation_visitor.visit(wrapped) + + # Handle annotations like `sqlalchemy.orm.Mapped[...]` + case ast.Subscript( + value=ast.Attribute(attr=dotted_name, value=before_dot), + slice=wrapped, + ): + while isinstance(before_dot, ast.Attribute): + dotted_name = f'{before_dot.attr}.{dotted_name}' + before_dot = before_dot.value + # there should be no subscripts between the attributes + if not isinstance(before_dot, ast.Name): + return + + # map the module if it's mapped otherwise use it as is + module = self.lookup_full_name(before_dot) or before_dot.id + dotted_name = f'{module}.{dotted_name}' + if dotted_name not in self.sqlalchemy_mapped_dotted_names: + return + + # record a use for the left-most node in the attribute access chain + self.uses[before_dot.id].append((before_dot, self.current_scope)) + + # visit the wrapped annotations to update the mapped names + self.sqlalchemy_annotation_visitor.visit(wrapped) + + # any other case is invalid, such as `Foo[...][...]` + case _: return - # record a use for the left-most node in the attribute access chain - self.uses[before_dot.id].append((before_dot, self.current_scope)) - - # any other case is invalid, such as `Foo[...][...]` - else: - return - - # visit the wrapped annotations to update the mapped names - self.sqlalchemy_annotation_visitor.visit(node.slice) - class InjectorMixin: """ @@ -588,6 +754,9 @@ class ImportName: #: Whether or not this import is exempt from TC001-004 checks. exempt: bool + #: Whether or not this import is lazy + is_lazy: bool | None + @property def module(self) -> str: """ @@ -677,6 +846,9 @@ class Symbol(NamedTuple): type: Literal['import', 'definition', 'declaration', 'argument'] in_type_checking_block: bool + # for imports whether or not they are lazy + is_lazy: bool | None = None + 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': @@ -973,6 +1145,7 @@ def visit_annotation_string(self, node: ast.Constant) -> None: class ImportVisitor( DunderAllMixin, + DunderLazyModulesMixin, FunctoolsSingledispatchMixin, AttrsMixin, InjectorMixin, @@ -990,6 +1163,7 @@ def __init__( self, cwd: Path, py314plus: bool, + ignore_dunder_lazy_modules: bool, pydantic_enabled: bool, fastapi_enabled: bool, fastapi_dependency_support_enabled: bool, @@ -1026,6 +1200,13 @@ def __init__( #: Import patterns we want to avoid mapping self.exempt_modules: list[str] = exempt_modules or [] + #: A set of modules marked as Python 3.15+ lazy imports + self.lazy_modules: set[str] = set() + + #: Whether or not __lazy_modules__ declarations should excempt imports + #: from being flagged by TC001, TC002 or TC003. + self.ignore_dunder_lazy_modules = ignore_dunder_lazy_modules + #: Whether or not TC100 should always be emitted if there are annotations self.force_future_annotation = force_future_annotation @@ -1217,39 +1398,6 @@ def is_type_checking(self, node: ast.AST) -> bool: return True return self.is_typing(node, 'TYPE_CHECKING') - def is_type_checking_true(self, node: ast.Compare) -> bool: - """ - Check whether the node matches `if TYPE_CHECKING is True`. - - An ast.Compare node has a `left`, `ops`, and `comparators` attribute. - - Here we want to check whether our node corresponds to - - `if TYPE_CHECKING is True` - ^ ^ ^ - left _______| ops |____ comparators - """ - # Left side should be a TYPE_CHECKING block - is_type_checking_block = hasattr(node, 'left') and self.is_type_checking(node.left) - if not is_type_checking_block: - return False - - # Operator should be `is` - operator_is_is = len(node.ops) == 1 and isinstance(node.ops[0], ast.Is) - if not operator_is_is: - return False - - # Right side should be `True` - right_side_is_true = ( - len(node.comparators) == 1 - and isinstance(node.comparators[0], ast.Constant) - and node.comparators[0].value is True - ) - if not right_side_is_true: - return False - - return True - def is_true_when_type_checking(self, node: ast.AST) -> bool | Literal['TYPE_CHECKING']: """Determine if the node evaluates to True when TYPE_CHECKING is True. @@ -1265,23 +1413,35 @@ def is_true_when_type_checking(self, node: ast.AST) -> bool | Literal['TYPE_CHEC """ if self.is_type_checking(node): return 'TYPE_CHECKING' - if isinstance(node, ast.BoolOp): - non_type_checking = [v for v in node.values if not self.is_type_checking(v)] - has_type_checking = len(non_type_checking) < len(node.values) - num_true = sum(1 if self.is_true_when_type_checking(v) else 0 for v in non_type_checking) - all_others_true = num_true == len(non_type_checking) - any_others_true = num_true > 0 - if isinstance(node.op, ast.Or): - # At least one of the conditions must be TYPE_CHECKING - return 'TYPE_CHECKING' if has_type_checking else any_others_true - elif isinstance(node.op, ast.And) and all_others_true: - # At least one of the conditions must be TYPE_CHECKING, and all others must be True - return 'TYPE_CHECKING' if has_type_checking else False - elif isinstance(node, ast.Constant): - with suppress(Exception): - return bool(literal_eval(node)) - elif isinstance(node, ast.Compare) and self.is_type_checking_true(node): - return 'TYPE_CHECKING' + match node: + case ast.BoolOp( + values=values, + op=ast.Or() | ast.And() as operator, + ): + non_type_checking = [v for v in values if not self.is_type_checking(v)] + has_type_checking = len(non_type_checking) < len(values) + num_true = sum(1 if self.is_true_when_type_checking(v) else 0 for v in non_type_checking) + any_others_true = num_true > 0 + all_others_true = num_true == len(non_type_checking) + if isinstance(operator, ast.Or): + # At least one of the conditions must be TYPE_CHECKING + return 'TYPE_CHECKING' if has_type_checking else any_others_true + elif has_type_checking and all_others_true: + # At least one of the conditions must be TYPE_CHECKING, and all others must be True + return 'TYPE_CHECKING' + case ast.Constant(): + with suppress(Exception): + return bool(literal_eval(node)) + # Matches `TYPE_CHECKING is True` + case ast.Compare( + # Operator should be `is` + ops=[ast.Is()], + # Right side should be `True` + comparators=[ast.Constant(value=True)], + # Left side should be a TYPE_CHECKING block + left=left, + ) if self.is_type_checking(left): + return 'TYPE_CHECKING' return False def visit_Module(self, node: ast.Module) -> ast.Module: @@ -1335,48 +1495,52 @@ 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.""" in_type_checking_block = self.in_type_checking_block(node.lineno, node.col_offset) + all_exempt = in_type_checking_block or ( + isinstance(node, ast.ImportFrom) and node.module and self.is_exempt_module(node.module) + ) + all_lazy: bool | None = getattr(node, 'is_lazy', None) + + # All ImportName objects share the same module regardles of node type + if isinstance(node, ast.ImportFrom): + module = f'{node.module}.' if node.module else '' + if node.level != 0: + module = '.' * node.level + module + + # Mark all imported symbols as lazy if the module is lazy + if not all_lazy and self.lazy_modules and module.rstrip('.') in self.lazy_modules: + all_lazy = True + else: + module = '' - # 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 + # Mark lazy imports + is_lazy = all_lazy or (isinstance(node, ast.Import) and name_node.name in self.lazy_modules) - self.current_scope.symbols[name].append( + # Record the imported names as symbols + symbol_name = name_node.asname or name_node.name + self.current_scope.symbols[symbol_name].append( Symbol( - name, + symbol_name, node.lineno, node.col_offset, 'import', in_type_checking_block=in_type_checking_block, + is_lazy=is_lazy, ) ) - all_exempt = in_type_checking_block or ( - isinstance(node, ast.ImportFrom) and node.module and self.is_exempt_module(node.module) - ) - - for name_node in node.names: - # Skip checking the import if the module is passlisted - exempt = all_exempt or (isinstance(node, ast.Import) and self.is_exempt_module(name_node.name)) - if name_node.name == '*': # don't record * imports continue - # Classify and map imports - if isinstance(node, ast.ImportFrom): - module = f'{node.module}.' if node.module else '' - if node.level != 0: - module = '.' * node.level + module - else: - module = '' + # Skip checking the import if the module is passlisted + exempt = all_exempt or (isinstance(node, ast.Import) and self.is_exempt_module(name_node.name)) imp = ImportName( _module=module, _alias=name_node.asname, _name=name_node.name, exempt=exempt, + is_lazy=is_lazy, ) # Add to import names map. This is what we use to match imports to uses @@ -1487,10 +1651,9 @@ def visit_Name(self, node: ast.Name) -> ast.Name: return node - def visit_Constant(self, node: ast.Constant) -> ast.Constant: + def visit_Constant(self, node: ast.Constant) -> None: """Map constants.""" super().visit_Constant(node) - return node def add_annotation( self, @@ -1568,7 +1731,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # if it wasn't a TypeAlias we need to visit the value expression self.visit(node.value) - def visit_Assign(self, node: ast.Assign) -> ast.Assign: + def visit_Assign(self, node: ast.Assign) -> None: """ Keep track of variable definitions. @@ -1607,9 +1770,8 @@ def visit_Assign(self, node: ast.Assign) -> ast.Assign: ) super().visit_Assign(node) - return node - def visit_Global(self, node: ast.Global) -> ast.Global: + def visit_Global(self, node: ast.Global) -> None: """ Treat global statements like a normal assignment. @@ -1631,9 +1793,7 @@ def visit_Global(self, node: ast.Global) -> ast.Global: ) ) - return node - - def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.Nonlocal: + def visit_Nonlocal(self, node: ast.Nonlocal) -> None: """ Treat nonlocal statements like a normal assignment. @@ -1655,8 +1815,6 @@ def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.Nonlocal: ) ) - return node - if sys.version_info >= (3, 12): def visit_TypeAlias(self, node: ast.TypeAlias) -> None: @@ -1925,7 +2083,7 @@ class TypingOnlyImportsChecker: __slots__ = [ 'cwd', 'strict_mode', - 'py314plus', + 'py315plus', 'builtin_names', 'used_type_checking_names', 'visitor', @@ -1936,19 +2094,22 @@ class TypingOnlyImportsChecker: def __init__(self, node: ast.Module, options: Namespace | None) -> None: self.cwd = Path(os.getcwd()) self.strict_mode = getattr(options, 'type_checking_strict', False) - py314plus = getattr(options, 'type_checking_py314plus', False) + self.py315plus = getattr(options, 'type_checking_py315plus', False) + # py315plus implies py314plus + py314plus = self.py315plus or getattr(options, 'type_checking_py314plus', 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.builtin_names = self.builtin_names.union(additional_builtins) self.used_type_checking_names: set[str] = set() typing_modules = getattr(options, 'type_checking_typing_modules', []) exempt_modules = getattr(options, 'type_checking_exempt_modules', []) force_future_annotation = getattr(options, 'type_checking_force_future_annotation', False) + ignore_dunder_lazy_modules = getattr(options, 'type_checking_ignore_dunder_lazy_modules', False) pydantic_enabled = getattr(options, 'type_checking_pydantic_enabled', False) pydantic_enabled_baseclass_passlist = getattr(options, 'type_checking_pydantic_enabled_baseclass_passlist', []) sqlalchemy_enabled = getattr(options, 'type_checking_sqlalchemy_enabled', False) @@ -1970,6 +2131,7 @@ def __init__(self, node: ast.Module, options: Namespace | None) -> None: self.visitor = ImportVisitor( self.cwd, py314plus=py314plus, + ignore_dunder_lazy_modules=ignore_dunder_lazy_modules, pydantic_enabled=pydantic_enabled, fastapi_enabled=fastapi_enabled, cattrs_enabled=cattrs_enabled, @@ -2032,6 +2194,10 @@ def unused_imports(self) -> Flake8Generator: # Get the ImportName object for this import name import_name: ImportName = self.visitor.imports[name] + # We don't emit an error for lazy imports, since they will be deferred + if import_name.is_lazy: + continue + # If strict mode is enabled, we want to flag each individual import # that can be moved into a type-checking block. If not enabled, # we only want to flag imports if there aren't other imports already @@ -2039,7 +2205,10 @@ def unused_imports(self) -> Flake8Generator: if self.strict_mode or import_name.module not in already_imported_modules: error_specific_imports, error = import_types[import_name.import_type] node = error_specific_imports.pop(import_name.import_name) - yield node.lineno, node.col_offset, error.format(module=import_name.import_name), None + msg = error.format(module=import_name.import_name) + if self.py315plus: + msg += LAZY_SUFFIX + yield node.lineno, node.col_offset, msg, None def used_type_checking_symbols(self) -> Flake8Generator: """TC004 and TC009.""" diff --git a/flake8_type_checking/constants.py b/flake8_type_checking/constants.py index 8691fd4..9c95615 100644 --- a/flake8_type_checking/constants.py +++ b/flake8_type_checking/constants.py @@ -41,6 +41,7 @@ class _Sentinels(enum.Enum): TC001 = "TC001 Move application import '{module}' into a type-checking block" TC002 = "TC002 Move third-party import '{module}' into a type-checking block" TC003 = "TC003 Move built-in import '{module}' into a type-checking block" +LAZY_SUFFIX = ' or turn it into a lazy import' TC004 = "TC004 Move import '{module}' out of type-checking block. Import is used for more than type hinting." TC005 = 'TC005 Found empty type-checking block' TC006 = "TC006 Annotation '{annotation}' in typing.cast() should be a string literal" diff --git a/flake8_type_checking/plugin.py b/flake8_type_checking/plugin.py index 23ac6d0..f6ae3f8 100644 --- a/flake8_type_checking/plugin.py +++ b/flake8_type_checking/plugin.py @@ -3,7 +3,7 @@ import logging from dataclasses import dataclass from importlib.metadata import version as v -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from flake8_type_checking.checker import TypingOnlyImportsChecker from flake8_type_checking.constants import flake_version_gt_v4 @@ -11,7 +11,7 @@ if TYPE_CHECKING: from argparse import Namespace from ast import Module - from typing import Optional + from typing import ClassVar from flake8.options.manager import OptionManager @@ -26,7 +26,7 @@ class Plugin: tree: Module filename: str - options: Optional[Namespace] = None + options: Namespace | None = None name: ClassVar[str] = 'flake8-type-checking' version: ClassVar[str] = v('flake8-type-checking') @@ -41,6 +41,13 @@ def add_options(cls, option_manager: OptionManager) -> None: # pragma: no cover default=False, help='Enables Python 3.14+ specific annotation semantics.', ) + option_manager.add_option( + '--type-checking-py315plus', + action='store_true', + parse_from_config=True, + default=False, + help='Enables suggesting Python 3.15+ lazy imports as remediation for TC001, TC002 and TC003.', + ) option_manager.add_option( '--type-checking-typing-modules', comma_separated_list=True, @@ -69,6 +76,13 @@ def add_options(cls, option_manager: OptionManager) -> None: # pragma: no cover default=False, help='Always emit TC100 as long as there are any annotations and no future import.', ) + option_manager.add_option( + '--type-checking-ignore-dunder-lazy-modules', + action='store_true', + parse_from_config=True, + default=False, + help='Allow TC001, TC002, and TC003 checks to flag modules covered by `__lazy_modules__`.', + ) # Third-party library options option_manager.add_option( diff --git a/poetry.lock b/poetry.lock index c36c3c6..4a44806 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "asttokens" @@ -208,25 +208,6 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "executing" version = "2.2.1" @@ -332,7 +313,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} @@ -605,12 +585,10 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -734,64 +712,6 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] -[[package]] -name = "tomli" -version = "2.4.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, - {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, - {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, - {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, - {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, - {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, - {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, - {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, - {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, - {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, - {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, - {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, - {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, - {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, - {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, - {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, - {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, - {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, - {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, -] - [[package]] name = "traitlets" version = "5.14.3" @@ -815,7 +735,7 @@ description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_version < \"3.12\"" +markers = "python_version == \"3.11\"" files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -837,7 +757,6 @@ files = [ distlib = ">=0.3.7,<1" filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] @@ -857,5 +776,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = ">=3.10" -content-hash = "4759549fee6af6d70ce780521add287965e7146680de2a2c4a5673f9091c7262" +python-versions = ">=3.11" +content-hash = "e3e7a690e5fd5c7d089f05852b59d572f6128c52a0dde5a71ab65fdab36aaa21" diff --git a/pyproject.toml b/pyproject.toml index 16c4cad..b770e5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,11 @@ classifiers = [ 'License :: OSI Approved :: BSD License', 'Topic :: Software Development :: Quality Assurance', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Typing :: Typed', ] @@ -29,7 +29,7 @@ classifiers = [ "Releases" = "https://github.com/snok/flake8-type-checking/releases" [tool.poetry.dependencies] -python = '>=3.10' +python = '>=3.11' flake8 = '*' classify-imports = '*' @@ -72,7 +72,7 @@ exclude_lines = [ ] [tool.mypy] -python_version = 3.9 +python_version = 3.11 strict = true warn_redundant_casts = true warn_unused_configs = true diff --git a/tests/conftest.py b/tests/conftest.py index fbf2993..4ec9c29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,16 +3,13 @@ import ast import os from pathlib import Path -from typing import TYPE_CHECKING +from typing import Any from unittest.mock import Mock import pytest from flake8_type_checking.plugin import Plugin -if TYPE_CHECKING: - from typing import Any, Optional - REPO_ROOT = Path(__file__).parent.parent mod = 'flake8_type_checking' @@ -25,7 +22,7 @@ def _change_test_dir(): os.chdir(REPO_ROOT) -def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwargs: Any) -> set[str]: +def _get_error(example: str, *, error_code_filter: str | None = None, **kwargs: Any) -> set[str]: os.chdir(REPO_ROOT) filename = kwargs.get('filename', 'test.py') if error_code_filter: @@ -37,6 +34,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg mock_options.extended_default_select = [] mock_options.enable_extensions = [] mock_options.type_checking_py314plus = False + mock_options.type_checking_py315plus = False mock_options.type_checking_pydantic_enabled = False mock_options.type_checking_exempt_modules = [] mock_options.type_checking_typing_modules = [] @@ -48,6 +46,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg mock_options.type_checking_injector_enabled = False mock_options.type_checking_strict = False mock_options.type_checking_force_future_annotation = False + mock_options.type_checking_ignore_dunder_lazy_modules = False # kwarg overrides for k, v in kwargs.items(): setattr(mock_options, k, v) diff --git a/tests/test_ignore_dunder_lazy_modules.py b/tests/test_ignore_dunder_lazy_modules.py new file mode 100644 index 0000000..abaef7d --- /dev/null +++ b/tests/test_ignore_dunder_lazy_modules.py @@ -0,0 +1,21 @@ +import textwrap + +from flake8_type_checking.constants import TC002 +from tests.conftest import _get_error + + +def test_ignore_dunder_lazy_modules(): + """ + Assert that imports are flagged for TC00[1-3] even if they appear + inside a __lazy_modules__ declaration. + """ + example = textwrap.dedent(''' + __lazy_modules__ = ['pandas'] + from pandas import DataFrame + + a: DataFrame + ''') + assert _get_error(example, error_code_filter='TC002', type_checking_ignore_dunder_lazy_modules=False) == set() + assert _get_error(example, error_code_filter='TC002', type_checking_ignore_dunder_lazy_modules=True) == { + '3:0 ' + TC002.format(module='pandas.DataFrame') + } diff --git a/tests/test_import_visitors.py b/tests/test_import_visitors.py index 3fa86e7..84ab6d2 100644 --- a/tests/test_import_visitors.py +++ b/tests/test_import_visitors.py @@ -1,6 +1,8 @@ from __future__ import annotations import ast +import sys +import textwrap from typing import TYPE_CHECKING import pytest @@ -9,13 +11,14 @@ from tests.conftest import REPO_ROOT if TYPE_CHECKING: - from typing import Callable + from collections.abc import Callable def _visit(example: str) -> ImportVisitor: visitor = ImportVisitor( cwd=REPO_ROOT, py314plus=False, + ignore_dunder_lazy_modules=False, pydantic_enabled=False, fastapi_enabled=False, fastapi_dependency_support_enabled=False, @@ -44,6 +47,11 @@ def _get_built_in_imports(example: str) -> list[str]: return list(visitor.built_in_imports.keys()) +def _get_lazy_imports(example: str) -> list[str]: + visitor = _visit(example) + return [imp.import_name for imp in visitor.imports.values() if imp.is_lazy] + + mod = 'flake8_type_checking' f = _get_application_imports @@ -90,7 +98,100 @@ def _get_built_in_imports(example: str) -> list[str]: for example, expected, f in _list[:-1] ] -test_data = [*application_imports, *stdlib_imports, *venv_imports, *typing_block_imports] +f = _get_lazy_imports +dunder_lazy_ignore_imports = [ + # ast.Import + ('__lazy_modules__ = ["lazy"]\nimport lazy, eager', ['lazy'], f), + ('__lazy_modules__ = ["lazy.a"]\nimport lazy, lazy.a', ['lazy.a'], f), + ('__lazy_modules__ = ["lazy.a.b"]\nimport lazy, lazy.a, lazy.a.b', ['lazy.a.b'], f), + ('import eager', [], f), + ('import too_late\n__lazy_modules__ = ["too_late"]', [], f), + ( + textwrap.dedent(''' + __lazy_modules__ = ["lazy", "superseded"] + import lazy + __lazy_modules__ = ["lazy"] + import superseded + '''), + ['lazy'], + f, + ), + # ast.ImportFrom + ('__lazy_modules__ = ["lazy"]\nfrom lazy import a, b', ['lazy.a', 'lazy.b'], f), + ('__lazy_modules__ = ["lazy.a"]\nfrom lazy.a import b', ['lazy.a.b'], f), + ('__lazy_modules__ = ["lazy.a.b"]\nfrom lazy.a.b import c', ['lazy.a.b.c'], f), + ('from eager import a', [], f), + ('from too_late import a\n__lazy_modules__ = ["too_late"]', [], f), + ( + textwrap.dedent(''' + __lazy_modules__ = ["lazy", "superseded"] + from lazy import a + __lazy_modules__ = ["lazy"] + from superseded import b + '''), + ['lazy.a'], + f, + ), +] + +relative_dunder_lazy_ignore_imports = [ + ( + textwrap.dedent(''' + __lazy_modules__ = [f"{__spec__.parent}.a"] + from .a import lazy, also_lazy + from ..a import eager + '''), + ['.a.lazy', '.a.also_lazy'], + f, + ), + ( + textwrap.dedent(''' + __lazy_modules__ = [ + f"{__spec__.parent.rsplit('.', 1)[0]}.a" + ] + from .a import eager + from ..a import lazy + '''), + ['..a.lazy'], + f, + ), + ( + textwrap.dedent(''' + __lazy_modules__ = [ + f"{(__spec__.parent or '').rsplit('.', 2)[0]}.a" + ] + from .a import eager + from ..a import also_eager + from ...a import lazy + '''), + ['...a.lazy'], + f, + ), +] + +if sys.version_info >= (3, 15): + lazy_imports = [ + # ast.Import + ('lazy import lazy', ['lazy'], f), + ('lazy import lazy.a', ['lazy.a'], f), + ('lazy import lazy.a.b', ['lazy.a.b'], f), + # ast.ImportFrom + ('lazy from lazy import a, b', ['lazy.a', 'lazy.b'], f), + ('lazy from lazy.a import b', ['lazy.a.b'], f), + ('lazy from lazy.a.b import c', ['lazy.a.b.c'], f), + ] +else: + lazy_imports = [] + +test_data = [ + *application_imports, + *stdlib_imports, + *venv_imports, + *typing_block_imports, + *dunder_lazy_ignore_imports, + *relative_dunder_lazy_ignore_imports, + *lazy_imports, +] @pytest.mark.parametrize(('example', 'result', 'loader'), test_data) diff --git a/tests/test_name_extraction.py b/tests/test_name_extraction.py index 2e20e37..ddbe1fc 100644 --- a/tests/test_name_extraction.py +++ b/tests/test_name_extraction.py @@ -1,5 +1,4 @@ import ast -import sys import pytest @@ -25,21 +24,16 @@ ('Nested["str"]', {'Nested', 'str'}), ('Annotated[str, validator(int, 5)]', {'Annotated', 'str'}), ('Annotated[str, "bool"]', {'Annotated', 'str'}), + ('*Ts', {'Ts'}), ] -if sys.version_info >= (3, 11): - examples.extend( - [ - ('*Ts', {'Ts'}), - ] - ) - @pytest.mark.parametrize(('example', 'expected'), examples) def test_name_extraction(example, expected): import_visitor = ImportVisitor( cwd='fake cwd', # type: ignore[arg-type] py314plus=False, + ignore_dunder_lazy_modules=False, pydantic_enabled=False, fastapi_enabled=False, fastapi_dependency_support_enabled=False, diff --git a/tests/test_name_visitor.py b/tests/test_name_visitor.py index 36f041d..cfcc2cc 100644 --- a/tests/test_name_visitor.py +++ b/tests/test_name_visitor.py @@ -13,6 +13,7 @@ def _get_names_and_soft_uses(example: str) -> tuple[set[str], set[str]]: visitor = ImportVisitor( cwd='fake cwd', # type: ignore[arg-type] py314plus=False, + ignore_dunder_lazy_modules=False, pydantic_enabled=False, fastapi_enabled=False, fastapi_dependency_support_enabled=False, diff --git a/tests/test_py315plus.py b/tests/test_py315plus.py new file mode 100644 index 0000000..929c4b4 --- /dev/null +++ b/tests/test_py315plus.py @@ -0,0 +1,23 @@ +import textwrap + +from flake8_type_checking.constants import LAZY_SUFFIX, TC002 +from tests.conftest import _get_error + + +def test_py315plus(): + """ + Assert that TC00[1-3] errors contain a hint about lazy imports + as a possible remediation in addition to moving the import into + a type checking_block + """ + example = textwrap.dedent(''' + from pandas import DataFrame + + a: DataFrame + ''') + assert _get_error(example, error_code_filter='TC002', type_checking_py315plus=False) == { + '2:0 ' + TC002.format(module='pandas.DataFrame') + } + assert _get_error(example, error_code_filter='TC002', type_checking_py315plus=True) == { + '2:0 ' + TC002.format(module='pandas.DataFrame') + LAZY_SUFFIX + } diff --git a/tests/test_tc001_to_tc003.py b/tests/test_tc001_to_tc003.py index dfcebf7..05ffe7c 100644 --- a/tests/test_tc001_to_tc003.py +++ b/tests/test_tc001_to_tc003.py @@ -121,6 +121,72 @@ def get_tc_001_to_003_tests(import_: str, ERROR: str) -> L: (f'import {import_}\ntype x = {import_}', {f"1:0 {ERROR.format(module=f'{import_}')}"}) ) + # Lazy imports should never generate errors + lazy_imports: L = [ + # ast.Import + (f'__lazy_modules__=["{import_}"]\nimport {import_}\nx:{import_}', set()), + (f'\n__lazy_modules__=["{import_}"]\nimport {import_}\nx:{import_}', set()), + # ast.ImportFrom + (f'__lazy_modules__=["{import_}"]\nfrom {import_} import Plugin\nx:Plugin', set()), + (f'__lazy_modules__=["{import_}"]\n\nfrom {import_} import constants\nx:constants', set()), + # Aliased imports + (f'__lazy_modules__=["{import_}"]\nimport {import_} as x\ny:x', set()), + (f'__lazy_modules__=["{import_}"]\nfrom {import_} import constants as x\ny:x', set()), + ] + + if ERROR == TC001: + # flake8-lazy style relative imports + lazy_imports.extend( + ( + ( + textwrap.dedent(f''' + __lazy_modules__ = [f"{{__spec__.parent}}.{import_}"] + from .{import_} import constants as x + + y: x + '''), + set(), + ), + ( + textwrap.dedent(f''' + __lazy_modules__ = [ + f"{{__spec__.parent.rsplit('.', 1)[0]}}.{import_}" + ] + from ..{import_} import x + + y: x + '''), + set(), + ), + ( + textwrap.dedent(f''' + __lazy_modules__ = [ + f"{{(__spec__.parent or '').rsplit('.', 2)[0]}}.{import_}" + ] + from ...{import_} import x + + y: x + '''), + set(), + ), + ) + ) + + if sys.version_info >= (3, 15): + lazy_imports.extend( + ( + # ast.Import + (f'lazy import {import_}\nx:{import_}', set()), + (f'\nlazy import {import_}\nx:{import_}', set()), + # ast.ImportFrom + (f'lazy from {import_} import Plugin\nx:Plugin', set()), + (f'\n\nlazy from {import_} import constants\nx:constants', set()), + # Aliased imports + (f'lazy import {import_} as x\ny:x', set()), + (f'lazy from {import_} import constants as x\ny:x', set()), + ) + ) + # Imports used for `functools.singledispatch`. None of these should generate errors. used_for_singledispatch: L = [ ( @@ -325,6 +391,7 @@ def example() -> Any: *used_for_arg_annotations_only, *used_for_return_annotations_only, *used_for_type_alias_only, + *lazy_imports, *used_for_singledispatch, *other_useful_test_cases, ] diff --git a/tests/test_tc200.py b/tests/test_tc200.py index 7d3316e..b9708c0 100644 --- a/tests/test_tc200.py +++ b/tests/test_tc200.py @@ -137,21 +137,17 @@ def foo(self) -> None: '''), set(), ), -] - -if sys.version_info >= (3, 11): - # PEP646 tests - examples += [ - ( - textwrap.dedent(""" + ( + # PEP646 test + textwrap.dedent(""" if TYPE_CHECKING: Ts = TypeVarTuple("Ts") x: tuple[*Ts] """), - {'5:10 ' + TC200.format(annotation='Ts')}, - ) - ] + {'5:10 ' + TC200.format(annotation='Ts')}, + ), +] if sys.version_info >= (3, 12): # PEP695 tests