From 89b1b12829c602786e9ac8e3eae0be144b62d543 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 24 Sep 2026 11:54:02 +0200 Subject: [PATCH 1/5] chore: Add zizmor and updates workflows and pre-commit hooks --- .github/workflows/publish.yml | 40 +++++++++++++++++------------------ .github/workflows/testing.yml | 40 +++++++++++++++++++++++------------ .github/workflows/zizmor.yml | 33 +++++++++++++++++++++++++++++ .pre-commit-config.yaml | 16 ++++++++------ 4 files changed, 89 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/zizmor.yml 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 From f49381afb77dcc89577bd9371dfec74b6178cc62 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 24 Sep 2026 11:54:38 +0200 Subject: [PATCH 2/5] feat: Add support for Python 3.15 and lazy imports. Drop Python 3.10 support. --- README.md | 94 +++++++++- flake8_type_checking/checker.py | 226 ++++++++++++++++------- flake8_type_checking/constants.py | 1 + flake8_type_checking/plugin.py | 20 +- poetry.lock | 89 +-------- pyproject.toml | 6 +- tests/conftest.py | 9 +- tests/test_ignore_dunder_lazy_modules.py | 21 +++ tests/test_import_visitors.py | 69 ++++++- tests/test_name_extraction.py | 10 +- tests/test_name_visitor.py | 1 + tests/test_py315plus.py | 23 +++ tests/test_tc001_to_tc003.py | 29 +++ tests/test_tc200.py | 16 +- 14 files changed, 429 insertions(+), 185 deletions(-) create mode 100644 tests/test_ignore_dunder_lazy_modules.py create mode 100644 tests/test_py315plus.py 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..337d202 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, @@ -171,30 +172,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]] = [] + self._in__all__declaration = False - def in___all___declaration(self, node: ast.Constant) -> bool: - """ - Indicate whether a node is a sub-node of an __all__ assignment node. - - 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 +200,101 @@ 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) class PydanticMixin: @@ -588,6 +658,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 +750,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 +1049,7 @@ def visit_annotation_string(self, node: ast.Constant) -> None: class ImportVisitor( DunderAllMixin, + DunderLazyModulesMixin, FunctoolsSingledispatchMixin, AttrsMixin, InjectorMixin, @@ -990,6 +1067,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 +1104,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 @@ -1335,48 +1420,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 +1576,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 +1656,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 +1695,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 +1718,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 +1740,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 +2008,7 @@ class TypingOnlyImportsChecker: __slots__ = [ 'cwd', 'strict_mode', - 'py314plus', + 'py315plus', 'builtin_names', 'used_type_checking_names', 'visitor', @@ -1936,19 +2019,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 +2056,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 +2119,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 +2130,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..40a0b96 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,64 @@ 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, + ), +] + +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, + *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..537b620 100644 --- a/tests/test_tc001_to_tc003.py +++ b/tests/test_tc001_to_tc003.py @@ -121,6 +121,34 @@ 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 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 +353,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 From 577dcaa3768a33458a40fd85475564c5e7284574 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 24 Sep 2026 10:56:23 +0200 Subject: [PATCH 3/5] chore: Refactor some of the AST traversal logic into match statements --- flake8_type_checking/checker.py | 279 +++++++++++++++----------------- 1 file changed, 128 insertions(+), 151 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index 337d202..c713c10 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -82,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: @@ -404,7 +401,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` @@ -433,72 +430,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: """ @@ -1302,39 +1300,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. @@ -1350,23 +1315,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: From 604e4cca0c75ae7a02131c0f0704b7966dabfe1e Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 24 Sep 2026 14:07:43 +0200 Subject: [PATCH 4/5] feat: Support `flake8-lazy` style relative imports in `__lazy_modules__` --- flake8_type_checking/checker.py | 98 +++++++++++++++++++++++++++++++++ tests/test_import_visitors.py | 36 ++++++++++++ tests/test_tc001_to_tc003.py | 38 +++++++++++++ 3 files changed, 172 insertions(+) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index c713c10..e8e9b2b 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -293,6 +293,104 @@ def visit_Constant(self, node: ast.Constant) -> None: 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: """ diff --git a/tests/test_import_visitors.py b/tests/test_import_visitors.py index 40a0b96..84ab6d2 100644 --- a/tests/test_import_visitors.py +++ b/tests/test_import_visitors.py @@ -134,6 +134,41 @@ def _get_lazy_imports(example: str) -> list[str]: ), ] +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 @@ -154,6 +189,7 @@ def _get_lazy_imports(example: str) -> list[str]: *venv_imports, *typing_block_imports, *dunder_lazy_ignore_imports, + *relative_dunder_lazy_ignore_imports, *lazy_imports, ] diff --git a/tests/test_tc001_to_tc003.py b/tests/test_tc001_to_tc003.py index 537b620..05ffe7c 100644 --- a/tests/test_tc001_to_tc003.py +++ b/tests/test_tc001_to_tc003.py @@ -134,6 +134,44 @@ def get_tc_001_to_003_tests(import_: str, ERROR: str) -> L: (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( ( From 57501d1f731105f727d48d8e93f2d0bdf2f1aee0 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Tue, 13 Oct 2026 14:40:11 +0200 Subject: [PATCH 5/5] feat: Support relative imports mapping to absolute lazy module names Since the only adverse outcome for detecting relative lazy imports via `__lazy_modules__` too eagerly is a false negative for that specific import, we can be a lot more forgiving and match both any absolute import names that could map to this relative import and also match other esoteric ways to write relative imports using expressions other than the conventions defined by `flake8-lazy`. This improves interoperability with ruff, which currently does not support `flake8-lazy`'s relative import conventions and generally allows users to be more free in how they handle relative lazy imports with `__lazy_modules__`. --- flake8_type_checking/checker.py | 33 +++++++++++++++++++--- tests/test_import_visitors.py | 49 +++++++++++++++++++++++++++++++++ tests/test_tc001_to_tc003.py | 43 ++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index e8e9b2b..d985083 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -232,6 +232,7 @@ class DunderLazyModulesMixin: if TYPE_CHECKING: lazy_modules: set[str] + relative_lazy_modules: set[str] ignore_dunder_lazy_modules: bool current_scope: Scope @@ -384,12 +385,15 @@ def visit_JoinedStr(self, node: ast.JoinedStr) -> None: ) if module.startswith('.'): level_minus_one = self.relative_import_level_minus_one(expr) if level_minus_one is None: + # we don't know the exact level, so record as a regular + # name so we perform a suffix match + self.lazy_modules.add(module) return if level_minus_one: module = '.' * level_minus_one + module - self.lazy_modules.add(module) + self.relative_lazy_modules.add(module) class PydanticMixin: @@ -754,7 +758,7 @@ class ImportName: #: Whether or not this import is exempt from TC001-004 checks. exempt: bool - #: Whether or not this import is lazy + #: Whether or not this import could be lazy is_lazy: bool | None @property @@ -1203,6 +1207,9 @@ def __init__( #: A set of modules marked as Python 3.15+ lazy imports self.lazy_modules: set[str] = set() + #: A set of relative module names detected using flake8-lazy's conventions + self.relative_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 @@ -1507,8 +1514,26 @@ def add_import(self, node: Import) -> None: # noqa: C901 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 + if not all_lazy and (self.lazy_modules or self.relative_lazy_modules): + module_name = module.rstrip('.') + if ( + module_name in self.lazy_modules + or module_name in self.relative_lazy_modules + or ( + # For relative imports it might match one of the absolute + # imports as long as it contains at least one dot, we can't + # prove whether or not the import is lazy, so we err on the + # side of caution and treat it as if it were lazy, so we + # don't generate any false positives + node.level != 0 + and any( + lazy_module.endswith('.' + module_name.lstrip('.')) + for lazy_module in self.lazy_modules + if '.' in lazy_module + ) + ) + ): + all_lazy = True else: module = '' diff --git a/tests/test_import_visitors.py b/tests/test_import_visitors.py index 84ab6d2..9bb7964 100644 --- a/tests/test_import_visitors.py +++ b/tests/test_import_visitors.py @@ -169,6 +169,54 @@ def _get_lazy_imports(example: str) -> list[str]: ), ] +relative_dunder_lazy_ignore_imports_absolute_or_unknown_format = [ + # we don't know how absolute import names map to relative ones + # so we have to assume the relative imports are lazy if they could + # match a lazy import. + ( + textwrap.dedent(''' + __lazy_modules__ = ["my_module.a"] + from .a import lazy, also_lazy + from ..a import still_lazy + '''), + ['.a.lazy', '.a.also_lazy', '..a.still_lazy'], + f, + ), + # test some other esoteric ways relative imports could show up + ( + textwrap.dedent(''' + __lazy_modules__ = [f"{parent_module}.a"] + from .a import lazy, also_lazy + from ..a import still_lazy + '''), + ['.a.lazy', '.a.also_lazy', '..a.still_lazy'], + f, + ), + ( + textwrap.dedent(''' + __lazy_modules__ = [ + parent_module + ".a" + ] + from .a import still_lazy + from ..a import lazy + '''), + ['.a.still_lazy', '..a.lazy'], + f, + ), + ( + textwrap.dedent(''' + __lazy_modules__ = [ + relative_import_name(".a") + ] + from .a import still_lazy + from ..a import also_still_lazy + from ...a import lazy + '''), + ['.a.still_lazy', '..a.also_still_lazy', '...a.lazy'], + f, + ), +] + if sys.version_info >= (3, 15): lazy_imports = [ # ast.Import @@ -190,6 +238,7 @@ def _get_lazy_imports(example: str) -> list[str]: *typing_block_imports, *dunder_lazy_ignore_imports, *relative_dunder_lazy_ignore_imports, + *relative_dunder_lazy_ignore_imports_absolute_or_unknown_format, *lazy_imports, ] diff --git a/tests/test_tc001_to_tc003.py b/tests/test_tc001_to_tc003.py index 05ffe7c..9bd4187 100644 --- a/tests/test_tc001_to_tc003.py +++ b/tests/test_tc001_to_tc003.py @@ -135,9 +135,9 @@ def get_tc_001_to_003_tests(import_: str, ERROR: str) -> L: ] if ERROR == TC001: - # flake8-lazy style relative imports lazy_imports.extend( ( + # flake8-lazy style relative imports ( textwrap.dedent(f''' __lazy_modules__ = [f"{{__spec__.parent}}.{import_}"] @@ -165,6 +165,47 @@ def get_tc_001_to_003_tests(import_: str, ERROR: str) -> L: ] from ...{import_} import x + y: x + '''), + set(), + ), + # absolute imports mapping to relative imports + ( + textwrap.dedent(f''' + __lazy_modules__ = ["my_module.{import_}"] + from .{import_} import x + + y: x + '''), + set(), + ), + # esoteric relative import formats + ( + textwrap.dedent(f''' + parent_module = __spec__.parent + __lazy_modules__ = [f"{{parent_module}}.{import_}"] + from .{import_} import x + + y: x + '''), + set(), + ), + ( + textwrap.dedent(f''' + parent_module = __spec__.parent + __lazy_modules__ = [parent_module + ".{import_}"] + from .{import_} import x + + y: x + '''), + set(), + ), + ( + textwrap.dedent(f''' + from .utils import relative_import + __lazy_modules__ = [relative_import(".{import_}")] + from .{import_} import x + y: x '''), set(),