diff --git a/README.md b/README.md index 694e032..a9513e1 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,24 @@ imports that *can* be moved. type-checking-strict = true # default false ``` +### Force `from __future__ import annotations` import + +The plugin, by default, will only report a TC100 error, if annotations +contain references to typing only symbols. If you want to enforce a more +consistent style and use a future import in every file that makes use +of annotations, you can enable this setting. + +When `force-future-annotation` is enabled, the plugin will flag all +files that contain annotations but not future import. + +- **setting name**: `type-checking-force-future-annotation` +- **type**: `bool` + +```ini +[flake8] +type-checking-force-future-annotation = true # default false +``` + ### Pydantic support If you use Pydantic models in your code, you should enable Pydantic support. diff --git a/flake8_type_checking/checker.py b/flake8_type_checking/checker.py index d866926..1080e21 100644 --- a/flake8_type_checking/checker.py +++ b/flake8_type_checking/checker.py @@ -1020,6 +1020,7 @@ def __init__( pydantic_enabled_baseclass_passlist: list[str], typing_modules: list[str] | None = None, exempt_modules: list[str] | None = None, + force_future_annotation: bool = False, ) -> None: super().__init__() @@ -1043,6 +1044,9 @@ def __init__( #: Import patterns we want to avoid mapping self.exempt_modules: list[str] = exempt_modules or [] + #: Whether or not TC100 should always be emitted if there are annotations + self.force_future_annotation = force_future_annotation + #: All imports, in each category self.application_imports: dict[str, Import] = {} self.third_party_imports: dict[str, Import] = {} @@ -1935,6 +1939,7 @@ def __init__(self, node: ast.Module, options: Namespace | None) -> None: 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) 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) @@ -1960,6 +1965,7 @@ def __init__(self, node: ast.Module, options: Namespace | None) -> None: cattrs_enabled=cattrs_enabled, typing_modules=typing_modules, exempt_modules=exempt_modules, + force_future_annotation=force_future_annotation, sqlalchemy_enabled=sqlalchemy_enabled, sqlalchemy_mapped_dotted_names=sqlalchemy_mapped_dotted_names, fastapi_dependency_support_enabled=fastapi_dependency_support_enabled, @@ -2123,7 +2129,13 @@ def missing_quotes_or_futures_import(self) -> Flake8Generator: # if any of the symbols imported/declared in type checking blocks are used # in an annotation outside a type checking block, then we need to emit TC100 - if encountered_missing_quotes and not self.visitor.futures_annotation: + if ( + encountered_missing_quotes + or ( + self.visitor.force_future_annotation + and (self.visitor.unwrapped_annotations or self.visitor.wrapped_annotations) + ) + ) and not self.visitor.futures_annotation: yield 1, 0, TC100, None def futures_excess_quotes(self) -> Flake8Generator: diff --git a/flake8_type_checking/plugin.py b/flake8_type_checking/plugin.py index 68a251d..b3b70e3 100644 --- a/flake8_type_checking/plugin.py +++ b/flake8_type_checking/plugin.py @@ -55,6 +55,13 @@ def add_options(cls, option_manager: OptionManager) -> None: # pragma: no cover default=False, help='Flag individual imports rather than looking at the module.', ) + option_manager.add_option( + '--type-checking-force-future-annotation', + action='store_true', + parse_from_config=True, + default=False, + help='Always emit TC100 as long as there are any annotations and no future import.', + ) # Third-party library options option_manager.add_option( diff --git a/tests/conftest.py b/tests/conftest.py index 34ba3dc..7526cab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg mock_options.type_checking_sqlalchemy_mapped_dotted_names = [] mock_options.type_checking_injector_enabled = False mock_options.type_checking_strict = False + mock_options.type_checking_force_future_annotation = False # kwarg overrides for k, v in kwargs.items(): setattr(mock_options, k, v) diff --git a/tests/test_force_future_annotation.py b/tests/test_force_future_annotation.py new file mode 100644 index 0000000..c28d445 --- /dev/null +++ b/tests/test_force_future_annotation.py @@ -0,0 +1,19 @@ +import textwrap + +from flake8_type_checking.constants import TC100 +from tests.conftest import _get_error + + +def test_force_future_annotation(): + """TC100 should be emitted even if there are no forward references to typing-only symbols.""" + example = textwrap.dedent( + ''' + from x import Y + + a: Y + ''' + ) + assert _get_error(example, error_code_filter='TC100', type_checking_force_future_annotation=False) == set() + assert _get_error(example, error_code_filter='TC100', type_checking_force_future_annotation=True) == { + '1:0 ' + TC100 + }