Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: 24.10.0
rev: 25.12.0
hooks:
- id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-ast
- id: check-added-large-files
Expand All @@ -19,7 +19,7 @@ repos:
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
rev: 7.3.0
hooks:
- id: flake8
additional_dependencies: [
Expand All @@ -34,18 +34,24 @@ repos:
'flake8-type-checking==2.9.1',
]
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.0
rev: v3.21.2
hooks:
- id: pyupgrade
args: [ "--py39-plus", '--keep-runtime-typing' ]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
rev: 7.0.0
hooks:
- id: isort
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
rev: v1.19.1
hooks:
- id: mypy
additional_dependencies:
- pytest
- flake8
# NOTE: We want this hook to always run, but exactly once
# instead of for every file. So we exclude all files
exclude: '.*'
always_run: true
pass_filenames: false
args: ['-p', 'flake8_type_checking']
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ pip install flake8-type-checking

These options are configurable, and can be set in your flake8 config.

## Python 3.14+

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
configuration and select the `TC1` rules.

- **setting name**: `type-checking-p314plus`
- **type**: `bool`

```ini
[flake8]
type-checking-py314plus = true # default false
```

### Typing modules

If you re-export `typing` or `typing_extensions` members from a compatibility
Expand Down
31 changes: 26 additions & 5 deletions flake8_type_checking/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def visit_Constant(self, node: ast.Constant) -> ast.Constant:
# 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))
self.uses[node.value].append((node, self.current_scope)) # type: ignore[index]
return node


Expand Down Expand Up @@ -287,6 +287,7 @@ def visit_annotation_name(self, node: ast.Name) -> None:

def visit_annotation_string(self, node: ast.Constant) -> None:
"""Add all the names in the string to mapped names."""
assert isinstance(node.value, str)
visitor = StringAnnotationVisitor(self.plugin)
visitor.parse_and_visit_string_annotation(node.value)
self.plugin.soft_uses.update(visitor.names)
Expand Down Expand Up @@ -838,6 +839,7 @@ def visit_annotation_name(self, node: ast.Name) -> None:

def visit_annotation_string(self, node: ast.Constant) -> None:
"""Parse and visit nested string annotations."""
assert isinstance(node.value, str)
self.parse_and_visit_string_annotation(node.value)


Expand Down Expand Up @@ -902,6 +904,7 @@ def visit_annotation_name(self, node: ast.Name) -> None:

def visit_annotation_string(self, node: ast.Constant) -> None:
"""Register wrapped annotation and invalid binop literals."""
assert isinstance(node.value, str)
setattr(node, ANNOTATION_PROPERTY, True)
# we don't want to register them as both so we don't emit redundant errors
if getattr(node, BINOP_OPERAND_PROPERTY, False):
Expand Down Expand Up @@ -930,7 +933,7 @@ def visit_annotated_value(self, node: ast.expr) -> None:
if self.never_evaluates:
return

if self.type == 'alias' or (self.type == 'annotation' and not self.import_visitor.futures_annotation):
if self.type == 'alias' or (self.type == 'annotation' and not self.import_visitor.are_annotations_deferred):
# visit nodes in regular runtime context
self.import_visitor.visit(node)
return
Expand Down Expand Up @@ -962,6 +965,7 @@ def visit_annotation_name(self, node: ast.Name) -> None:

def visit_annotation_string(self, node: ast.Constant) -> None:
"""Collect all the names referenced inside the forward reference."""
assert isinstance(node.value, str)
visitor = StringAnnotationVisitor(self._typing_lookup)
visitor.parse_and_visit_string_annotation(node.value)
self.quoted_names.update(visitor.names)
Expand All @@ -985,6 +989,7 @@ class ImportVisitor(
def __init__(
self,
cwd: Path,
py314plus: bool,
pydantic_enabled: bool,
fastapi_enabled: bool,
fastapi_dependency_support_enabled: bool,
Expand All @@ -999,6 +1004,9 @@ def __init__(
) -> None:
super().__init__()

#: Whether or not we should be using Python 3.14 semantics for annotations
self.py314plus = py314plus

#: Plugin settings
self.pydantic_enabled = pydantic_enabled
self.fastapi_enabled = fastapi_enabled
Expand Down Expand Up @@ -1166,6 +1174,16 @@ def lookup_full_name(self, node: ast.AST) -> str | None:
self._lookup_cache[node] = name
return name

@property
def are_annotations_deferred(self) -> bool:
"""
Return whether or not annotations are deferred.

This is the case when either there is a `from __future__ import annotations`
import present or we're targetting Python 3.14+.
"""
return self.py314plus or self.futures_annotation is True

def is_typing(self, node: ast.AST, symbol: str) -> bool:
"""Check if the given node matches the given typing symbol."""
full_name = self.lookup_full_name(node)
Expand Down Expand Up @@ -1904,6 +1922,7 @@ class TypingOnlyImportsChecker:
__slots__ = [
'cwd',
'strict_mode',
'py314plus',
'builtin_names',
'used_type_checking_names',
'visitor',
Expand All @@ -1914,6 +1933,7 @@ 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)

# we use the same option as pyflakes to extend the list of builtins
self.builtin_names = builtin_names
Expand Down Expand Up @@ -1946,6 +1966,7 @@ def __init__(self, node: ast.Module, options: Namespace | None) -> None:

self.visitor = ImportVisitor(
self.cwd,
py314plus=py314plus,
pydantic_enabled=pydantic_enabled,
fastapi_enabled=fastapi_enabled,
cattrs_enabled=cattrs_enabled,
Expand Down Expand Up @@ -2124,13 +2145,13 @@ def missing_quotes_or_futures_import(self) -> Flake8Generator:
self.visitor.force_future_annotation
and (self.visitor.unwrapped_annotations or self.visitor.wrapped_annotations)
)
) and not self.visitor.futures_annotation:
) and not self.visitor.are_annotations_deferred:
yield 1, 0, TC100, None

def futures_excess_quotes(self) -> Flake8Generator:
"""TC101."""
# If futures imports are present, any ast.Constant captured in add_annotation should yield an error
if self.visitor.futures_annotation:
if self.visitor.are_annotations_deferred:
for item in self.visitor.wrapped_annotations:
if item.type != 'annotation': # TypeAlias value will not be affected by a futures import
continue
Expand Down Expand Up @@ -2204,7 +2225,7 @@ def excess_quotes(self) -> Flake8Generator:
else:
error = TC201.format(annotation=item.annotation)

if not self.visitor.futures_annotation:
if not self.visitor.are_annotations_deferred:
yield item.lineno, item.col_offset, TC101.format(annotation=item.annotation), None

yield item.lineno, item.col_offset, error, None
Expand Down
7 changes: 7 additions & 0 deletions flake8_type_checking/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class Plugin:
@classmethod
def add_options(cls, option_manager: OptionManager) -> None: # pragma: no cover
"""Parse plugin options."""
option_manager.add_option(
'--type-checking-py314plus',
action='store_true',
parse_from_config=True,
default=False,
help='Enables Python 3.14+ specific annotation semantics.',
)
option_manager.add_option(
'--type-checking-typing-modules',
comma_separated_list=True,
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg
mock_options.builtins = []
mock_options.extended_default_select = []
mock_options.enable_extensions = []
mock_options.type_checking_py314plus = False
mock_options.type_checking_pydantic_enabled = False
mock_options.type_checking_exempt_modules = []
mock_options.type_checking_typing_modules = []
Expand Down
30 changes: 10 additions & 20 deletions tests/test_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ def test_attrs_model(imp, dec):
Test `attrs` classes together with a non-`attrs` class that has a class var of the same type.
`attrs` classes are instantiated using different dataclass decorators. The `attrs` module is imported as whole.
"""
example = textwrap.dedent(
f'''
example = textwrap.dedent(f'''
{imp}
from decimal import Decimal

Expand All @@ -43,8 +42,7 @@ class Y:

class Z:
x: Decimal
'''
)
''')
assert _get_error(example, error_code_filter='TC001,TC002,TC003') == set()


Expand All @@ -65,8 +63,7 @@ def test_complex_attrs_model(imp, dec, expected):
Test `attrs` classes together with a non-`attrs` class tha has a class var of another type.
`attrs` classes are instantiated using different dataclass decorators. The `attrs` module is imported as whole.
"""
example = textwrap.dedent(
f'''
example = textwrap.dedent(f'''
{imp}
from decimals import Decimal
from decimal import Context
Expand All @@ -81,8 +78,7 @@ class Y:

class Z:
x: Context
'''
)
''')
assert _get_error(example, error_code_filter='TC001,TC002,TC003') == expected


Expand All @@ -103,8 +99,7 @@ def test_complex_attrs_model_direct_import(imp, dec, expected):
Test `attrs` classes together with a non-`attrs` class tha has a class var of another type.
`attrs` classes are instantiated using different dataclass decorators which are imported as submodules.
"""
example = textwrap.dedent(
f'''
example = textwrap.dedent(f'''
{imp}
from decimals import Decimal
from decimal import Context
Expand All @@ -119,8 +114,7 @@ class Y:

class Z:
x: Context
'''
)
''')
assert _get_error(example, error_code_filter='TC001,TC002,TC003') == expected


Expand Down Expand Up @@ -150,8 +144,7 @@ def test_complex_attrs_model_as_import(imp, dec, expected):
`attrs` classes are instantiated using different dataclass
decorators which are imported as submodules using an alias.
"""
example = textwrap.dedent(
f'''
example = textwrap.dedent(f'''
{imp}
from decimals import Decimal
from decimal import Context
Expand All @@ -166,8 +159,7 @@ class Y:

class Z:
x: Context
'''
)
''')
assert _get_error(example, error_code_filter='TC001,TC002,TC003') == expected


Expand All @@ -193,8 +185,7 @@ def test_complex_attrs_model_slots_frozen(imp, dec, expected):
Test `attrs` classes together with a non-`attrs` class tha has a class var of another type.
`attrs` classes are instantiated using different dataclass decorators and arguments.
"""
example = textwrap.dedent(
f'''
example = textwrap.dedent(f'''
{imp}
from decimals import Decimal
from decimal import Context
Expand All @@ -209,6 +200,5 @@ class Y:

class Z:
x: Context
'''
)
''')
assert _get_error(example, error_code_filter='TC001,TC002,TC003') == expected
Loading