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

Recognize all symbols named TYPE_CHECKING for in_type_checking_block #15719

Merged

Conversation

Daverball
Copy link
Contributor

@Daverball Daverball commented Jan 24, 2025

Closes #15681

Summary

This changes analyze::typing::is_type_checking_block to recognize all symbols named "TYPE_CHECKING".
This matches the current behavior of mypy and pyright as well as flake8-type-checking.

It also drops support for detecting if False: and if 0: as type checking blocks. This used to be an option for
providing backwards compatibility with Python versions that did not have a typing module, but has since
been removed from the typing spec and is no longer supported by any of the mainstream type checkers.

Test Plan

cargo nextest run

This matches the current behavior of mypy and pyright as well as
`flake8-type-checking`.
@Daverball Daverball force-pushed the feat/TYPE_CHECKING_without_typing_import branch from cdea018 to f9f6540 Compare January 24, 2025 15:00
@Daverball
Copy link
Contributor Author

Daverball commented Jan 24, 2025

I will add more test cases, since I'm worried some of the TC fixes will not be able to deal with these new kinds of type checking blocks without additional changes.

Copy link
Contributor

github-actions bot commented Jan 24, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

ℹ️ ecosystem check detected linter changes. (+1 -0 violations, +0 -0 fixes in 1 projects; 54 projects unchanged)

scikit-build/scikit-build-core (+1 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --no-fix --output-format concise --preview

+ src/scikit_build_core/resources/_editable_redirect.py:11:12: TC004 Move import `importlib.machinery` out of type-checking block. Import is used for more than type hinting.

Changes by rule (1 rules affected)

code total + violation - violation + fix - fix
TC004 1 1 0 0 0

@MichaReiser
Copy link
Member

MichaReiser commented Jan 24, 2025

That was quick, thanks. I'm leaning towards making this a preview change because it's technically breaking. The counterargument is that there's barely any ecosystem change.

@MichaReiser MichaReiser added the rule Implementing or modifying a lint rule label Jan 24, 2025
@Daverball
Copy link
Contributor Author

I'm a little confused by the scikit ecosystem change. If anything I would've expected a previous TC003 error to disappear, not a new TC004 to appear, especially since there is no runtime use of importlib.machinery in that file...

So it seems like there's a different bug at play here, that was previously obscured by not recognizing the TYPE_CHECKING block in that file.

@Daverball
Copy link
Contributor Author

@MichaReiser If we're going to treat it as a breaking change anyways and put it behind the preview flag, I would suggest to also stop treating if False and if 0 as type checking blocks in the same change, since that hasn't been a thing since type checkers dropped support for Python 2.7/3.5.

@Daverball
Copy link
Contributor Author

I'm a little confused by the scikit ecosystem change. If anything I would've expected a previous TC003 error to disappear, not a new TC004 to appear, especially since there is no runtime use of importlib.machinery in that file...

So it seems like there's a different bug at play here, that was previously obscured by not recognizing the TYPE_CHECKING block in that file.

Looks like references to bindings don't really try to find their matching import beyond the first level, so since there are multiple importlib bindings, the references to importlib.abc and importlib.util will bind to the most recent importlib import, which happens to be importlib.machinery in the type checking block.

So it looks like TC001-004 will need to do something more sophisticated than simply looking at import bindings and their references, when that import binding came from a dotted import and it's shadowing another import. I will open a separate issue for this problem.

@Daverball
Copy link
Contributor Author

@MichaReiser I wonder if it would be alright to copy the preview flag to the SemanticModel. in_type_checking_block is used in quite a few places, so adding a new argument for the preview behavior only to remove it again later when we stabilize this, would end up introducing quite a bit of churn. Adding the flag to the SemanticModel would be a much smaller change and I would've found it quite useful in the past, just like checker.source_type is duplicated into a semantic flag for stub files.

I will also need to make some structural changes to Importer::typing_import_edit so it can still emit a fix for these new type checking blocks, since it currently always assumes it will need an import of TYPE_CHECKING from one of the typing modules, but we know for sure it will not need that if we have a preceding type checking block. If we want to gate this change behind a preview flag as well, that would be another place where having the preview flag on the SemanticModel would be useful.

Comment on lines 167 to 170
Some(Edit::range_replacement(
self.locator.slice(type_checking.range()).to_string(),
type_checking.range(),
))
Copy link
Contributor Author

@Daverball Daverball Jan 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By restructuring the logic, this no-op edit would now only happen if we don't have an existing preceding type checking block. I'm not sure whether this is problematic or why we're adding a no-op edit to begin with (maybe to ensure no other edit is allowed to remove the import?).

If this no-op edit indeed accomplishes something useful, we may need a more sophisticated change, which allows find_type_checking to return a name based on an assignment target in addition to an import. This would allow us to write separate fix logic for when it's imported and when it's defined in the same file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, the no-op edit is important. It's a "hack" to prevent any other fix to remove the import. At least, I remember something along those lines from @charliermarsh

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed that was the case, but then was surprised that none of the tests were failing. Seems kind of important to have at least one regression test for this case. In the absence of a failing test the other possibility is that this since has been fixed in a different way and is now essentially doing nothing.

Copy link
Contributor Author

@Daverball Daverball Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other thing that bothered me, is that this hack does not seem very reliable, since the type checking block we're moving the imports to might not correspond to the typing/TYPE_CHECKING import we find in the first step...

Maybe we revert it back to my earlier refactored version, but when we have an existing type checking block we lookup the relevant statement from the binding that's referenced by the condition in the type checking block and emit a no-op edit for that specific statement if it doesn't overlap with one of the import edits. That seems a lot more robust to me.

Copy link
Contributor Author

@Daverball Daverball Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had another little think about this. I don't think we have to worry about the no-op edit when we find a preceding type checking block. If another edit would remove the binding we're referencing, it would necessarily also have to remove the type checking block itself (otherwise there would still be a reference to the binding), but then the edit that removes the type checking block would overlap with our edit to add a new import to it, so we don't need to artificially create another overlap.

In the case where there isn't a type checking block, but there is a unused TYPE_CHECKING import, it makes sense, since we don't want to let F401 remove the import when our edit adds a new reference to it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I believe this is to prevent things like:

from typing import TYPE_CHECKING

from foo import Bar

def func() -> Bar: ...

If we don't add the no-op edit, TYPE_CHECKING might be removed, leaving us with:

if TYPE_CHECKING:
  from foo import Bar

def func() -> "Bar": ...

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. However, this is a somewhat significant change. I think i'd feel better if we gate this behind the preview mode and release it as part of 0.10.

crates/ruff_python_semantic/src/analyze/typing.rs Outdated Show resolved Hide resolved
Comment on lines 148 to 151
// TODO: Should we provide an option to avoid this import?
// E.g. either through an explicit setting, or implicitly
// when `typing` isn't part of the exempt modules and there
// are no other existing runtime imports of `typing`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah this is interesting. Not having an option seems fine to me for now, we can introduce one later if it is being asked for.

@Daverball
Copy link
Contributor Author

This looks great. However, this is a somewhat significant change. I think i'd feel better if we gate this behind the preview mode and release it as part of 0.10.

The only problematic part of that is adding a preview flag check will add quite the rat tail to the diff, since the function is used in so many places. One way to get around this would be to add the preview flag to the semantic model. Which I would've found useful in the past anyways. (Either as a SemanticModelFlag or its own thing)

@MichaReiser
Copy link
Member

Ah right, sorry. I forgot about our previous conversation while I was on PTO. Let me have a quick look

@MichaReiser
Copy link
Member

Adding a preview option to SemanticModel or to SemanticModelFlag sounds reasonable to me. The alternative is to add a flag specific for this feature to SemanticModelFlag. The advantage of a feature-specific flag is that it's easier to promote all changes because we can simply remove the flag and then fix all compile errors.

@MichaReiser MichaReiser added the preview Related to preview mode features label Feb 4, 2025
@Daverball
Copy link
Contributor Author

@MichaReiser Since we're now gating the change I also removed special casing for if False: and if 0: in preview mode.
I think support for this compatibility shim has been dropped 4+ years ago.

@MichaReiser
Copy link
Member

MichaReiser commented Feb 4, 2025

@MichaReiser Since we're now gating the change I also removed special casing for if False: and if 0: in preview mode. I think support for this compatibility shim has been dropped 4+ years ago.

Sounds reasonable. Would you mind updating your PR summary to reflect this change?

This tries to still emit a no-op edit whenever possible
Comment on lines +141 to +164
let type_checking_edit =
if let Some(statement) = Self::type_checking_binding_statement(semantic, block) {
if statement == import.statement {
// Special-case: if the `TYPE_CHECKING` symbol is imported as part of the same
// statement that we're modifying, avoid adding a no-op edit. For example, here,
// the `TYPE_CHECKING` no-op edit would overlap with the edit to remove `Final`
// from the import:
// ```python
// from __future__ import annotations
//
// from typing import Final, TYPE_CHECKING
//
// Const: Final[dict] = {}
// ```
None
} else {
Some(Edit::range_replacement(
self.locator.slice(statement.range()).to_string(),
statement.range(),
))
}
} else {
None
};
Copy link
Contributor Author

@Daverball Daverball Feb 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should retain the no-op edit in the most common case (the top-most TYPE_CHECKING block is defined in global scope). Although this brought up a question about the implementation of Importer:

Why are we adding both the if statement and the body AST node to type_checking_blocks in global scope but only the body AST node elsewhere? I don't really see an obvious reason. Shouldn't we just always pass the body or always the entire if statement? Or at least either one or the other, but never both. Either way it is pretty confusing and it would seem more consistent to pass in something like a struct that contains the condition, the body and the scope. That would also make it flexible enough to support a non-idiomatic if not TYPE_CHECKING with an else clause.

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great. Thank you. Also thanks for reviewing the ecosytem checks so carefully.

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

Successfully merging this pull request may close these issues.

TC003/TC002/TC001 False positive on manual TYPE_CHECKING with __future__.annotations
3 participants