Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

False-positive TCH002 for runtime-required type annotations #14140

Open
OlehChyhyryn opened this issue Nov 6, 2024 · 9 comments
Open

False-positive TCH002 for runtime-required type annotations #14140

OlehChyhyryn opened this issue Nov 6, 2024 · 9 comments
Labels
rule Implementing or modifying a lint rule

Comments

@OlehChyhyryn
Copy link

OlehChyhyryn commented Nov 6, 2024

In our project, we are using https://injector.readthedocs.io/en/latest/ library to implement dependency injection.

For proper workflow, it requires the following reference of injecting types:

@inject
def fun(t: SomeType) -> None:
    pass
    
# or equivalent

def fun(t: Inject[SomeType]) -> None:
    pass

(link to the documentation - https://injector.readthedocs.io/en/latest/api.html#injector.Inject)

Both methods (type annotation and regular annotation) require that the context (Inject itself or any type underneath) be available during runtime.

According to the documentation, I added the required annotations to the flake8-type-checking configuration.

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = [
    "pydantic.BaseModel",
    "injector.Inject", # DI injection library
    "injector.ClassAssistedBuilder" # DI injection library
]

But it does not work as expected and returns false positive warnings.

You can reproduce it using the following sanitized sample:

from __future__ import annotations

from typing import Any

from injector import Inject

from services.service_a import ServiceA
from services.service_b import ServiceB



class SomeTask:
    """
    Task to pre-heat public URLs cache.
    """

    def __init__(
        self,
        *args: Any,
        some_other_variable: str,
        service_a: Inject[ServiceA],
        service_b: Inject[ServiceB],
    ) -> None:
        self._service_a = service_a
        self._service_b = service_b
        self._some_other_variable = some_other_variable

After running a ruff check for this file, I get the following response:

sample_sanitized.py:24:22:   TCH002 Move third-party import `injector.Inject` into a type-checking block
   |
22 | from typing import Any
23 | 
24 | from injector import Inject
   |                      ^^^^^^ TCH002
25 | 
26 | from services.service_a import ServiceA
   |
   = help: Move into type-checking block

sample_sanitized.py:26:32: TCH002 Move third-party import `services.service_a.ServiceA` into a type-checking block
   |
24 | from injector import Inject
25 | 
26 | from services.service_a import ServiceA
   |                                ^^^^^^^^ TCH002
27 | from services.service_b import ServiceB
   |
   = help: Move into type-checking block

sample_sanitized.py:27:32: TCH002 Move third-party import `services.service_b.ServiceB` into a type-checking block
   |
26 | from services.service_a import ServiceA
27 | from services.service_b import ServiceB
   |                                ^^^^^^^^ TCH002
   |
   = help: Move into type-checking block

Found 3 errors.
No fixes available (3 hidden fixes can be enabled with the `--unsafe-fixes` option).

This issue is reproducible not only for exactly this library but for any typing annotations evaluated at runtime in similar fashion

@OlehChyhyryn OlehChyhyryn changed the title False TCH002 for runtime-required type annotations False-positive TCH002 for runtime-required type annotations Nov 6, 2024
@MichaReiser
Copy link
Member

Thanks for the nice write up!

This sounds related to #13713

@MichaReiser MichaReiser added the rule Implementing or modifying a lint rule label Nov 6, 2024
@charliermarsh
Copy link
Member

Did you try using runtime-evaluated-decorators with injector.inject? You might have better luck there. runtime-evaluated-base-classes definitely doesn't work for annotations (e.g., Inject[SomeType]); it's applied to the class body when the mentioned class is a base class.

@OlehChyhyryn
Copy link
Author

OlehChyhyryn commented Nov 18, 2024

Sorry, I was on vacation, so I could not answer quickly.

Yes, I tested this (and checked again today) and got the same result as I described in the issue description

UPD:

Let me check it with the 0.7.4 release

@OlehChyhyryn
Copy link
Author

Checked again against https://github.com/astral-sh/ruff/releases/tag/0.7.4

The same results were obtained after adding the following to the project.toml:

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = [
    "pydantic.BaseModel",
    "injector.Inject", # DI injection library
    "injector.ClassAssistedBuilder" # DI injection library
]
runtime-evaluated-decorators = [
    "injector.Inject", # DI injection library
    "injector.ClassAssistedBuilder" # DI injection library
]

@charliermarsh
Copy link
Member

To clarify, I believe you want:

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-decorators = [
    "injector.inject",
]

Notice the lower-case inject.

@OlehChyhyryn
Copy link
Author

OlehChyhyryn commented Nov 18, 2024

I checked again with this one. Still working not as intended:

I have specified pyproject.toml as

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = [
    "injector.Inject", # DI injection library
    "injector.ClassAssistedBuilder", # DI injection library
    "injector.inject", # DI injection library
]
runtime-evaluated-decorators = [
    "injector.inject", # DI injection library
    "injector.Inject", # DI injection library
    "injector.ClassAssistedBuilder" # DI injection library
]

Checked for both versions of the declaration:

@annotation scenario is working properly.

In a scenario where only typing annotation is used, e.g., Inject[ServiceA], the error still exists.

from __future__ import annotations

from typing import Any

from injector import Inject
from services.service_a import ServiceA
from services.service_b import ServiceB


class SomeTask:
    """
    Task to pre-heat public URLs cache.
    """

    @inject
    def __init__(
        self,
        *args: Any,
        some_other_variable: str,
        service_a: Inject[ServiceA],
        service_b: Inject[ServiceB],
    ) -> None:
        self._service_a = service_a
        self._service_b = service_b
        self._some_other_variable = some_other_variable

This one still generates false positives.

@OlehChyhyryn
Copy link
Author

OlehChyhyryn commented Mar 18, 2025

Sorry for bumping it up; the reference task (#13713) has been closed.

But this error (with runtime-required) annotation is still reproducible. I checked it with Ruff 0.11.0

[tool.ruff.lint.flake8-type-checking]
# Add quotes around type annotations, if doing so would allow
# an import to be moved into a type-checking block.
quote-annotations = true
runtime-evaluated-base-classes = [
    "injector.Inject", # DI injection library
]
exempt-modules = ["injector"]
from __future__ import annotations

from typing import Any

from injector import Inject
from services.service_a import ServiceA
from services.service_b import ServiceB


class SomeTask:
    """
    Task to pre-heat public URLs cache.
    """

    def __init__(
        self,
        *args: Any,
        some_other_variable: str,
        service_a: Inject[ServiceA],
        service_b: Inject[ServiceB],
    ) -> None:
        self._service_a = service_a
        self._service_b = service_b
        self._some_other_variable = some_other_variable
src/example.py:3:20: TC003 Move standard library import `typing.Any` into a type-checking block
  |
1 | from __future__ import annotations
2 |
3 | from typing import Any
  |                    ^^^ TC003
4 |
5 | from injector import Inject
  |
  = help: Move into type-checking block

src/example.py:6:32: TC002 Move third-party import `services.service_a.ServiceA` into a type-checking block
  |
5 | from injector import Inject
6 | from services.service_a import ServiceA
  |                                ^^^^^^^^ TC002
7 | from services.service_b import ServiceB
  |
  = help: Move into type-checking block

src/example.py:7:32: TC002 Move third-party import `services.service_b.ServiceB` into a type-checking block
  |
5 | from injector import Inject
6 | from services.service_a import ServiceA
7 | from services.service_b import ServiceB
  |                                ^^^^^^^^ TC002
  |
  = help: Move into type-checking block

Found 3 errors.
No fixes available (3 hidden fixes can be enabled with the `--unsafe-fixes` option).

The expected behaviour of Injector type annotation is the fact annotation itself and everything under it I available in the runtime

@ntBre
Copy link
Contributor

ntBre commented Mar 18, 2025

Thanks for the reminder. Here's a reproduction in the playground, which might be a little easier for others to try out.

@Daverball do you know if this is resolved by any of your open PRs? I know you've been working on these rules and would know better than me.

@Daverball
Copy link
Contributor

Daverball commented Mar 18, 2025

No, but it's tangentially related to one of my open issues, so I plan on tackling this issue eventually: #16412

We would definitely need a new setting, so we can treat certain generics as always runtime required.

What makes this particular case a lot trickier however, is that generally it would not be enough just to treat this generic and anything it encloses as runtime required, since most naïve uses of runtime type information will use inspect.get_annotations or typing.get_type_hints both of which will fail if not all the symbols in all of the annotations are available at runtime. This will change however in Python 3.14 with PEP 649, which allows partial evaluation of __annotations__, that replaces anything it can't resolve with a ForwardRef object. There are already some runtime type libraries that go out of their way to be more forgiving. So treating everything else as runtime required would not be entirely accurate either. Which is why I believe we would also need to mark those type expressions as runtime ambiguous, i.e. neither treating them as runtime required, nor typing only. This would also involve first having to peek at all of the annotations of a class or function definition so we can check whether or not there's a runtime required generic in play.

A workaround for this that currently works is to define a no-op decorator and add it to runtime-evaluated-decorators, so you can manually mark functions that contain annotations that are runtime required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
rule Implementing or modifying a lint rule
Projects
None yet
Development

No branches or pull requests

5 participants