Skip to content

Commit 1ede4bd

Browse files
OlehChyhyrynsondrelg
authored andcommitted
feat: support for injector library
1 parent 2f478a4 commit 1ede4bd

File tree

7 files changed

+224
-2
lines changed

7 files changed

+224
-2
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ More examples can be found in the [examples](#examples) section.
4444
<br>
4545

4646
If you're using [pydantic](https://pydantic-docs.helpmanual.io/),
47-
[fastapi](https://fastapi.tiangolo.com/), or [cattrs](https://github.com/python-attrs/cattrs)
47+
[fastapi](https://fastapi.tiangolo.com/), [cattrs](https://github.com/python-attrs/cattrs),
48+
or [injector](https://github.com/python-injector/injector)
4849
see the [configuration](#configuration) for how to enable support.
4950

5051
## Primary features
@@ -244,6 +245,17 @@ This can be added in the future if needed.
244245
type-checking-cattrs-enabled = true # default false
245246
```
246247

248+
### Injector support
249+
250+
If you're using the injector library, you can enable support.
251+
This will treat any `Inject[Dependency]` types as needed at runtime.
252+
253+
- **name**: `type-checking-injector-enabled`
254+
- **type**: `bool`
255+
```ini
256+
type-checking-injector-enabled = true # default false
257+
```
258+
247259
## Rationale
248260

249261
Why did we create this plugin?

flake8_type_checking/checker.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,53 @@ def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None:
246246
self.visit(argument.annotation)
247247

248248

249+
class InjectorMixin:
250+
"""
251+
Contains the necessary logic for injector (https://github.com/python-injector/injector) support.
252+
253+
For injected dependencies, we want to treat annotations as needed at runtime.
254+
"""
255+
256+
if TYPE_CHECKING:
257+
injector_enabled: bool
258+
259+
def visit(self, node: ast.AST) -> ast.AST: # noqa: D102
260+
...
261+
262+
def visit_FunctionDef(self, node: FunctionDef) -> None:
263+
"""Remove and map function arguments and returns."""
264+
super().visit_FunctionDef(node) # type: ignore[misc]
265+
if self.injector_enabled:
266+
self.handle_injector_declaration(node)
267+
268+
def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None:
269+
"""Remove and map function arguments and returns."""
270+
super().visit_AsyncFunctionDef(node) # type: ignore[misc]
271+
if self.injector_enabled:
272+
self.handle_injector_declaration(node)
273+
274+
def handle_injector_declaration(self, node: Union[AsyncFunctionDef, FunctionDef]) -> None:
275+
"""
276+
Adjust for injector declaration setting.
277+
278+
When the injector setting is enabled, treat all annotations from within
279+
a function definition (except for return annotations) as needed at runtime.
280+
281+
To achieve this, we just visit the annotations to register them as "uses".
282+
"""
283+
for path in [node.args.args, node.args.kwonlyargs]:
284+
for argument in path:
285+
if hasattr(argument, 'annotation') and argument.annotation:
286+
annotation = argument.annotation
287+
if not hasattr(annotation, 'value'):
288+
continue
289+
value = annotation.value
290+
if hasattr(value, 'id') and value.id == 'Inject':
291+
self.visit(argument.annotation)
292+
if hasattr(value, 'attr') and value.attr == 'Inject':
293+
self.visit(argument.annotation)
294+
295+
249296
class FastAPIMixin:
250297
"""
251298
Contains the necessary logic for FastAPI support.
@@ -522,7 +569,7 @@ def lookup(self, symbol_name: str, use: HasPosition | None = None, runtime_only:
522569
return parent.lookup(symbol_name, use, runtime_only)
523570

524571

525-
class ImportVisitor(DunderAllMixin, AttrsMixin, FastAPIMixin, PydanticMixin, ast.NodeVisitor):
572+
class ImportVisitor(DunderAllMixin, AttrsMixin, InjectorMixin, FastAPIMixin, PydanticMixin, ast.NodeVisitor):
526573
"""Map all imports outside of type-checking blocks."""
527574

528575
#: The currently active scope
@@ -534,6 +581,7 @@ def __init__(
534581
pydantic_enabled: bool,
535582
fastapi_enabled: bool,
536583
fastapi_dependency_support_enabled: bool,
584+
injector_enabled: bool,
537585
cattrs_enabled: bool,
538586
pydantic_enabled_baseclass_passlist: list[str],
539587
exempt_modules: Optional[list[str]] = None,
@@ -545,6 +593,7 @@ def __init__(
545593
self.fastapi_enabled = fastapi_enabled
546594
self.fastapi_dependency_support_enabled = fastapi_dependency_support_enabled
547595
self.cattrs_enabled = cattrs_enabled
596+
self.injector_enabled = injector_enabled
548597
self.pydantic_enabled_baseclass_passlist = pydantic_enabled_baseclass_passlist
549598
self.pydantic_validate_arguments_import_name = None
550599
self.cwd = cwd # we need to know the current directory to guess at which imports are remote and which are not
@@ -1448,6 +1497,7 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None:
14481497
fastapi_enabled = getattr(options, 'type_checking_fastapi_enabled', False)
14491498
fastapi_dependency_support_enabled = getattr(options, 'type_checking_fastapi_dependency_support_enabled', False)
14501499
cattrs_enabled = getattr(options, 'type_checking_cattrs_enabled', False)
1500+
injector_enabled = getattr(options, 'type_checking_injector_enabled', False)
14511501

14521502
if fastapi_enabled and not pydantic_enabled:
14531503
# FastAPI support must include Pydantic support.
@@ -1466,6 +1516,7 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None:
14661516
exempt_modules=exempt_modules,
14671517
fastapi_dependency_support_enabled=fastapi_dependency_support_enabled,
14681518
pydantic_enabled_baseclass_passlist=pydantic_enabled_baseclass_passlist,
1519+
injector_enabled=injector_enabled,
14691520
)
14701521
self.visitor.visit(node)
14711522

flake8_type_checking/plugin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ def add_options(cls, option_manager: OptionManager) -> None: # pragma: no cover
8585
default=False,
8686
help='Prevent flagging of annotations on attrs class definitions.',
8787
)
88+
option_manager.add_option(
89+
'--type-checking-injector-enabled',
90+
action='store_true',
91+
parse_from_config=True,
92+
default=False,
93+
help='Prevent flagging of annotations on injector class definitions.',
94+
)
8895

8996
def run(self) -> Flake8Generator:
9097
"""Run flake8 plugin and return any relevant errors."""

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def _get_error(example: str, *, error_code_filter: Optional[str] = None, **kwarg
4141
mock_options.type_checking_fastapi_enabled = False
4242
mock_options.type_checking_fastapi_dependency_support_enabled = False
4343
mock_options.type_checking_pydantic_enabled_baseclass_passlist = []
44+
mock_options.type_checking_injector_enabled = False
4445
mock_options.type_checking_strict = False
4546
# kwarg overrides
4647
for k, v in kwargs.items():

tests/test_import_visitors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def _visit(example: str) -> ImportVisitor:
1919
fastapi_enabled=False,
2020
fastapi_dependency_support_enabled=False,
2121
cattrs_enabled=False,
22+
injector_enabled=False,
2223
pydantic_enabled_baseclass_passlist=[],
2324
)
2425
visitor.visit(ast.parse(example.replace('; ', '\n')))

tests/test_injector.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""This file tests injector support."""
2+
3+
import textwrap
4+
5+
import pytest
6+
7+
from flake8_type_checking.constants import TC002
8+
from tests.conftest import _get_error
9+
10+
11+
@pytest.mark.parametrize(
12+
('enabled', 'expected'),
13+
[
14+
(True, {'2:0 ' + TC002.format(module='services.Service')}),
15+
(False, {'2:0 ' + TC002.format(module='services.Service')}),
16+
],
17+
)
18+
def test_non_pydantic_model(enabled, expected):
19+
"""A class does not use injector, so error should be risen in both scenarios."""
20+
example = textwrap.dedent('''
21+
from services import Service
22+
23+
class X:
24+
def __init__(self, service: Service) -> None:
25+
self.service = service
26+
''')
27+
assert _get_error(example, error_code_filter='TC002', type_checking_pydantic_enabled=enabled) == expected
28+
29+
30+
@pytest.mark.parametrize(
31+
('enabled', 'expected'),
32+
[
33+
(True, set()),
34+
(False, {'2:0 ' + TC002.format(module='injector.Inject'), '3:0 ' + TC002.format(module='services.Service')}),
35+
],
36+
)
37+
def test_injector_option(enabled, expected):
38+
"""When an injector option is enabled, injector should be ignored."""
39+
example = textwrap.dedent('''
40+
from injector import Inject
41+
from services import Service
42+
43+
class X:
44+
def __init__(self, service: Inject[Service]) -> None:
45+
self.service = service
46+
''')
47+
assert _get_error(example, error_code_filter='TC002', type_checking_injector_enabled=enabled) == expected
48+
49+
50+
@pytest.mark.parametrize(
51+
('enabled', 'expected'),
52+
[
53+
(True, {'4:0 ' + TC002.format(module='other_dependency.OtherDependency')}),
54+
(
55+
False,
56+
{
57+
'2:0 ' + TC002.format(module='injector.Inject'),
58+
'3:0 ' + TC002.format(module='services.Service'),
59+
'4:0 ' + TC002.format(module='other_dependency.OtherDependency'),
60+
},
61+
),
62+
],
63+
)
64+
def test_injector_option_only_allows_injected_dependencies(enabled, expected):
65+
"""Whenever an injector option is enabled, only injected dependencies should be ignored."""
66+
example = textwrap.dedent('''
67+
from injector import Inject
68+
from services import Service
69+
from other_dependency import OtherDependency
70+
71+
class X:
72+
def __init__(self, service: Inject[Service], other: OtherDependency) -> None:
73+
self.service = service
74+
self.other = other
75+
''')
76+
assert _get_error(example, error_code_filter='TC002', type_checking_injector_enabled=enabled) == expected
77+
78+
79+
@pytest.mark.parametrize(
80+
('enabled', 'expected'),
81+
[
82+
(True, {'4:0 ' + TC002.format(module='other_dependency.OtherDependency')}),
83+
(
84+
False,
85+
{
86+
'2:0 ' + TC002.format(module='injector.Inject'),
87+
'3:0 ' + TC002.format(module='services.Service'),
88+
'4:0 ' + TC002.format(module='other_dependency.OtherDependency'),
89+
},
90+
),
91+
],
92+
)
93+
def test_injector_option_only_allows_injector_slices(enabled, expected):
94+
"""
95+
Whenever an injector option is enabled, only injected dependencies should be ignored,
96+
not any dependencies with slices.
97+
"""
98+
example = textwrap.dedent("""
99+
from injector import Inject
100+
from services import Service
101+
from other_dependency import OtherDependency
102+
103+
class X:
104+
def __init__(self, service: Inject[Service], other_deps: list[OtherDependency]) -> None:
105+
self.service = service
106+
self.other_deps = other_deps
107+
""")
108+
assert _get_error(example, error_code_filter='TC002', type_checking_injector_enabled=enabled) == expected
109+
110+
111+
@pytest.mark.parametrize(
112+
('enabled', 'expected'),
113+
[
114+
(True, set()),
115+
(False, {'2:0 ' + TC002.format(module='injector'), '3:0 ' + TC002.format(module='services.Service')}),
116+
],
117+
)
118+
def test_injector_option_allows_injector_as_module(enabled, expected):
119+
"""Whenever an injector option is enabled, injected dependencies should be ignored, even if import as module."""
120+
example = textwrap.dedent('''
121+
import injector
122+
from services import Service
123+
124+
class X:
125+
def __init__(self, service: injector.Inject[Service]) -> None:
126+
self.service = service
127+
''')
128+
assert _get_error(example, error_code_filter='TC002', type_checking_injector_enabled=enabled) == expected
129+
130+
131+
@pytest.mark.parametrize(
132+
('enabled', 'expected'),
133+
[
134+
(True, set()),
135+
(False, {'2:0 ' + TC002.format(module='injector.Inject'), '3:0 ' + TC002.format(module='services.Service')}),
136+
],
137+
)
138+
def test_injector_option_only_mentioned_second_time(enabled, expected):
139+
"""Whenever an injector option is enabled, dependency referenced second time is accepted."""
140+
example = textwrap.dedent("""
141+
from injector import Inject
142+
from services import Service
143+
144+
class X:
145+
def __init__(self, service: Inject[Service], other_deps: list[Service]) -> None:
146+
self.service = service
147+
self.other_deps = other_deps
148+
""")
149+
assert _get_error(example, error_code_filter='TC002', type_checking_injector_enabled=enabled) == expected

tests/test_name_visitor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def _get_names(example: str) -> Set[str]:
1919
fastapi_enabled=False,
2020
fastapi_dependency_support_enabled=False,
2121
cattrs_enabled=False,
22+
injector_enabled=False,
2223
pydantic_enabled_baseclass_passlist=[],
2324
)
2425
visitor.visit(ast.parse(example))

0 commit comments

Comments
 (0)