From 3e1f63645109e69f9bccc7be354c5ee4672fb8f3 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:42:27 -0400 Subject: [PATCH 001/222] Bump 0.15.21 (#26676) --- CHANGELOG.md | 68 ++++++++++++++++ Cargo.lock | 74 +++++++++--------- Cargo.toml | 72 ++++++++--------- README.md | 6 +- crates/ruff/Cargo.toml | 2 +- crates/ruff/README.md | 2 +- crates/ruff_annotate_snippets/Cargo.toml | 2 +- crates/ruff_cache/Cargo.toml | 2 +- crates/ruff_cache/README.md | 4 +- crates/ruff_db/Cargo.toml | 2 +- crates/ruff_db/README.md | 4 +- crates/ruff_diagnostics/Cargo.toml | 2 +- crates/ruff_diagnostics/README.md | 4 +- crates/ruff_formatter/Cargo.toml | 2 +- crates/ruff_formatter/README.md | 4 +- crates/ruff_graph/Cargo.toml | 2 +- crates/ruff_graph/README.md | 4 +- crates/ruff_index/Cargo.toml | 2 +- crates/ruff_index/README.md | 4 +- crates/ruff_linter/Cargo.toml | 2 +- crates/ruff_linter/README.md | 4 +- .../rules/deprecated_abc_decorator.rs | 2 +- crates/ruff_macros/Cargo.toml | 2 +- crates/ruff_macros/README.md | 4 +- crates/ruff_markdown/Cargo.toml | 2 +- crates/ruff_markdown/README.md | 4 +- crates/ruff_memory_usage/Cargo.toml | 2 +- crates/ruff_memory_usage/README.md | 4 +- crates/ruff_notebook/Cargo.toml | 2 +- crates/ruff_notebook/README.md | 4 +- crates/ruff_options_metadata/Cargo.toml | 2 +- crates/ruff_options_metadata/README.md | 4 +- crates/ruff_python_ast/Cargo.toml | 2 +- crates/ruff_python_ast/README.md | 4 +- crates/ruff_python_codegen/Cargo.toml | 2 +- crates/ruff_python_codegen/README.md | 4 +- crates/ruff_python_formatter/Cargo.toml | 2 +- crates/ruff_python_formatter/README.md | 4 +- crates/ruff_python_importer/Cargo.toml | 2 +- crates/ruff_python_importer/README.md | 4 +- crates/ruff_python_index/Cargo.toml | 2 +- crates/ruff_python_index/README.md | 4 +- crates/ruff_python_literal/Cargo.toml | 2 +- crates/ruff_python_literal/README.md | 4 +- crates/ruff_python_parser/Cargo.toml | 2 +- crates/ruff_python_parser/README.md | 4 +- crates/ruff_python_semantic/Cargo.toml | 2 +- crates/ruff_python_semantic/README.md | 4 +- crates/ruff_python_stdlib/Cargo.toml | 2 +- crates/ruff_python_stdlib/README.md | 4 +- crates/ruff_python_trivia/Cargo.toml | 2 +- crates/ruff_python_trivia/README.md | 4 +- crates/ruff_ranged_value/Cargo.toml | 2 +- crates/ruff_ranged_value/README.md | 4 +- crates/ruff_server/Cargo.toml | 2 +- crates/ruff_server/README.md | 4 +- crates/ruff_source_file/Cargo.toml | 2 +- crates/ruff_source_file/README.md | 4 +- crates/ruff_text_size/Cargo.toml | 2 +- crates/ruff_text_size/README.md | 4 +- crates/ruff_wasm/Cargo.toml | 2 +- crates/ruff_wasm/README.md | 4 +- crates/ruff_workspace/Cargo.toml | 2 +- crates/ruff_workspace/README.md | 4 +- crates/ty_combine/Cargo.toml | 2 +- crates/ty_combine/README.md | 4 +- crates/ty_module_resolver/Cargo.toml | 2 +- crates/ty_module_resolver/README.md | 4 +- crates/ty_python_core/Cargo.toml | 2 +- crates/ty_python_core/README.md | 4 +- crates/ty_python_semantic/Cargo.toml | 2 +- crates/ty_python_semantic/README.md | 4 +- crates/ty_site_packages/Cargo.toml | 2 +- crates/ty_site_packages/README.md | 4 +- crates/ty_static/Cargo.toml | 2 +- crates/ty_static/README.md | 4 +- crates/ty_vendored/Cargo.toml | 2 +- docs/formatter.md | 2 +- docs/integrations.md | 8 +- docs/tutorial.md | 2 +- pyproject.toml | 2 +- scripts/benchmarks/pyproject.toml | 2 +- uv.lock | 78 +++++++++---------- 83 files changed, 298 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e8150171..d8eff74ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## 0.15.21 + +Released on 2026-07-09. + +### Preview features + +- Add `--add-ignore` for adding `ruff:ignore` comments ([#26346](https://github.com/astral-sh/ruff/pull/26346)) +- \[`flake8-comprehensions`\] Drop `C409` tuple comprehension preview behavior ([#25707](https://github.com/astral-sh/ruff/pull/25707)) +- Avoid whitespace normalization when formatting comments ([#26455](https://github.com/astral-sh/ruff/pull/26455)) +- \[`pyupgrade`\] Lint and fix use of deprecated `abc` decorators (`UP051`) ([#26417](https://github.com/astral-sh/ruff/pull/26417)) + +### Bug fixes + +- Refine non-empty f-string detection ([#26526](https://github.com/astral-sh/ruff/pull/26526)) +- Detect syntax errors in individual notebook cells ([#26419](https://github.com/astral-sh/ruff/pull/26419)) +- \[`flake8-implicit-str-concat`\] Fix `ISC003` autofix incorrectly stripping `+` from comments ([#26554](https://github.com/astral-sh/ruff/pull/26554)) + +### Rule changes + +- \[`flake8-executable`\] Mark `EXE004` fix as unsafe ([#26033](https://github.com/astral-sh/ruff/pull/26033)) +- \[`flake8-pyi`\] Mark `PYI061` fixes as unsafe in Python files ([#26533](https://github.com/astral-sh/ruff/pull/26533)) +- \[`pydocstyle`\] Skip `overload-with-docstring` in stub files (`D418`) ([#26318](https://github.com/astral-sh/ruff/pull/26318)) + +### Performance + +- Avoid per-token source index visitor calls ([#26506](https://github.com/astral-sh/ruff/pull/26506)) +- Cache parenthesized expression boundaries in the formatter ([#26344](https://github.com/astral-sh/ruff/pull/26344)) +- Improve performance of rendering edits in preview mode ([#26565](https://github.com/astral-sh/ruff/pull/26565)) +- Inline `fits_element` in formatter ([#26429](https://github.com/astral-sh/ruff/pull/26429)) +- Inline formatter printing hot paths ([#26504](https://github.com/astral-sh/ruff/pull/26504)) +- Lazily create builtin bindings ([#26510](https://github.com/astral-sh/ruff/pull/26510)) +- Skip empty trivia scans in the source indexer ([#26507](https://github.com/astral-sh/ruff/pull/26507)) +- Use ICF for macOS release builds ([#25780](https://github.com/astral-sh/ruff/pull/25780)) + +### Formatter + +- Add `--extend-exclude` to `ruff format` ([#26372](https://github.com/astral-sh/ruff/pull/26372)) + +### Documentation + +- Add "How does Ruff's import sorting compare to isort?" link to README ([#26530](https://github.com/astral-sh/ruff/pull/26530)) +- Fix Mozilla Firefox repository link in README ([#26537](https://github.com/astral-sh/ruff/pull/26537)) +- \[`flake8-bandit`\] Fix misleading docstring for `mako-templates` (`S702`) ([#26432](https://github.com/astral-sh/ruff/pull/26432)) +- \[`ruff`\] Fix non-triggering example for `if-key-in-dict-del` (`RUF051`) ([#26433](https://github.com/astral-sh/ruff/pull/26433)) + +### Contributors + +- [@EkriirkE](https://github.com/EkriirkE) +- [@tingerrr](https://github.com/tingerrr) +- [@s-rigaud](https://github.com/s-rigaud) +- [@nikolauspschuetz](https://github.com/nikolauspschuetz) +- [@Avasam](https://github.com/Avasam) +- [@ntBre](https://github.com/ntBre) +- [@omar-y-abdi](https://github.com/omar-y-abdi) +- [@AlexWaygood](https://github.com/AlexWaygood) +- [@sylvestre](https://github.com/sylvestre) +- [@shaanmajid](https://github.com/shaanmajid) +- [@lerebear](https://github.com/lerebear) +- [@baltasarblanco](https://github.com/baltasarblanco) +- [@Sanjays2402](https://github.com/Sanjays2402) +- [@ZedThree](https://github.com/ZedThree) +- [@servusdei2018](https://github.com/servusdei2018) +- [@charliermarsh](https://github.com/charliermarsh) +- [@jesco-absolute](https://github.com/jesco-absolut) +- [@velikodniy](https://github.com/velikodniy) +- [@zaniebot](https://github.com/zaniebot) +- [@epage](https://github.com/epage) + ## 0.15.20 Released on 2026-06-25. diff --git a/Cargo.lock b/Cargo.lock index e74b251cfd..407f6c57a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3067,7 +3067,7 @@ dependencies = [ [[package]] name = "ruff" -version = "0.15.20" +version = "0.15.21" dependencies = [ "anyhow", "argfile", @@ -3131,7 +3131,7 @@ dependencies = [ [[package]] name = "ruff_annotate_snippets" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anstream 1.0.0", "anstyle", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "ruff_cache" -version = "0.0.3" +version = "0.0.4" dependencies = [ "filetime", "glob", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "ruff_db" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anstyle", "arc-swap", @@ -3276,7 +3276,7 @@ dependencies = [ [[package]] name = "ruff_diagnostics" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", "is-macro", @@ -3286,7 +3286,7 @@ dependencies = [ [[package]] name = "ruff_formatter" -version = "0.0.3" +version = "0.0.4" dependencies = [ "drop_bomb", "ruff_cache", @@ -3302,7 +3302,7 @@ dependencies = [ [[package]] name = "ruff_graph" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "clap", @@ -3323,7 +3323,7 @@ dependencies = [ [[package]] name = "ruff_index" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", "ruff_macros", @@ -3333,7 +3333,7 @@ dependencies = [ [[package]] name = "ruff_linter" -version = "0.15.20" +version = "0.15.21" dependencies = [ "aho-corasick", "anyhow", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "ruff_macros" -version = "0.0.3" +version = "0.0.4" dependencies = [ "heck", "itertools 0.15.0", @@ -3409,7 +3409,7 @@ dependencies = [ [[package]] name = "ruff_markdown" -version = "0.0.3" +version = "0.0.4" dependencies = [ "insta", "regex", @@ -3440,14 +3440,14 @@ dependencies = [ [[package]] name = "ruff_memory_usage" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", ] [[package]] name = "ruff_notebook" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "itertools 0.15.0", @@ -3464,14 +3464,14 @@ dependencies = [ [[package]] name = "ruff_options_metadata" -version = "0.0.3" +version = "0.0.4" dependencies = [ "serde", ] [[package]] name = "ruff_python_ast" -version = "0.0.3" +version = "0.0.4" dependencies = [ "aho-corasick", "arrayvec", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "ruff_python_codegen" -version = "0.0.3" +version = "0.0.4" dependencies = [ "ruff_python_ast", "ruff_python_literal", @@ -3519,7 +3519,7 @@ dependencies = [ [[package]] name = "ruff_python_formatter" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "clap", @@ -3552,7 +3552,7 @@ dependencies = [ [[package]] name = "ruff_python_importer" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "insta", @@ -3567,7 +3567,7 @@ dependencies = [ [[package]] name = "ruff_python_index" -version = "0.0.3" +version = "0.0.4" dependencies = [ "ruff_python_ast", "ruff_python_parser", @@ -3578,7 +3578,7 @@ dependencies = [ [[package]] name = "ruff_python_literal" -version = "0.0.3" +version = "0.0.4" dependencies = [ "bitflags 2.13.0", "icu_properties", @@ -3588,7 +3588,7 @@ dependencies = [ [[package]] name = "ruff_python_parser" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "ruff_python_semantic" -version = "0.0.3" +version = "0.0.4" dependencies = [ "bitflags 2.13.0", "insta", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "ruff_python_stdlib" -version = "0.0.3" +version = "0.0.4" dependencies = [ "bitflags 2.13.0", "unicode-ident", @@ -3646,7 +3646,7 @@ dependencies = [ [[package]] name = "ruff_python_trivia" -version = "0.0.3" +version = "0.0.4" dependencies = [ "itertools 0.15.0", "ruff_source_file", @@ -3667,7 +3667,7 @@ dependencies = [ [[package]] name = "ruff_ranged_value" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", "ruff_db", @@ -3679,7 +3679,7 @@ dependencies = [ [[package]] name = "ruff_server" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "crossbeam", @@ -3722,7 +3722,7 @@ dependencies = [ [[package]] name = "ruff_source_file" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", "memchr", @@ -3732,7 +3732,7 @@ dependencies = [ [[package]] name = "ruff_text_size" -version = "0.0.3" +version = "0.0.4" dependencies = [ "get-size2", "schemars", @@ -3743,7 +3743,7 @@ dependencies = [ [[package]] name = "ruff_wasm" -version = "0.15.20" +version = "0.15.21" dependencies = [ "console_error_panic_hook", "console_log", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "ruff_workspace" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "colored", @@ -4656,7 +4656,7 @@ dependencies = [ [[package]] name = "ty_combine" -version = "0.0.3" +version = "0.0.4" dependencies = [ "ordermap", "ruff_db", @@ -4739,7 +4739,7 @@ dependencies = [ [[package]] name = "ty_module_resolver" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "camino", @@ -4812,7 +4812,7 @@ dependencies = [ [[package]] name = "ty_python_core" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -4845,7 +4845,7 @@ dependencies = [ [[package]] name = "ty_python_semantic" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -4937,7 +4937,7 @@ dependencies = [ [[package]] name = "ty_site_packages" -version = "0.0.3" +version = "0.0.4" dependencies = [ "camino", "colored", @@ -4958,7 +4958,7 @@ dependencies = [ [[package]] name = "ty_static" -version = "0.0.3" +version = "0.0.4" dependencies = [ "ruff_macros", ] @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "ty_vendored" -version = "0.0.3" +version = "0.0.4" dependencies = [ "path-slash", "ruff_db", diff --git a/Cargo.toml b/Cargo.toml index 8d805f6964..c6675c2eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,51 +13,51 @@ authors = ["Charlie Marsh "] license = "MIT" [workspace.dependencies] -ruff = { version = "0.15.20", path = "crates/ruff" } -ruff_annotate_snippets = { version = "0.0.3", path = "crates/ruff_annotate_snippets" } -ruff_cache = { version = "0.0.3", path = "crates/ruff_cache" } -ruff_db = { version = "0.0.3", path = "crates/ruff_db", default-features = false } -ruff_diagnostics = { version = "0.0.3", path = "crates/ruff_diagnostics" } -ruff_formatter = { version = "0.0.3", path = "crates/ruff_formatter" } -ruff_graph = { version = "0.0.3", path = "crates/ruff_graph" } -ruff_index = { version = "0.0.3", path = "crates/ruff_index" } -ruff_linter = { version = "0.15.20", path = "crates/ruff_linter" } -ruff_macros = { version = "0.0.3", path = "crates/ruff_macros" } -ruff_markdown = { version = "0.0.3", path = "crates/ruff_markdown" } -ruff_memory_usage = { version = "0.0.3", path = "crates/ruff_memory_usage" } -ruff_notebook = { version = "0.0.3", path = "crates/ruff_notebook" } -ruff_options_metadata = { version = "0.0.3", path = "crates/ruff_options_metadata" } -ruff_python_ast = { version = "0.0.3", path = "crates/ruff_python_ast" } -ruff_python_codegen = { version = "0.0.3", path = "crates/ruff_python_codegen" } -ruff_python_formatter = { version = "0.0.3", path = "crates/ruff_python_formatter" } -ruff_python_importer = { version = "0.0.3", path = "crates/ruff_python_importer" } -ruff_python_index = { version = "0.0.3", path = "crates/ruff_python_index" } -ruff_python_literal = { version = "0.0.3", path = "crates/ruff_python_literal" } -ruff_python_parser = { version = "0.0.3", path = "crates/ruff_python_parser" } -ruff_python_semantic = { version = "0.0.3", path = "crates/ruff_python_semantic" } -ruff_python_stdlib = { version = "0.0.3", path = "crates/ruff_python_stdlib" } -ruff_python_trivia = { version = "0.0.3", path = "crates/ruff_python_trivia" } -ruff_server = { version = "0.0.3", path = "crates/ruff_server" } -ruff_source_file = { version = "0.0.3", path = "crates/ruff_source_file" } +ruff = { version = "0.15.21", path = "crates/ruff" } +ruff_annotate_snippets = { version = "0.0.4", path = "crates/ruff_annotate_snippets" } +ruff_cache = { version = "0.0.4", path = "crates/ruff_cache" } +ruff_db = { version = "0.0.4", path = "crates/ruff_db", default-features = false } +ruff_diagnostics = { version = "0.0.4", path = "crates/ruff_diagnostics" } +ruff_formatter = { version = "0.0.4", path = "crates/ruff_formatter" } +ruff_graph = { version = "0.0.4", path = "crates/ruff_graph" } +ruff_index = { version = "0.0.4", path = "crates/ruff_index" } +ruff_linter = { version = "0.15.21", path = "crates/ruff_linter" } +ruff_macros = { version = "0.0.4", path = "crates/ruff_macros" } +ruff_markdown = { version = "0.0.4", path = "crates/ruff_markdown" } +ruff_memory_usage = { version = "0.0.4", path = "crates/ruff_memory_usage" } +ruff_notebook = { version = "0.0.4", path = "crates/ruff_notebook" } +ruff_options_metadata = { version = "0.0.4", path = "crates/ruff_options_metadata" } +ruff_python_ast = { version = "0.0.4", path = "crates/ruff_python_ast" } +ruff_python_codegen = { version = "0.0.4", path = "crates/ruff_python_codegen" } +ruff_python_formatter = { version = "0.0.4", path = "crates/ruff_python_formatter" } +ruff_python_importer = { version = "0.0.4", path = "crates/ruff_python_importer" } +ruff_python_index = { version = "0.0.4", path = "crates/ruff_python_index" } +ruff_python_literal = { version = "0.0.4", path = "crates/ruff_python_literal" } +ruff_python_parser = { version = "0.0.4", path = "crates/ruff_python_parser" } +ruff_python_semantic = { version = "0.0.4", path = "crates/ruff_python_semantic" } +ruff_python_stdlib = { version = "0.0.4", path = "crates/ruff_python_stdlib" } +ruff_python_trivia = { version = "0.0.4", path = "crates/ruff_python_trivia" } +ruff_server = { version = "0.0.4", path = "crates/ruff_server" } +ruff_source_file = { version = "0.0.4", path = "crates/ruff_source_file" } ruff_mdtest = { path = "crates/ruff_mdtest" } -ruff_ranged_value = { version = "0.0.3", path = "crates/ruff_ranged_value" } -ruff_text_size = { version = "0.0.3", path = "crates/ruff_text_size" } -ruff_workspace = { version = "0.0.3", path = "crates/ruff_workspace" } +ruff_ranged_value = { version = "0.0.4", path = "crates/ruff_ranged_value" } +ruff_text_size = { version = "0.0.4", path = "crates/ruff_text_size" } +ruff_workspace = { version = "0.0.4", path = "crates/ruff_workspace" } ty = { path = "crates/ty" } -ty_combine = { version = "0.0.3", path = "crates/ty_combine" } +ty_combine = { version = "0.0.4", path = "crates/ty_combine" } ty_completion_bench = { path = "crates/ty_completion_bench" } ty_completion_eval = { path = "crates/ty_completion_eval" } ty_ide = { path = "crates/ty_ide" } -ty_module_resolver = { version = "0.0.3", path = "crates/ty_module_resolver" } +ty_module_resolver = { version = "0.0.4", path = "crates/ty_module_resolver" } ty_project = { path = "crates/ty_project", default-features = false } -ty_python_semantic = { version = "0.0.3", path = "crates/ty_python_semantic" } -ty_python_core = { version = "0.0.3", path = "crates/ty_python_core" } +ty_python_semantic = { version = "0.0.4", path = "crates/ty_python_semantic" } +ty_python_core = { version = "0.0.4", path = "crates/ty_python_core" } ty_server = { path = "crates/ty_server" } -ty_site_packages = { version = "0.0.3", path = "crates/ty_site_packages" } -ty_static = { version = "0.0.3", path = "crates/ty_static" } +ty_site_packages = { version = "0.0.4", path = "crates/ty_site_packages" } +ty_static = { version = "0.0.4", path = "crates/ty_static" } ty_test = { path = "crates/ty_test" } -ty_vendored = { version = "0.0.3", path = "crates/ty_vendored" } +ty_vendored = { version = "0.0.4", path = "crates/ty_vendored" } mdtest = { path = "crates/mdtest" } diff --git a/README.md b/README.md index d6d51bedb9..1302a04aea 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh powershell -c "irm https://astral.sh/ruff/install.ps1 | iex" # For a specific version. -curl -LsSf https://astral.sh/ruff/0.15.20/install.sh | sh -powershell -c "irm https://astral.sh/ruff/0.15.20/install.ps1 | iex" +curl -LsSf https://astral.sh/ruff/0.15.21/install.sh | sh +powershell -c "irm https://astral.sh/ruff/0.15.21/install.ps1 | iex" ``` You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff), @@ -186,7 +186,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.20 + rev: v0.15.21 hooks: # Run the linter. - id: ruff-check diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index 66ce7f0109..9a7ed66590 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff" -version = "0.15.20" +version = "0.15.21" description = "An extremely fast Python linter and code formatter" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff/README.md b/crates/ruff/README.md index 51d723d72f..e6e92a36f7 100644 --- a/crates/ruff/README.md +++ b/crates/ruff/README.md @@ -10,7 +10,7 @@ See the [documentation](https://docs.astral.sh/ruff/) or This crate is the entry point to the Ruff command-line interface. The Rust API exposed here is not considered public interface. -This is version 0.15.20. The source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff). +This is version 0.15.21. The source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff). The following Ruff workspace members are also available: diff --git a/crates/ruff_annotate_snippets/Cargo.toml b/crates/ruff_annotate_snippets/Cargo.toml index c92ae41a1b..d039a84724 100644 --- a/crates/ruff_annotate_snippets/Cargo.toml +++ b/crates/ruff_annotate_snippets/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_annotate_snippets" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_cache/Cargo.toml b/crates/ruff_cache/Cargo.toml index 42d2299e0f..cad5e6a3c9 100644 --- a/crates/ruff_cache/Cargo.toml +++ b/crates/ruff_cache/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_cache" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_cache/README.md b/crates/ruff_cache/README.md index 582b8013ba..db062d35fe 100644 --- a/crates/ruff_cache/README.md +++ b/crates/ruff_cache/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_cache). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_cache). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_db/Cargo.toml b/crates/ruff_db/Cargo.toml index bd08540a43..35cc319e1c 100644 --- a/crates/ruff_db/Cargo.toml +++ b/crates/ruff_db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_db" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_db/README.md b/crates/ruff_db/README.md index e901a90ee5..d22b4fe108 100644 --- a/crates/ruff_db/README.md +++ b/crates/ruff_db/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_db). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_db). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_diagnostics/Cargo.toml b/crates/ruff_diagnostics/Cargo.toml index cc716d94eb..e887f1a94d 100644 --- a/crates/ruff_diagnostics/Cargo.toml +++ b/crates/ruff_diagnostics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_diagnostics" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_diagnostics/README.md b/crates/ruff_diagnostics/README.md index 8b27f8294e..481a3ecff2 100644 --- a/crates/ruff_diagnostics/README.md +++ b/crates/ruff_diagnostics/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_diagnostics). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_diagnostics). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_formatter/Cargo.toml b/crates/ruff_formatter/Cargo.toml index 6dc3c46140..0292a467ba 100644 --- a/crates/ruff_formatter/Cargo.toml +++ b/crates/ruff_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_formatter" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_formatter/README.md b/crates/ruff_formatter/README.md index 7dd4045a38..4ba4ae9fb3 100644 --- a/crates/ruff_formatter/README.md +++ b/crates/ruff_formatter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_formatter). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_graph/Cargo.toml b/crates/ruff_graph/Cargo.toml index 478008aaa9..7ad3961578 100644 --- a/crates/ruff_graph/Cargo.toml +++ b/crates/ruff_graph/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_graph" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ruff_graph/README.md b/crates/ruff_graph/README.md index 9646468294..a529146b33 100644 --- a/crates/ruff_graph/README.md +++ b/crates/ruff_graph/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_graph). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_graph). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_index/Cargo.toml b/crates/ruff_index/Cargo.toml index f9395adadf..639ba89c2d 100644 --- a/crates/ruff_index/Cargo.toml +++ b/crates/ruff_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_index" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_index/README.md b/crates/ruff_index/README.md index 61b53e2524..ca40eb9e69 100644 --- a/crates/ruff_index/README.md +++ b/crates/ruff_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_index). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index d62d2f11b9..ff47dc150d 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.15.20" +version = "0.15.21" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_linter/README.md b/crates/ruff_linter/README.md index 3c3bbb91f1..f71ed24dda 100644 --- a/crates/ruff_linter/README.md +++ b/crates/ruff_linter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.15.20) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_linter). +This version (0.15.21) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_linter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs index 5cf42362a9..a9ad1786f6 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_abc_decorator.rs @@ -52,7 +52,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// def prop(self): ... /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +#[violation_metadata(preview_since = "0.15.21")] pub(crate) struct DeprecatedAbcDecorator { from: &'static str, to: &'static str, diff --git a/crates/ruff_macros/Cargo.toml b/crates/ruff_macros/Cargo.toml index 94305e6c55..3a609b4a26 100644 --- a/crates/ruff_macros/Cargo.toml +++ b/crates/ruff_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_macros" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_macros/README.md b/crates/ruff_macros/README.md index aad888cbe8..af4e570a9e 100644 --- a/crates/ruff_macros/README.md +++ b/crates/ruff_macros/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_macros). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_macros). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_markdown/Cargo.toml b/crates/ruff_markdown/Cargo.toml index ef8731bee7..f6587b2368 100644 --- a/crates/ruff_markdown/Cargo.toml +++ b/crates/ruff_markdown/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_markdown" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ruff_markdown/README.md b/crates/ruff_markdown/README.md index 752b559894..343703f403 100644 --- a/crates/ruff_markdown/README.md +++ b/crates/ruff_markdown/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_markdown). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_markdown). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_memory_usage/Cargo.toml b/crates/ruff_memory_usage/Cargo.toml index 2116532457..442cdaeb73 100644 --- a/crates/ruff_memory_usage/Cargo.toml +++ b/crates/ruff_memory_usage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_memory_usage" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_memory_usage/README.md b/crates/ruff_memory_usage/README.md index 64a49b411d..f3e37905d6 100644 --- a/crates/ruff_memory_usage/README.md +++ b/crates/ruff_memory_usage/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_memory_usage). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_memory_usage). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_notebook/Cargo.toml b/crates/ruff_notebook/Cargo.toml index da17f6cb12..f211741de7 100644 --- a/crates/ruff_notebook/Cargo.toml +++ b/crates/ruff_notebook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_notebook" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_notebook/README.md b/crates/ruff_notebook/README.md index 1721084d3e..6d4cd2d05c 100644 --- a/crates/ruff_notebook/README.md +++ b/crates/ruff_notebook/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_notebook). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_notebook). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_options_metadata/Cargo.toml b/crates/ruff_options_metadata/Cargo.toml index 7d2e973c01..d35695f9ac 100644 --- a/crates/ruff_options_metadata/Cargo.toml +++ b/crates/ruff_options_metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_options_metadata" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_options_metadata/README.md b/crates/ruff_options_metadata/README.md index 25383ceb42..f5b989fd51 100644 --- a/crates/ruff_options_metadata/README.md +++ b/crates/ruff_options_metadata/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_options_metadata). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_options_metadata). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index c64a942561..338af11d14 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_ast" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_ast/README.md b/crates/ruff_python_ast/README.md index 7cf1b68d16..738181eb10 100644 --- a/crates/ruff_python_ast/README.md +++ b/crates/ruff_python_ast/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_ast). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_ast). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_codegen/Cargo.toml b/crates/ruff_python_codegen/Cargo.toml index be037f47a0..739a1ed45b 100644 --- a/crates/ruff_python_codegen/Cargo.toml +++ b/crates/ruff_python_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_codegen" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_codegen/README.md b/crates/ruff_python_codegen/README.md index 257d31e608..4be1cdc562 100644 --- a/crates/ruff_python_codegen/README.md +++ b/crates/ruff_python_codegen/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_codegen). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_codegen). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index b5d48a6aff..a215af3cbe 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_formatter" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_formatter/README.md b/crates/ruff_python_formatter/README.md index 24ceef9085..ea88a40106 100644 --- a/crates/ruff_python_formatter/README.md +++ b/crates/ruff_python_formatter/README.md @@ -32,8 +32,8 @@ Head to [The Ruff Formatter](https://docs.astral.sh/ruff/formatter/) for usage i This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_formatter). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_importer/Cargo.toml b/crates/ruff_python_importer/Cargo.toml index 365c950536..d1d675d0fd 100644 --- a/crates/ruff_python_importer/Cargo.toml +++ b/crates/ruff_python_importer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_importer" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_importer/README.md b/crates/ruff_python_importer/README.md index 4e2a6a5df8..eba92d21dc 100644 --- a/crates/ruff_python_importer/README.md +++ b/crates/ruff_python_importer/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_importer). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_importer). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_index/Cargo.toml b/crates/ruff_python_index/Cargo.toml index 12f32a1643..f47cbe0a58 100644 --- a/crates/ruff_python_index/Cargo.toml +++ b/crates/ruff_python_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_index" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_index/README.md b/crates/ruff_python_index/README.md index 837ade593e..1ea8ebedde 100644 --- a/crates/ruff_python_index/README.md +++ b/crates/ruff_python_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_index). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_literal/Cargo.toml b/crates/ruff_python_literal/Cargo.toml index e19602e4dd..47096437d6 100644 --- a/crates/ruff_python_literal/Cargo.toml +++ b/crates/ruff_python_literal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_literal" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } diff --git a/crates/ruff_python_literal/README.md b/crates/ruff_python_literal/README.md index c8afd008f7..1d271bf256 100644 --- a/crates/ruff_python_literal/README.md +++ b/crates/ruff_python_literal/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_literal). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_literal). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_parser/Cargo.toml b/crates/ruff_python_parser/Cargo.toml index 8de52b7625..480589a5a6 100644 --- a/crates/ruff_python_parser/Cargo.toml +++ b/crates/ruff_python_parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_parser" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } diff --git a/crates/ruff_python_parser/README.md b/crates/ruff_python_parser/README.md index 115815a968..2b24f98eba 100644 --- a/crates/ruff_python_parser/README.md +++ b/crates/ruff_python_parser/README.md @@ -19,8 +19,8 @@ Refer to the [contributing guidelines](./CONTRIBUTING.md) to get started and Git This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_parser). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_parser). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_semantic/Cargo.toml b/crates/ruff_python_semantic/Cargo.toml index e7bea78d47..c5fd2f388e 100644 --- a/crates/ruff_python_semantic/Cargo.toml +++ b/crates/ruff_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_semantic" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_semantic/README.md b/crates/ruff_python_semantic/README.md index deba3ddcb4..10abff75e1 100644 --- a/crates/ruff_python_semantic/README.md +++ b/crates/ruff_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_semantic). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_stdlib/Cargo.toml b/crates/ruff_python_stdlib/Cargo.toml index f0962516a2..7714197cc5 100644 --- a/crates/ruff_python_stdlib/Cargo.toml +++ b/crates/ruff_python_stdlib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_stdlib" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_stdlib/README.md b/crates/ruff_python_stdlib/README.md index 3af6d34366..5903367dfe 100644 --- a/crates/ruff_python_stdlib/README.md +++ b/crates/ruff_python_stdlib/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_stdlib). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_stdlib). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_trivia/Cargo.toml b/crates/ruff_python_trivia/Cargo.toml index 73ff8f20b6..1849b6de16 100644 --- a/crates/ruff_python_trivia/Cargo.toml +++ b/crates/ruff_python_trivia/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_trivia" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_trivia/README.md b/crates/ruff_python_trivia/README.md index a2e29f72ca..b9bcb1c7e1 100644 --- a/crates/ruff_python_trivia/README.md +++ b/crates/ruff_python_trivia/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_python_trivia). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_trivia). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_ranged_value/Cargo.toml b/crates/ruff_ranged_value/Cargo.toml index 6fc498c503..d43902559d 100644 --- a/crates/ruff_ranged_value/Cargo.toml +++ b/crates/ruff_ranged_value/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_ranged_value" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_ranged_value/README.md b/crates/ruff_ranged_value/README.md index dd5fef539f..023af142c3 100644 --- a/crates/ruff_ranged_value/README.md +++ b/crates/ruff_ranged_value/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_ranged_value). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_ranged_value). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index 932e088b1d..abffd61924 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_server" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_server/README.md b/crates/ruff_server/README.md index 2eaf34042d..e9bec02362 100644 --- a/crates/ruff_server/README.md +++ b/crates/ruff_server/README.md @@ -24,8 +24,8 @@ You can also join us on [**Discord**](https://discord.com/invite/astral-sh). This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_server). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_server). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_source_file/Cargo.toml b/crates/ruff_source_file/Cargo.toml index e8167e479d..3c1ae15c81 100644 --- a/crates/ruff_source_file/Cargo.toml +++ b/crates/ruff_source_file/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_source_file" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_source_file/README.md b/crates/ruff_source_file/README.md index 2edc9a4b4b..1e7a464240 100644 --- a/crates/ruff_source_file/README.md +++ b/crates/ruff_source_file/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_source_file). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_source_file). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_text_size/Cargo.toml b/crates/ruff_text_size/Cargo.toml index 1da6e86d52..5e69e00997 100644 --- a/crates/ruff_text_size/Cargo.toml +++ b/crates/ruff_text_size/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_text_size" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_text_size/README.md b/crates/ruff_text_size/README.md index d12c578060..cbb2505bcc 100644 --- a/crates/ruff_text_size/README.md +++ b/crates/ruff_text_size/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_text_size). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_text_size). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index 3cc0288120..5986913722 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.15.20" +version = "0.15.21" description = "WebAssembly bindings for Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_wasm/README.md b/crates/ruff_wasm/README.md index bc5edd6e5f..7881507b87 100644 --- a/crates/ruff_wasm/README.md +++ b/crates/ruff_wasm/README.md @@ -55,8 +55,8 @@ const formatted = workspace.format(exampleDocument); This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.15.20) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_wasm). +This version (0.15.21) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_wasm). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index e384a0ab3b..b35ce34304 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_workspace" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_workspace/README.md b/crates/ruff_workspace/README.md index 21f59a4b52..40a1db01bd 100644 --- a/crates/ruff_workspace/README.md +++ b/crates/ruff_workspace/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ruff_workspace). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_workspace). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_combine/Cargo.toml b/crates/ty_combine/Cargo.toml index e70fb2e9a8..b27ab8d12e 100644 --- a/crates/ty_combine/Cargo.toml +++ b/crates/ty_combine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_combine" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ty_combine/README.md b/crates/ty_combine/README.md index 8f86d231d1..e929516dc9 100644 --- a/crates/ty_combine/README.md +++ b/crates/ty_combine/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_combine). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_combine). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_module_resolver/Cargo.toml b/crates/ty_module_resolver/Cargo.toml index 0d4e9aa5a4..3ae75426f0 100644 --- a/crates/ty_module_resolver/Cargo.toml +++ b/crates/ty_module_resolver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_module_resolver" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_module_resolver/README.md b/crates/ty_module_resolver/README.md index f82cda8a04..6d0a6be112 100644 --- a/crates/ty_module_resolver/README.md +++ b/crates/ty_module_resolver/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_module_resolver). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_module_resolver). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_core/Cargo.toml b/crates/ty_python_core/Cargo.toml index 3dfbfc76f6..08bef060c2 100644 --- a/crates/ty_python_core/Cargo.toml +++ b/crates/ty_python_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_core" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_python_core/README.md b/crates/ty_python_core/README.md index a189fc573a..9510919965 100644 --- a/crates/ty_python_core/README.md +++ b/crates/ty_python_core/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_python_core). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_python_core). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index ffc6045e3c..d70a876753 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_semantic" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_python_semantic/README.md b/crates/ty_python_semantic/README.md index 846be96c0a..cf3010067a 100644 --- a/crates/ty_python_semantic/README.md +++ b/crates/ty_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_python_semantic). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_site_packages/Cargo.toml b/crates/ty_site_packages/Cargo.toml index 7acb0110be..76560f7afa 100644 --- a/crates/ty_site_packages/Cargo.toml +++ b/crates/ty_site_packages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_site_packages" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_site_packages/README.md b/crates/ty_site_packages/README.md index 73dd87cc8d..3952b325da 100644 --- a/crates/ty_site_packages/README.md +++ b/crates/ty_site_packages/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_site_packages). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_site_packages). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_static/Cargo.toml b/crates/ty_static/Cargo.toml index 4ed95f879e..9de7f22d66 100644 --- a/crates/ty_static/Cargo.toml +++ b/crates/ty_static/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_static" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ty_static/README.md b/crates/ty_static/README.md index 6341fb6ab0..55834c7bd6 100644 --- a/crates/ty_static/README.md +++ b/crates/ty_static/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.3) is a component of [Ruff 0.15.20](https://crates.io/crates/ruff/0.15.20). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.20/crates/ty_static). +This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_static). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_vendored/Cargo.toml b/crates/ty_vendored/Cargo.toml index 0f57374c16..dc0b928a66 100644 --- a/crates/ty_vendored/Cargo.toml +++ b/crates/ty_vendored/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_vendored" -version = "0.0.3" +version = "0.0.4" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/docs/formatter.md b/docs/formatter.md index 3a9c34ae92..87e7296bc0 100644 --- a/docs/formatter.md +++ b/docs/formatter.md @@ -306,7 +306,7 @@ support needs to be explicitly included by adding it to `types_or`: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.15.21 hooks: - id: ruff-format types_or: [python, pyi, jupyter, markdown] diff --git a/docs/integrations.md b/docs/integrations.md index f1b1c20b07..b2ddc6ad07 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -80,7 +80,7 @@ You can add the following configuration to `.gitlab-ci.yml` to run a `ruff forma stage: build interruptible: true image: - name: ghcr.io/astral-sh/ruff:0.15.20-alpine + name: ghcr.io/astral-sh/ruff:0.15.21-alpine before_script: - cd $CI_PROJECT_DIR - ruff --version @@ -106,7 +106,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.20 + rev: v0.15.21 hooks: # Run the linter. - id: ruff-check @@ -119,7 +119,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.20 + rev: v0.15.21 hooks: # Run the linter. - id: ruff-check @@ -133,7 +133,7 @@ To avoid running on Jupyter Notebooks, remove `jupyter` from the list of allowed ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.20 + rev: v0.15.21 hooks: # Run the linter. - id: ruff-check diff --git a/docs/tutorial.md b/docs/tutorial.md index dd96fc9a87..ce731e35d3 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -372,7 +372,7 @@ This tutorial has focused on Ruff's command-line interface, but Ruff can also be ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.20 + rev: v0.15.21 hooks: # Run the linter. - id: ruff-check diff --git a/pyproject.toml b/pyproject.toml index cb8349f6fc..9ad90e2532 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ruff" -version = "0.15.20" +version = "0.15.21" description = "An extremely fast Python linter and code formatter, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] readme = "README.md" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index 0cc1511ac8..0ae5a78981 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scripts" -version = "0.15.20" +version = "0.15.21" description = "" authors = ["Charles Marsh "] diff --git a/uv.lock b/uv.lock index 17c38b44c5..fe94650f2f 100644 --- a/uv.lock +++ b/uv.lock @@ -30,8 +30,8 @@ name = "anyio" version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -43,7 +43,7 @@ name = "anysqlite" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } wheels = [ @@ -64,7 +64,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -176,11 +176,11 @@ name = "hishel" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "anysqlite" }, - { name = "httpx" }, - { name = "msgpack" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "anysqlite", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "msgpack", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468, upload-time = "2025-10-18T13:32:41.854Z" } wheels = [ @@ -192,8 +192,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -205,10 +205,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "httpcore", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -229,7 +229,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -374,10 +374,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version >= '3.12'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -389,7 +389,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -519,7 +519,7 @@ name = "pygit2" version = "1.19.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/44/415aa93422b4bfc21a6448acb7e16280d5f33a9a3fae38a384e37b046ae4/pygit2-1.19.3.tar.gz", hash = "sha256:a543e6d4ebb43825564935758dc234e770016fed673b84370d46ae9580558831", size = 810489, upload-time = "2026-06-13T08:06:04.982Z" } wheels = [ @@ -594,8 +594,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -607,14 +607,14 @@ name = "rooster" version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hishel" }, - { name = "httpx" }, - { name = "marko" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pygit2" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "hishel", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "marko", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "pygit2", marker = "python_full_version >= '3.12'" }, + { name = "tqdm", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/02/8ce565271dc52bd0d0d812043b12ec60111d947f81dc30301d19d7bfd453/rooster-0.1.1.tar.gz", hash = "sha256:c9823122f0c2b035985e70384323cdd353477af988e0f065bc302646a49da482", size = 18608, upload-time = "2025-10-29T15:18:49.478Z" } wheels = [ @@ -623,7 +623,7 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" +version = "0.15.21" source = { editable = "." } [package.dev-dependencies] @@ -654,7 +654,7 @@ name = "tqdm" version = "4.68.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } wheels = [ @@ -666,10 +666,10 @@ name = "typer" version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "shellingham", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ @@ -690,7 +690,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From eca693732fd8485820d3547ed72d6c4c3fe92af1 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Thu, 9 Jul 2026 15:51:47 -0400 Subject: [PATCH 002/222] [ty] Reorganize bidirectional inference tests (#26675) These tests are currently split across `assignment/annotations.md` and `bidirectional.md` somewhat arbitrarily. I merged the two and reorganized some of the larger snippets which had devolved into catch-all sections. --- .../mdtest/assignment/annotations.md | 927 ------------ .../resources/mdtest/bidirectional.md | 1294 +++++++++++++---- .../resources/mdtest/typed_dict.md | 114 ++ 3 files changed, 1159 insertions(+), 1176 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md index f57a66ea45..535b6844e3 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/annotations.md @@ -101,146 +101,6 @@ b: tuple[int] = ("foo",) c: tuple[str | int, str] = ([], "foo") ``` -## Collection literal annotations are understood - -```toml -[environment] -python-version = "3.12" -``` - -```py -import typing - -a: list[int] = [1, 2, 3] -reveal_type(a) # revealed: list[int] - -b: list[int | str] = [1, 2, 3] -reveal_type(b) # revealed: list[int | str] - -c: typing.List[int] = [1, 2, 3] -reveal_type(c) # revealed: list[int] - -d: list[typing.Any] = [] -reveal_type(d) # revealed: list[Any] - -e: set[int] = {1, 2, 3} -reveal_type(e) # revealed: set[int] - -f: set[int | str] = {1, 2, 3} -reveal_type(f) # revealed: set[int | str] - -g: typing.Set[int] = {1, 2, 3} -reveal_type(g) # revealed: set[int] - -h: list[list[int]] = [[], [42]] -reveal_type(h) # revealed: list[list[int]] - -i: list[typing.Any] = [1, 2, "3", ([4],)] -reveal_type(i) # revealed: list[Any] - -j: list[tuple[str | int, ...]] = [(1, 2), ("foo", "bar"), ()] -reveal_type(j) # revealed: list[tuple[str | int, ...]] - -k: list[tuple[list[int], ...]] = [([],), ([1, 2], [3, 4]), ([5], [6], [7])] -reveal_type(k) # revealed: list[tuple[list[int], ...]] - -l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) -reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] - -type IntList = list[int] - -m: IntList = [1, 2, 3] -reveal_type(m) # revealed: list[int] - -n: list[typing.Literal[1, 2, 3]] = [1, 2, 3] -reveal_type(n) # revealed: list[Literal[1, 2, 3]] - -o: list[typing.LiteralString] = ["a", "b", "c"] -reveal_type(o) # revealed: list[LiteralString] - -p: dict[int, int] = {} -reveal_type(p) # revealed: dict[int, int] - -q: dict[int | str, int] = {1: 1, 2: 2, 3: 3} -reveal_type(q) # revealed: dict[int | str, int] - -r: dict[int | str, int | str] = {1: 1, 2: 2, 3: 3} -reveal_type(r) # revealed: dict[int | str, int | str] - -s: dict[int | str, int | str] -s = {1: 1, 2: 2, 3: 3} -reveal_type(s) # revealed: dict[int | str, int | str] -(s := {1: 1, 2: 2, 3: 3}) -reveal_type(s) # revealed: dict[int | str, int | str] -``` - -## Optional collection literal annotations are understood - -```toml -[environment] -python-version = "3.12" -``` - -```py -import typing - -a: list[int] | None = [1, 2, 3] -reveal_type(a) # revealed: list[int] - -b: list[int | str] | None = [1, 2, 3] -reveal_type(b) # revealed: list[int | str] - -c: typing.List[int] | None = [1, 2, 3] -reveal_type(c) # revealed: list[int] - -d: list[typing.Any] | None = [] -reveal_type(d) # revealed: list[Any] - -e: set[int] | None = {1, 2, 3} -reveal_type(e) # revealed: set[int] - -f: set[int | str] | None = {1, 2, 3} -reveal_type(f) # revealed: set[int | str] - -g: typing.Set[int] | None = {1, 2, 3} -reveal_type(g) # revealed: set[int] - -h: list[list[int]] | None = [[], [42]] -reveal_type(h) # revealed: list[list[int]] - -i: list[typing.Any] | None = [1, 2, "3", ([4],)] -reveal_type(i) # revealed: list[Any] - -j: list[tuple[str | int, ...]] | None = [(1, 2), ("foo", "bar"), ()] -reveal_type(j) # revealed: list[tuple[str | int, ...]] - -k: list[tuple[list[int], ...]] | None = [([],), ([1, 2], [3, 4]), ([5], [6], [7])] -reveal_type(k) # revealed: list[tuple[list[int], ...]] - -l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] | None = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) -reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] - -type IntList = list[int] - -m: IntList | None = [1, 2, 3] -reveal_type(m) # revealed: list[int] - -n: list[typing.Literal[1, 2, 3]] | None = [1, 2, 3] -reveal_type(n) # revealed: list[Literal[1, 2, 3]] - -o: list[typing.LiteralString] | None = ["a", "b", "c"] -reveal_type(o) # revealed: list[LiteralString] - -p: dict[int, int] | None = {} -reveal_type(p) # revealed: dict[int, int] - -q: dict[int | str, int] | None = {1: 1, 2: 2, 3: 3} -reveal_type(q) # revealed: dict[int | str, int] - -r: dict[int | str, int | str] | None = {1: 1, 2: 2, 3: 3} -reveal_type(r) # revealed: dict[int | str, int | str] -``` - ## Incorrect collection literal assignments are complained about ```py @@ -291,69 +151,6 @@ reveal_type(v4) # revealed: list[int] invalid: list[str] = returns_list_int() ``` -## Generic constructor annotations are understood - -```toml -[environment] -python-version = "3.12" -``` - -```py -from typing import Any - -class X[T]: - def __init__(self, value: T): - self.value = value - -x1: X[int] = X(1) -reveal_type(x1) # revealed: X[int] - -x2: X[int | None] = X(1) -reveal_type(x2) # revealed: X[int | None] - -x3: X[int | None] | None = X(1) -reveal_type(x3) # revealed: X[int | None] - -def _[T](x1: X[T]): - x2: X[T | int] = X(x1.value) - reveal_type(x2) # revealed: X[T@_ | int] - -x4: X[Any] = X(1) -reveal_type(x4) # revealed: X[Any] - -def _(flag: bool): - x5: X[int | None] = X(1) if flag else X(2) - reveal_type(x5) # revealed: X[int | None] -``` - -```py -from dataclasses import dataclass - -@dataclass -class Y[T]: - value: T - -y1 = Y(value=1) -reveal_type(y1) # revealed: Y[int] - -y2: Y[Any] = Y(value=1) -reveal_type(y2) # revealed: Y[Any] -``` - -```py -class Z[T]: - value: T - - def __new__(cls, value: T): - return super().__new__(cls) - -z1 = Z(1) -reveal_type(z1) # revealed: Z[int] - -z2: Z[Any] = Z(1) -reveal_type(z2) # revealed: Z[Any] -``` - ## PEP-604 annotations are supported ```py @@ -427,730 +224,6 @@ x: int = 1 reveal_type(x) # revealed: Literal[1] ``` -## Annotations influence generic call inference - -```toml -[environment] -python-version = "3.12" -``` - -```py -from typing import Literal, Mapping, Sequence - -def f[T](x: T) -> list[T]: - return [x] - -x1 = f("a") -reveal_type(x1) # revealed: list[str] - -x2: list[int | Literal["a"]] = f("a") -reveal_type(x2) # revealed: list[int | Literal["a"]] - -x3: list[int | str] = f("a") -reveal_type(x3) # revealed: list[int | str] - -x4: list[int | tuple[int, int]] = f((1, 2)) -reveal_type(x4) # revealed: list[int | tuple[int, int]] - -x5: list[int] = f(True) -reveal_type(x5) # revealed: list[int] - -# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `list[int]`" -x6: list[int] = f("a") - -# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `tuple[int]`" -x7: tuple[int] = f("a") - -def f2[T: int](x: T) -> T: - return x - -x8: int = f2(True) -reveal_type(x8) # revealed: Literal[True] - -x9: int | str = f2(True) -reveal_type(x9) # revealed: Literal[True] - -x10: list[int | str] | list[int | None] = [1, 2, 3] -reveal_type(x10) # revealed: list[int | str] - -x11: Sequence[int | str] | Sequence[int | None] = [1, 2, 3] -reveal_type(x11) # revealed: list[int] - -x12: list[int] | list[int | None] | list[str | None] = ["1", "2"] -reveal_type(x12) # revealed: list[str | None] - -x13: dict[str, list[int | None]] | dict[str, list[str | None]] = {"a": ["b"]} -reveal_type(x13) # revealed: dict[str, list[str | None]] - -x14: Mapping[str, list[int | None]] | Mapping[str, list[str | None]] = {"a": ["b"]} -reveal_type(x14) # revealed: dict[str, list[str | None]] - -def _(x15: list[dict[str, list[int] | int] | dict[str, list[int]]]): - x15.append(reveal_type({"b": 1})) # revealed: dict[str, list[int] | int] - -type EitherList = list[int | str] | list[int | None] - -x16: EitherList = [None, None] -reveal_type(x16) # revealed: list[int | None] - -x17: EitherList = ["1", "2", "3"] -reveal_type(x17) # revealed: list[int | str] - -type SelfOp[T] = Mapping[Literal["$eq", "$ne"], T] -type ListOp[T] = Mapping[Literal["$in", "$nin"], Sequence[T]] -type Ops[T] = SelfOp[T] | ListOp[T] -type NestedOp[T] = T | Ops[T] - -x18: NestedOp[str] = {"$in": ["a", "b"]} -reveal_type(x18) # revealed: dict[Literal["$in", "$nin"], list[str]] -``` - -## Annotations influence generic call argument inference - -```toml -[environment] -python-version = "3.13" -``` - -A function's arguments are also inferred using the type context: - -```py -from typing import Callable, TypedDict - -class TD(TypedDict): - x: int - -def first[T](x: list[T]) -> T: - return x[0] - -type ObjectCallback = Callable[[object], None] -type IntCallback = Callable[[int], None] - -def make_callback[T](callback: Callable[[T], None]) -> Callable[[T], None]: - return callback - -def consume(value: int) -> None: - pass - -x1: TD = first([{"x": 0}, {"x": 1}]) -reveal_type(x1) # revealed: TD - -x2: TD | None = first([{"x": 0}, {"x": 1}]) -reveal_type(x2) # revealed: TD - -# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" -# error: [invalid-key] "Unknown key "y" for TypedDict `TD`" -# error: [invalid-assignment] "Object of type `TD | dict[str, int]` is not assignable to `TD`" -x3: TD = first([{"y": 0}, {"x": 1}]) - -# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" -# error: [invalid-key] "Unknown key "y" for TypedDict `TD`" -# error: [invalid-assignment] "Object of type `TD | None | dict[str, int]` is not assignable to `TD | None`" -x4: TD | None = first([{"y": 0}, {"x": 1}]) - -# `ObjectCallback` is redundant in this union, so expanding the aliases collapses the narrowing -# target to `IntCallback`. -x5: ObjectCallback | IntCallback = make_callback(lambda value: consume(value.bit_length())) -``` - -But not in a way that leads to assignability errors: - -```py -from typing import TypedDict, Any - -class TD2(TypedDict): - x: str - -def _(dt: dict[str, Any], key: str): - x1: TD = dt.get(key, {}) - reveal_type(x1) # revealed: TD - - x2: TD = dt.get(key, {"x": 0}) - reveal_type(x2) # revealed: TD - - x3: TD | None = dt.get(key, {}) - reveal_type(x3) # revealed: TD | None - - x4: TD | None = dt.get(key, {"x": 0}) - reveal_type(x4) # revealed: TD | None - - x5: TD2 = dt.get(key, {}) - reveal_type(x5) # revealed: TD2 - - x6: TD2 = dt.get(key, {"x": 0}) - reveal_type(x6) # revealed: TD2 - - x7: TD2 | None = dt.get(key, {}) - reveal_type(x7) # revealed: TD2 | None - - x8: TD2 | None = dt.get(key, {"x": 0}) - reveal_type(x8) # revealed: TD2 | None -``` - -Partially specialized type context is not ignored: - -```py -from typing import TypeVar - -U = TypeVar("U", default=Any) - -class X: ... - -def lst[T](x: T) -> list[T]: - return [x] - -def two_lists[T](x: list[T | int], y: list[T | str]) -> T: - raise NotImplementedError - -def two_lists_default(x: list[U | int], y: list[U | str]) -> U: - raise NotImplementedError - -def dct[K, V](k: K, v: V) -> dict[K, V]: - return {k: v} - -def two_dicts[T](x: dict[T | int, Any], y: dict[T | str, Any]) -> T: - raise NotImplementedError - -def two_dicts_default(x: dict[U | int, Any], y: dict[U | str, Any]) -> U: - raise NotImplementedError - -def _(): - # revealed: list[int | X] - # revealed: list[str | X] - x1 = two_lists(reveal_type(lst(X())), reveal_type(lst(X()))) - reveal_type(x1) # revealed: X - - # revealed: list[int | X] - # revealed: list[str | X] - x2 = two_lists(reveal_type([X()]), reveal_type([X()])) - reveal_type(x2) # revealed: X - - # revealed: list[int | X] - # revealed: list[str | X] - x3 = two_lists_default(reveal_type(lst(X())), reveal_type(lst(X()))) - reveal_type(x3) # revealed: X - - # revealed: list[int | X] - # revealed: list[str | X] - x4 = two_lists_default(reveal_type([X()]), reveal_type([X()])) - reveal_type(x4) # revealed: X - - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] - x5 = two_dicts(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) - reveal_type(x5) # revealed: X - - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] - x6 = two_dicts(reveal_type({X(): X()}), reveal_type({X(): X()})) - reveal_type(x6) # revealed: X - - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] - x7 = two_dicts_default(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) - reveal_type(x7) # revealed: X - - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] - x8 = two_dicts_default(reveal_type({X(): X()}), reveal_type({X(): X()})) - reveal_type(x8) # revealed: X -``` - -## Prefer the declared type of generic classes and callables - -```toml -[environment] -python-version = "3.14" -``` - -```py -from typing import Any - -def f[T](x: T) -> list[T]: - return [x] - -def f2[T](x: T) -> list[T] | None: - return [x] - -def f3[T](x: T) -> list[T] | dict[T, T]: - return [x] - -a = f(1) -reveal_type(a) # revealed: list[int] - -b: list[Any] = f(1) -reveal_type(b) # revealed: list[Any] - -c: list[Any] = [1] -reveal_type(c) # revealed: list[Any] - -d: list[Any] | None = f(1) -reveal_type(d) # revealed: list[Any] - -e: list[Any] | None = [1] -reveal_type(e) # revealed: list[Any] - -f: list[Any] | None = f2(1) -reveal_type(f) # revealed: list[Any] | None - -g: list[Any] | dict[Any, Any] = f3(1) -reveal_type(g) # revealed: list[Any] | dict[Any, Any] -``` - -When inferring a generic call, we only use the declared type as type context if it is in -non-covariant position. The final annotated assignment binding still uses the declared type if the -inferred and declared types are mutually assignable. - -```py -class Bivariant[T]: - pass - -class Covariant[T]: - def pop(self) -> T: - raise NotImplementedError - -class Contravariant[T]: - def push(self, value: T) -> None: - pass - -class Invariant[T]: - x: T - -def bivariant[T](x: T) -> Bivariant[T]: - return Bivariant() - -def covariant[T](x: T) -> Covariant[T]: - return Covariant() - -def contravariant[T](x: T) -> Contravariant[T]: - return Contravariant() - -def invariant[T](x: T) -> Invariant[T]: - return Invariant() - -x1 = bivariant(1) -x2 = covariant(1) -x3 = contravariant(1) -x4 = invariant(1) - -reveal_type(x1) # revealed: Bivariant[Literal[1]] -reveal_type(x2) # revealed: Covariant[Literal[1]] -reveal_type(x3) # revealed: Contravariant[int] -reveal_type(x4) # revealed: Invariant[int] - -x5: Bivariant[int | None] = bivariant(1) -x6: Covariant[int | None] = covariant(1) -x7: Contravariant[int | None] = contravariant(1) -x8: Invariant[int | None] = invariant(1) - -reveal_type(x5) # revealed: Bivariant[int | None] -reveal_type(x6) # revealed: Covariant[Literal[1]] -reveal_type(x7) # revealed: Contravariant[int | None] -reveal_type(x8) # revealed: Invariant[int | None] - -x9: Bivariant[Any] = bivariant(1) -x10: Covariant[Any] = covariant(1) -x11: Contravariant[Any] = contravariant(1) -x12: Invariant[Any] = invariant(1) - -reveal_type(x9) # revealed: Bivariant[Any] -reveal_type(x10) # revealed: Covariant[Any] -reveal_type(x11) # revealed: Contravariant[Any] -reveal_type(x12) # revealed: Invariant[Any] -``` - -```py -class X[T]: - def __init__(self: X[None]): ... - def pop(self) -> T: - raise NotImplementedError - -x1: X[int | None] = X() -reveal_type(x1) # revealed: X[None] -``` - -We also prefer the declared type of `Callable` parameters, which are in contravariant position: - -```py -from typing import Callable - -type AnyToBool = Callable[[Any], bool] - -def wrap[**P, T](f: Callable[P, T]) -> Callable[P, T]: - return f - -def make_callable[T](x: T) -> Callable[[T], bool]: - raise NotImplementedError - -def maybe_make_callable[T](x: T) -> Callable[[T], bool] | None: - raise NotImplementedError - -x1: Callable[[Any], bool] = make_callable(0) -reveal_type(x1) # revealed: (Any, /) -> bool - -x2: AnyToBool = make_callable(0) -reveal_type(x2) # revealed: (Any, /) -> bool - -x3: Callable[[list[Any]], bool] = make_callable([0]) -reveal_type(x3) # revealed: (list[Any], /) -> bool - -x4: Callable[[Any], bool] = wrap(make_callable(0)) -reveal_type(x4) # revealed: (Any, /) -> bool - -x5: Callable[[Any], bool] | None = maybe_make_callable(0) -reveal_type(x5) # revealed: ((Any, /) -> bool) | None -``` - -## Declared type preference sees through subtyping - -```toml -[environment] -python-version = "3.12" -``` - -Similarly, if the inferred type is a subtype of the declared type, we prefer declared type -assignments that are in non-covariant position. - -```py -import builtins -from collections import defaultdict -from collections.abc import Mapping -from typing import Any, Callable, Iterable, Literal, MutableSequence, overload, Sequence - -x1: Sequence[Any] = [1, 2, 3] -reveal_type(x1) # revealed: list[int] - -x2: MutableSequence[Any] = [1, 2, 3] -reveal_type(x2) # revealed: list[Any] - -x3: Iterable[Any] = [1, 2, 3] -reveal_type(x3) # revealed: list[int] - -x4: Iterable[Iterable[Any]] = [[1, 2, 3]] -reveal_type(x4) # revealed: list[list[int]] - -x5: list[Iterable[Any]] = [[1, 2, 3]] -reveal_type(x5) # revealed: list[Iterable[Any]] - -x6: Iterable[list[Any]] = [[1, 2, 3]] -reveal_type(x6) # revealed: list[list[Any]] - -x7: Sequence[Any] = [i for i in [1, 2, 3]] -reveal_type(x7) # revealed: list[int] - -x8: MutableSequence[Any] = [i for i in [1, 2, 3]] -reveal_type(x8) # revealed: list[Any] - -x9: Iterable[Any] = [i for i in [1, 2, 3]] -reveal_type(x9) # revealed: list[int] - -x10: Iterable[Iterable[Any]] = [[i] for i in [1, 2, 3]] -reveal_type(x10) # revealed: list[list[int]] - -x11: list[Iterable[Any]] = [[i] for i in [1, 2, 3]] -reveal_type(x11) # revealed: list[Iterable[Any]] - -x12: Iterable[list[Any]] = [[i] for i in [1, 2, 3]] -reveal_type(x12) # revealed: list[list[Any]] - -class X[T]: - value: T - - def __init__(self, value: T): ... - -class A[T](X[T]): ... - -def a[T](value: T) -> A[T]: - return A(value) - -x13: A[object] = A(1) -reveal_type(x13) # revealed: A[object] - -x14: X[object] = A(1) -reveal_type(x14) # revealed: A[object] - -x15: X[object] | None = A(1) -reveal_type(x15) # revealed: A[object] - -x16: X[object] | None = a(1) -reveal_type(x16) # revealed: A[object] - -def f[T](x: T) -> list[list[T]]: - return [[x]] - -x17: Sequence[Sequence[Any]] = f(1) -reveal_type(x17) # revealed: list[list[int]] - -x18: Sequence[list[Any]] = f(1) -reveal_type(x18) # revealed: list[list[Any]] - -x19: dict[int, dict[str, int]] = defaultdict(dict) -reveal_type(x19) # revealed: defaultdict[int, dict[str, int]] - -x20: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[str, list[str]] -x20["key"].append(1) # error: [invalid-argument-type] - -factory: Callable[[], list[str]] = reveal_type(list) # revealed: -reveal_type(factory()) # revealed: list[str] - -optional_factory: Callable[[], list[str]] | None = reveal_type(list) # revealed: - -qualified_factory: Callable[[], list[str]] = reveal_type(builtins.list) # revealed: -reveal_type(qualified_factory()) # revealed: list[str] - -type ListFactory = Callable[[], list[str]] - -alias_factory: ListFactory = reveal_type(list) # revealed: -reveal_type(alias_factory()) # revealed: list[str] - -gradual_factory: Callable[..., Any] = reveal_type(list) # revealed: -dynamic_factory: Callable[[Any], Any] = reveal_type(list) # revealed: - -class Wrapped[T]: - value: T - - def __new__(cls, value: T) -> "Wrapped[tuple[T]]": - raise NotImplementedError - -wrapped_factory: Callable[[str], Wrapped[tuple[str]]] = reveal_type(Wrapped) # revealed: -reveal_type(wrapped_factory("x")) # revealed: Wrapped[tuple[str]] - -class M[T]: - value: T - - def __new__[S](cls, value: S) -> "M[tuple[S]]": - raise NotImplementedError - -m_factory: Callable[[str], M[tuple[str]]] = reveal_type(M) # revealed: -reveal_type(m_factory("x")) # revealed: M[tuple[str]] - -class MultiPath[T]: - value: T - - @overload - def __init__(self, value: T) -> None: ... - @overload - def __init__(self, value: list[T]) -> None: ... - def __init__(self, value: object) -> None: ... - -# fmt: off -multi_path_factory: Callable[[list[int]], MultiPath[int] | MultiPath[list[int]]] = reveal_type(MultiPath) # revealed: -# fmt: on -``` - -## Narrow union declared type for generic calls - -```toml -[environment] -python-version = "3.12" -``` - -```py -from typing import reveal_type, Any, Callable, TypedDict - -def identity[T](x: T) -> T: - return x - -type Target = Any | list[str] | dict[str, str] | Callable[[str], None] | None - -def _(narrow: dict[str, str], target: Target): - target = identity(narrow) - reveal_type(target) # revealed: dict[str, str] - -def _(narrow: list[str], target: Target): - target = identity(narrow) - reveal_type(target) # revealed: list[str] - -def _(narrow: Callable[[str], None], target: Target): - target = identity(narrow) - reveal_type(target) # revealed: (str, /) -> None - -def _(narrow: list[str] | dict[str, str], target: Target): - target = identity(narrow) - reveal_type(target) # revealed: list[str] | dict[str, str] - -class TD(TypedDict): - x: int - -type TargetWithTD = Any | list[TD] | dict[str, TD] | Callable[[TD], None] | None - -def _(target: TargetWithTD): - target = identity([{"x": 1}]) - reveal_type(target) # revealed: list[TD] - -def _(target: TargetWithTD): - target = identity({"x": {"x": 1}}) - reveal_type(target) # revealed: dict[str, TD] - -def _(target: TargetWithTD): - def make_callable[T](x: T) -> Callable[[T], None]: - raise NotImplementedError - - target = identity(make_callable({"x": 1})) - reveal_type(target) # revealed: (TD, /) -> None -``` - -```py -from typing import Mapping, Sequence - -x1: list[int | str] | list[int | None] = list((1, 2, 3)) -reveal_type(x1) # revealed: list[int | str] - -x2: Sequence[int | str] | Sequence[int | None] = list((1, 2, 3)) -reveal_type(x2) # revealed: list[int] - -x3: list[int] | list[int | None] | list[str | None] = list(("1", "2")) -reveal_type(x3) # revealed: list[str | None] - -x4: dict[str, list[int | None]] | dict[str, list[str | None]] = dict([("a", ["b"])]) -reveal_type(x4) # revealed: dict[str, list[str | None]] - -x5: Mapping[str, list[int | None]] | Mapping[str, list[str | None]] = dict([("a", ["b"])]) -reveal_type(x5) # revealed: dict[str, list[str | None]] - -def _(x6: list[dict[str, list[int] | int] | dict[str, list[int]]]): - x6.append(reveal_type(dict([("b", 1)]))) # revealed: dict[str, list[int] | int] - -type EitherList = list[int | str] | list[int | None] - -x7: EitherList = list((None, None)) -reveal_type(x7) # revealed: list[int | None] - -x8: EitherList = list(("1", "2", "3")) -reveal_type(x8) # revealed: list[int | str] -``` - -## Prefer the inferred type of non-generic classes - -```toml -[environment] -python-version = "3.12" -``` - -```py -def identity[T](x: T) -> T: - return x - -def lst[T](x: T) -> list[T]: - return [x] - -def _(i: int): - a: int | None = i - b: int | None = identity(i) - c: int | str | None = identity(i) - reveal_type(a) # revealed: int - reveal_type(b) # revealed: int - reveal_type(c) # revealed: int - - a: list[int | None] | None = [i] - b: list[int | None] | None = identity([i]) - c: list[int | None] | int | None = identity([i]) - reveal_type(a) # revealed: list[int | None] - reveal_type(b) # revealed: list[int | None] - reveal_type(c) # revealed: list[int | None] - - a: list[int | None] | None = [i] - b: list[int | None] | None = lst(i) - c: list[int | None] | int | None = lst(i) - reveal_type(a) # revealed: list[int | None] - reveal_type(b) # revealed: list[int | None] - reveal_type(c) # revealed: list[int | None] - - a: list | None = [] # error: [missing-type-argument] - b: list | None = identity([]) # error: [missing-type-argument] - c: list | int | None = identity([]) # error: [missing-type-argument] - reveal_type(a) # revealed: list[Unknown] - reveal_type(b) # revealed: list[Unknown] - reveal_type(c) # revealed: list[Unknown] - -def f[T](x: list[T]) -> T: - return x[0] - -def _(a: int, b: str, c: int | str): - x1: int = f(lst(a)) - reveal_type(x1) # revealed: int - - x2: int | str = f(lst(a)) - reveal_type(x2) # revealed: int - - x3: int | None = f(lst(a)) - reveal_type(x3) # revealed: int - - x4: str = f(lst(b)) - reveal_type(x4) # revealed: str - - x5: int | str = f(lst(b)) - reveal_type(x5) # revealed: str - - x6: str | int = f(lst(b)) - reveal_type(x6) # revealed: str - - x7: str | None = f(lst(b)) - reveal_type(x7) # revealed: str - - x8: int | str = f(lst(c)) - reveal_type(x8) # revealed: int | str - - x9: int | str = f(lst(c)) - reveal_type(x9) # revealed: int | str - - # TODO: Ideally this would reveal `int | str`. This is a known limitation of our - # call inference solver, and would require an extra inference attempt without type - # context, or with type context of subsets of the union, both of which are impractical - # for performance reasons. - x10: int | str | None = f(lst(c)) - reveal_type(x10) # revealed: int | str | None -``` - -## Assignability diagnostics ignore declared type - -```toml -[environment] -python-version = "3.12" -``` - -The type displayed in an invalid assignment diagnostic should account for the type context, e.g., to -avoid literal promotion: - -```py -from typing import Literal, TypedDict - -def f[T](x: T) -> list[T]: - return [x] - -# error: [invalid-assignment] "Object of type `list[Literal["hello"] | int]` is not assignable to `list[Literal["hello"] | bool]`" -x1: list[Literal["hello"] | bool] = ["hello", 1] - -class A(TypedDict): - bar: int - -# error: [invalid-assignment] "Object of type `list[A | int]` is not assignable to `list[A | bool]`" -x2: list[A | bool] = [{"bar": 1}, 1] -``` - -However, the declared type should be ignored if the specialization is not solvable: - -```py -from typing import Any, Callable - -def g[T](x: list[T]) -> T: - return x[0] - -def _(a: int | None): - # error: [invalid-assignment] "Object of type `list[int | None]` is not assignable to `list[str]`" - x1: list[str] = f(a) - - # error: [invalid-assignment] "Object of type `int | None` is not assignable to `str`" - x2: str = g(f(a)) - -def make_callable[T](x: T) -> Callable[[T], bool]: - raise NotImplementedError - -def _(a: int | None): - # error: [invalid-assignment] "Object of type `(int | None, /) -> bool` is not assignable to `(str, /) -> bool`" - x1: Callable[[str], bool] = make_callable(a) -``` - ## Forward annotation with unclosed string literal Regression test for [#1611](https://github.com/astral-sh/ty/issues/1611). diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 636a51c7fa..ed170f00bc 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -10,42 +10,351 @@ to the inference of inner expressions. ```toml [environment] -python-version = "3.12" +python-version = "3.13" ``` -## Propagating target type annotation +## Type context sources + +Type context is sourced from various places, including annotated assignments: + +```py +from typing import Literal + +a: list[Literal[1]] = [1] +``` + +Function parameter annotations: + +```py +def b(x: list[Literal[1]]): ... + +b([1]) +``` + +Bound method parameter annotations: + +```py +class C: + def __init__(self, x: list[Literal[1]]): ... + def foo(self, x: list[Literal[1]]): ... + +C([1]).foo([1]) +``` + +Declared variable types: + +```py +d: list[Literal[1]] +d = [1] +``` + +Declared attribute types: + +```py +class E: + a: list[Literal[1]] + b: list[Literal[1]] + +def _(e: E): + e.a = [1] + E.b = [1] +``` + +Function return types: + +```py +def f() -> list[Literal[1]]: + return [1] +``` + +## Collection literals + +### Basic + +```py +import typing + +a: list[int] = [1, 2, 3] +reveal_type(a) # revealed: list[int] + +b: list[int | str] = [1, 2, 3] +reveal_type(b) # revealed: list[int | str] + +c: typing.List[int] = [1, 2, 3] +reveal_type(c) # revealed: list[int] + +d: list[typing.Any] = [] +reveal_type(d) # revealed: list[Any] + +e: set[int] = {1, 2, 3} +reveal_type(e) # revealed: set[int] + +f: set[int | str] = {1, 2, 3} +reveal_type(f) # revealed: set[int | str] + +g: typing.Set[int] = {1, 2, 3} +reveal_type(g) # revealed: set[int] + +h: list[list[int]] = [[], [42]] +reveal_type(h) # revealed: list[list[int]] + +i: list[typing.Any] = [1, 2, "3", ([4],)] +reveal_type(i) # revealed: list[Any] + +j: list[tuple[str | int, ...]] = [(1, 2), ("foo", "bar"), ()] +reveal_type(j) # revealed: list[tuple[str | int, ...]] + +k: list[tuple[list[int], ...]] = [([],), ([1, 2], [3, 4]), ([5], [6], [7])] +reveal_type(k) # revealed: list[tuple[list[int], ...]] + +l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) +reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] + +type IntList = list[int] + +m: IntList = [1, 2, 3] +reveal_type(m) # revealed: list[int] + +n: list[typing.Literal[1, 2, 3]] = [1, 2, 3] +reveal_type(n) # revealed: list[Literal[1, 2, 3]] + +o: list[typing.LiteralString] = ["a", "b", "c"] +reveal_type(o) # revealed: list[LiteralString] + +p: dict[int, int] = {} +reveal_type(p) # revealed: dict[int, int] + +q: dict[int | str, int] = {1: 1, 2: 2, 3: 3} +reveal_type(q) # revealed: dict[int | str, int] + +r: dict[int | str, int | str] = {1: 1, 2: 2, 3: 3} +reveal_type(r) # revealed: dict[int | str, int | str] + +s: dict[int | str, int | str] +s = {1: 1, 2: 2, 3: 3} +reveal_type(s) # revealed: dict[int | str, int | str] +(s := {1: 1, 2: 2, 3: 3}) +reveal_type(s) # revealed: dict[int | str, int | str] +``` + +### Optional unions + +```py +import typing + +a: list[int] | None = [1, 2, 3] +reveal_type(a) # revealed: list[int] + +b: list[int | str] | None = [1, 2, 3] +reveal_type(b) # revealed: list[int | str] + +c: typing.List[int] | None = [1, 2, 3] +reveal_type(c) # revealed: list[int] + +d: list[typing.Any] | None = [] +reveal_type(d) # revealed: list[Any] + +e: set[int] | None = {1, 2, 3} +reveal_type(e) # revealed: set[int] + +f: set[int | str] | None = {1, 2, 3} +reveal_type(f) # revealed: set[int | str] + +g: typing.Set[int] | None = {1, 2, 3} +reveal_type(g) # revealed: set[int] + +h: list[list[int]] | None = [[], [42]] +reveal_type(h) # revealed: list[list[int]] + +i: list[typing.Any] | None = [1, 2, "3", ([4],)] +reveal_type(i) # revealed: list[Any] + +j: list[tuple[str | int, ...]] | None = [(1, 2), ("foo", "bar"), ()] +reveal_type(j) # revealed: list[tuple[str | int, ...]] + +k: list[tuple[list[int], ...]] | None = [([],), ([1, 2], [3, 4]), ([5], [6], [7])] +reveal_type(k) # revealed: list[tuple[list[int], ...]] + +l: tuple[list[int], *tuple[list[typing.Any], ...], list[str]] | None = ([1, 2, 3], [4, 5, 6], [7, 8, 9], ["10", "11", "12"]) +reveal_type(l) # revealed: tuple[list[int], list[Any], list[Any], list[str]] + +type IntList = list[int] + +m: IntList | None = [1, 2, 3] +reveal_type(m) # revealed: list[int] + +n: list[typing.Literal[1, 2, 3]] | None = [1, 2, 3] +reveal_type(n) # revealed: list[Literal[1, 2, 3]] + +o: list[typing.LiteralString] | None = ["a", "b", "c"] +reveal_type(o) # revealed: list[LiteralString] + +p: dict[int, int] | None = {} +reveal_type(p) # revealed: dict[int, int] + +q: dict[int | str, int] | None = {1: 1, 2: 2, 3: 3} +reveal_type(q) # revealed: dict[int | str, int] + +r: dict[int | str, int | str] | None = {1: 1, 2: 2, 3: 3} +reveal_type(r) # revealed: dict[int | str, int | str] +``` + +### Starred elements and dictionary unpacking + +```py +from typing import Any + +x1: list[int] = [1, 2, *(3, 4, 5)] +reveal_type(x1) # revealed: list[int] + +x2: list[list[int]] = [[1], [2], *([3], [4])] +reveal_type(x2) # revealed: list[list[int]] + +x3: dict[str, int] = {"a": 1, **{"b": 2}} +reveal_type(x3) # revealed: dict[str, int] + +def dynamic_mapping() -> Any: ... + +x4: dict[str, int] = reveal_type({**dynamic_mapping()}) # revealed: dict[str | Any, int | Any] + +# error: [invalid-argument-type] "Argument expression after ** must be a mapping type" +x5: dict[str, int] = {**42} +``` + +### Aliases and protocols + +```py +from collections.abc import Iterable + +type IntDict = dict[str, int] + +x1: IntDict = {"a": 1} +reveal_type(x1) # revealed: dict[str, int] + +# A protocol context is not an exact nominal collection context and must use the general path. +x2: Iterable[int] = [1] +reveal_type(x2) # revealed: list[int] +``` + +### Invalid elements + +```py +x1: dict[str, int] = {"a": "bad"} # error: [invalid-assignment] +x2: list[list[list[str]]] = [[[1]]] # error: [invalid-assignment] +``` + +### Collection unions ```py -from typing import Any, AsyncGenerator, AsyncIterable, Generator, Iterable, Literal +from collections.abc import Mapping, Sequence +from typing import Literal + +x1: list[int | str] | list[int | None] = [1, 2, 3] +reveal_type(x1) # revealed: list[int | str] + +x2: Sequence[int | str] | Sequence[int | None] = [1, 2, 3] +reveal_type(x2) # revealed: list[int] + +x3: list[int] | list[int | None] | list[str | None] = ["1", "2"] +reveal_type(x3) # revealed: list[str | None] + +x4: dict[str, list[int | None]] | dict[str, list[str | None]] = {"a": ["b"]} +reveal_type(x4) # revealed: dict[str, list[str | None]] + +x5: Mapping[str, list[int | None]] | Mapping[str, list[str | None]] = {"a": ["b"]} +reveal_type(x5) # revealed: dict[str, list[str | None]] -def list1[T](x: T) -> list[T]: +def _(x6: list[dict[str, list[int] | int] | dict[str, list[int]]]): + x6.append(reveal_type({"b": 1})) # revealed: dict[str, list[int] | int] + +type EitherList = list[int | str] | list[int | None] + +x7: EitherList = [None, None] +reveal_type(x7) # revealed: list[int | None] + +x8: EitherList = ["1", "2", "3"] +reveal_type(x8) # revealed: list[int | str] + +type SelfOp[T] = Mapping[Literal["$eq", "$ne"], T] +type ListOp[T] = Mapping[Literal["$in", "$nin"], Sequence[T]] +type Ops[T] = SelfOp[T] | ListOp[T] +type NestedOp[T] = T | Ops[T] + +x9: NestedOp[str] = {"$in": ["a", "b"]} +reveal_type(x9) # revealed: dict[Literal["$in", "$nin"], list[str]] +``` + +### Binary operations + +```py +def singleton[T](x: T) -> list[T]: return [x] -l1: list[Literal[1]] = list1(1) -reveal_type(l1) # revealed: list[Literal[1]] +x1: list[list[int | str]] = [[1], [2]] * 3 +reveal_type(x1) # revealed: list[list[int | str]] -l2 = list1(1) -reveal_type(l2) # revealed: list[int] +x2: list[list[int | str]] = 3 * ([[1]] + [[2]]) +reveal_type(x2) # revealed: list[list[int | str]] -l3: list[int | str] | None = list1(1) -reveal_type(l3) # revealed: list[int | str] +x3: list[int | str] = 3 * ["x" for _ in range(3)] +reveal_type(x3) # revealed: list[int | str] -def _(l: list[int] | None = None): - l1 = l or list() - reveal_type(l1) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown] +x4: set[int | str] = {1, 2} | {3, 4} +reveal_type(x4) # revealed: set[int | str] - l2: list[int] = l or list() - reveal_type(l2) # revealed: list[int] +x5: dict[int | str, int | str] = {1: 2} | {3: 4} +reveal_type(x5) # revealed: dict[int | str, int | str] -class TextContent: ... -class TagContent: ... +# TODO: We currently eagerly pass type context to collection literals on either side of a binary +# operator. That makes the cases above work, but it is not generally sound. +class X: + def __add__(self, _: list[int]) -> list[int | str]: + return [] -def expects_list_content(content: list[TextContent | TagContent]) -> None: ... -def optional_content(content: list[TextContent | TagContent] | None) -> None: - expects_list_content(content or [TextContent()]) +# error: [unsupported-operator] "Operator `+` is not supported between objects of type `X` and `list[int | str]`" +x6: list[int | str] = X() + [1] -def invalid_fallback(content: list[TextContent | TagContent] | None) -> None: - expects_list_content(content or [object()]) # error: [invalid-argument-type] +# TODO: We do not yet propagate type context through the generic call. +# error: [invalid-assignment] "Object of type `list[int]` is not assignable to `list[int | str]`" +x7: list[int | str] = singleton(42) * 3 +``` + +## Comprehensions + +```py +x1: set[int | str] = {42 for _ in range(3)} +reveal_type(x1) # revealed: set[int | str] + +x2: dict[int | str, int | str] = {str(i): i for i in range(3)} +reveal_type(x2) # revealed: dict[int | str, int | str] +``` + +## Tuple expressions + +```py +from typing import Literal + +def singleton[T](x: T) -> list[T]: + return [x] + +# Tuple elements are inferred individually, but type context can prevent e.g. `int` widening. +x1: tuple[list[Literal[1]]] = (singleton(1),) +reveal_type(x1) # revealed: tuple[list[Literal[1]]] + +x2: tuple[list[Literal[1]], ...] = (singleton(1),) * 3 +reveal_type(x2) # revealed: tuple[list[Literal[1]], ...] + +x3: tuple[list[Literal[1]], ...] = 3 * ((singleton(1),) + (singleton(1),)) +reveal_type(x3) # revealed: tuple[list[Literal[1]], ...] +``` + +## Generator expressions + +```py +from collections.abc import AsyncGenerator, AsyncIterable, Generator, Iterable + +class TextContent: ... +class TagContent: ... def expects_generator_content(content: Generator[list[TextContent | TagContent], None, None]) -> None: ... def expects_iterable_content(content: Iterable[list[TextContent | TagContent]]) -> None: ... @@ -75,339 +384,813 @@ async def async_generator_content() -> None: async def invalid_async_generator_content() -> None: expects_async_generator_content([object()] async for _ in async_texts()) # error: [invalid-argument-type] +``` + +## Generic call inference + +The declared type of a generic call expression is used to infer a more assignable specialization for +the callable: + +```py +from typing import Literal + +def f[T](x: T) -> list[T]: + return [x] + +x1 = f("a") +reveal_type(x1) # revealed: list[str] + +x2: list[int | Literal["a"]] = f("a") +reveal_type(x2) # revealed: list[int | Literal["a"]] + +x3: list[int | str] = f("a") +reveal_type(x3) # revealed: list[int | str] + +x4: list[int | tuple[int, int]] = f((1, 2)) +reveal_type(x4) # revealed: list[int | tuple[int, int]] + +x5: list[int] = f(True) +reveal_type(x5) # revealed: list[int] + +# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `list[int]`" +x6: list[int] = f("a") -def f[T](x: T, cond: bool) -> T | list[T]: +# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `tuple[int]`" +x7: tuple[int] = f("a") + +def f2[T: int](x: T) -> T: + return x + +x8: int = f2(True) +reveal_type(x8) # revealed: Literal[True] + +x9: int | str = f2(True) +reveal_type(x9) # revealed: Literal[True] +``` + +```py +from typing import Callable, overload + +def singleton[T](x: T) -> list[T]: + return [x] + +x10: list[int | str] | None = singleton(1) +reveal_type(x10) # revealed: list[int | str] + +def value_or_list[T](x: T, cond: bool) -> T | list[T]: return x if cond else [x] -l5: int | list[int] = f(1, True) +x11: int | list[int] = value_or_list(1, True) +reveal_type(x11) # revealed: int | list[int] -x: list[int] = [1, 2, *(3, 4, 5)] -reveal_type(x) # revealed: list[int] +def returns_objects() -> list[object]: + reveal_type(singleton(1)) # revealed: list[int] + # `list[int]` and `list[object]` are incompatible, but the return type check passes because + # this call is inferred using the annotated return type. + return singleton(1) -x: list[list[int]] = [[1], [2], *([3], [4])] -reveal_type(x) # revealed: list[list[int]] +def returns_optional_objects() -> list[object] | None: + return singleton(1) -type IntDict = dict[str, int] +def deco[T](func: Callable[[], T]) -> Callable[[], T]: + return func -unique: set[int] = {1, 2, 3} -reveal_type(unique) # revealed: set[int] +def outer() -> Callable[[], list[object]]: + @deco + def inner() -> list[object]: + return singleton(1) -mapping: dict[str, int] = {"a": 1, **{"b": 2}} -reveal_type(mapping) # revealed: dict[str, int] + return inner -def dynamic_mapping() -> Any: ... +@overload +def overloaded(x: int) -> list[int]: ... +@overload +def overloaded(x: str) -> list[str]: ... +def overloaded(x: int | str) -> list[int] | list[str]: + # `list[int] | list[str]` is disjoint from `list[int | str]`. + if isinstance(x, int): + return singleton(x) + else: + return singleton(x) -dynamic_unpack: dict[str, int] = reveal_type({**dynamic_mapping()}) # revealed: dict[str | Any, int | Any] +reveal_type(overloaded(1)) # revealed: list[int] +reveal_type(overloaded("a")) # revealed: list[str] -alias_mapping: IntDict = {"a": 1} -reveal_type(alias_mapping) # revealed: dict[str, int] +async def async_return() -> list[int | str]: + return singleton(1) -optional_mapping: dict[str, int] | None = {"a": 1} -reveal_type(optional_mapping) # revealed: dict[str, int] +def forward[T](x: T, cond: bool) -> T | list[T]: + return forwarded(x, cond) -either: list[int] | list[str] = [1] -reveal_type(either) # revealed: list[int] +def forwarded[T](x: T, cond: bool) -> T | list[T]: + return x if cond else [x] +``` -# A protocol context is not an exact nominal collection context and must use the general path. -iterable: Iterable[int] = [1] -reveal_type(iterable) # revealed: list[int] +## Generic constructors -bad_list: list[str] = [1] # error: [invalid-assignment] -bad_dict: dict[str, int] = {"a": "bad"} # error: [invalid-assignment] +The same applies to constructors of generic classes: -bad_nested_list: list[list[list[str]]] = [[[1]]] # error: [invalid-assignment] +```py +from typing import Any -# error: [invalid-argument-type] "Argument expression after ** must be a mapping type" -bad_unpack: dict[str, int] = {**42} +class X[T]: + def __init__(self, value: T): + self.value = value -x: list[list[int | str]] = [[1], [2]] * 3 -reveal_type(x) # revealed: list[list[int | str]] +x1: X[int] = X(1) +reveal_type(x1) # revealed: X[int] -x: list[list[int | str]] = 3 * ([[1]] + [[2]]) -reveal_type(x) # revealed: list[list[int | str]] +x2: X[int | None] = X(1) +reveal_type(x2) # revealed: X[int | None] -x: list[int | str] = 3 * ["x" for _ in range(3)] -reveal_type(x) # revealed: list[int | str] +x3: X[int | None] | None = X(1) +reveal_type(x3) # revealed: X[int | None] -# Tuple elements are inferred individually, but type context can prevent e.g. `int` widening. -x: tuple[list[Literal[1]]] = (list1(1),) -reveal_type(x) # revealed: tuple[list[Literal[1]]] +def _[T](x1: X[T]): + x2: X[T | int] = X(x1.value) + reveal_type(x2) # revealed: X[T@_ | int] -x: tuple[list[Literal[1]], ...] = (list1(1),) * 3 -reveal_type(x) # revealed: tuple[list[Literal[1]], ...] +x4: X[Any] = X(1) +reveal_type(x4) # revealed: X[Any] -x: tuple[list[Literal[1]], ...] = 3 * ((list1(1),) + (list1(1),)) -reveal_type(x) # revealed: tuple[list[Literal[1]], ...] +def _(flag: bool): + x5: X[int | None] = X(1) if flag else X(2) + reveal_type(x5) # revealed: X[int | None] +``` -x: set[int | str] = {1, 2} | {3, 4} -reveal_type(x) # revealed: set[int | str] +```py +from dataclasses import dataclass -x: set[int | str] = {42 for _ in range(3)} -reveal_type(x) # revealed: set[int | str] +@dataclass +class Y[T]: + value: T -x: dict[int | str, int | str] = {1: 2} | {3: 4} -reveal_type(x) # revealed: dict[int | str, int | str] +y1 = Y(value=1) +reveal_type(y1) # revealed: Y[int] -x: dict[int | str, int | str] = {str(i): i for i in range(3)} -reveal_type(x) # revealed: dict[int | str, int | str] +y2: Y[Any] = Y(value=1) +reveal_type(y2) # revealed: Y[Any] +``` -# TODO: We currently eagerly pass type context to collection literals on either side of a binary -# operator. That makes the cases above work, but it's not generally sound. For example, it gives the -# wrong result in this case. -class X: - def __add__(self, _: list[int]) -> list[int | str]: - return [] +```py +class Z[T]: + value: T -# error: [unsupported-operator] "Operator `+` is not supported between objects of type `X` and `list[int | str]`" -x: list[int | str] = X() + [1] + def __new__(cls, value: T): + return super().__new__(cls) -# TODO: We also don't yet support generic function calls like this. -# error: [invalid-assignment] "Object of type `list[int]` is not assignable to `list[int | str]`" -x: list[int | str] = list1(42) * 3 +z1 = Z(1) +reveal_type(z1) # revealed: Z[int] + +z2: Z[Any] = Z(1) +reveal_type(z2) # revealed: Z[Any] +``` + +The return type should preserve the independent key and value types of a generic `dict` constructor: + +```py +from collections.abc import Iterable, Mapping + +def dict_with_numeric_promotion( + keys: Iterable[float], + values: Iterable[int], +) -> Mapping[float, int]: + return dict(zip(keys, values)) +``` + +```py +from collections.abc import Callable, Hashable + +# The `dict(...)` variant is not technically allowed by the typeshed overloads, which require +# string keys for keyword arguments. We special-case it to match the literal form. +x1: dict[Hashable, Callable[..., object]] = {"x": lambda: 1} +x2: dict[Hashable, Callable[..., object]] = dict(x=lambda: 1) ``` -`typed_dict.py`: +## Generic call argument inference + +A function's arguments are also inferred using the type context: ```py -from typing import Any, Callable, Hashable, Iterable, Mapping, TypedDict -from typing_extensions import Never +from typing import Callable, TypedDict class TD(TypedDict): x: int -class BadTD(TypedDict): +def first[T](x: list[T]) -> T: + return x[0] + +type ObjectCallback = Callable[[object], None] +type IntCallback = Callable[[int], None] + +def make_callback[T](callback: Callable[[T], None]) -> Callable[[T], None]: + return callback + +def consume(value: int) -> None: + pass + +x1: TD = first([{"x": 0}, {"x": 1}]) +reveal_type(x1) # revealed: TD + +x2: TD | None = first([{"x": 0}, {"x": 1}]) +reveal_type(x2) # revealed: TD + +# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" +# error: [invalid-key] "Unknown key "y" for TypedDict `TD`" +# error: [invalid-assignment] "Object of type `TD | dict[str, int]` is not assignable to `TD`" +x3: TD = first([{"y": 0}, {"x": 1}]) + +# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" +# error: [invalid-key] "Unknown key "y" for TypedDict `TD`" +# error: [invalid-assignment] "Object of type `TD | None | dict[str, int]` is not assignable to `TD | None`" +x4: TD | None = first([{"y": 0}, {"x": 1}]) + +# `ObjectCallback` is redundant in this union, so expanding the aliases collapses the narrowing +# target to `IntCallback`. +x5: ObjectCallback | IntCallback = make_callback(lambda value: consume(value.bit_length())) +``` + +But not in a way that leads to assignability errors: + +```py +from typing import TypedDict, Any + +class TD2(TypedDict): x: str -d1_literal = {"x": 1} -d1_dict = dict(x=1) +def _(dt: dict[str, Any], key: str): + x1: TD = dt.get(key, {}) + reveal_type(x1) # revealed: TD -reveal_type(d1_literal) # revealed: dict[str, int] -reveal_type(d1_dict) # revealed: dict[str, int] + x2: TD = dt.get(key, {"x": 0}) + reveal_type(x2) # revealed: TD -d2_literal: TD = {"x": 1} -d2_dict: TD = dict(x=1) -d2_unpack: TD = dict(**d2_literal) + x3: TD | None = dt.get(key, {}) + reveal_type(x3) # revealed: TD | None -reveal_type(d2_literal) # revealed: TD -reveal_type(d2_dict) # revealed: TD -reveal_type(d2_unpack) # revealed: TD + x4: TD | None = dt.get(key, {"x": 0}) + reveal_type(x4) # revealed: TD | None -d3_literal: dict[str, int] = {"x": 1} -d3_dict: dict[str, int] = dict(x=1) + x5: TD2 = dt.get(key, {}) + reveal_type(x5) # revealed: TD2 -reveal_type(d3_literal) # revealed: dict[str, int] -reveal_type(d3_dict) # revealed: dict[str, int] + x6: TD2 = dt.get(key, {"x": 0}) + reveal_type(x6) # revealed: TD2 -d4_invalid_literal: TD = {"x": "1"} # error: [invalid-argument-type] -d4_invalid_dict: TD = dict(x="1") # error: [invalid-argument-type] + x7: TD2 | None = dt.get(key, {}) + reveal_type(x7) # revealed: TD2 | None -reveal_type(d4_invalid_literal) # revealed: TD -reveal_type(d4_invalid_dict) # revealed: TD + x8: TD2 | None = dt.get(key, {"x": 0}) + reveal_type(x8) # revealed: TD2 | None +``` -def unpack_invalid_typed_dict(src: BadTD) -> TD: - # The fast path should validate TypedDict-shaped unpacks even when they are not assignable to - # the target. That preserves the key-level TypedDict diagnostic instead of falling back to a - # broad `dict[str, str]` assignment error. - # error: [invalid-argument-type] "Invalid argument to key "x" with declared type `int` on TypedDict `TD`: value of type `str`" - return dict(**src) +Partially specialized type context is not ignored: -def return_any_unpack(src: Any) -> TD: - return dict(**src) +```py +from typing import TypeVar -def pass_never_unpack(src: Never) -> None: - takes_td(dict(**src)) +U = TypeVar("U", default=Any) -def takes_mapping(value: Mapping[str, object]) -> None: - pass +class X: ... -def keep_keyword_diagnostics(kwargs: Mapping[str, object]) -> None: - # The TypedDict-aware `dict(...)` fast path should not lose diagnostics from named keywords - # when unsupported `**kwargs` forces it to fall back to ordinary dict inference. - # error: [unresolved-reference] "Name `missing` used when not defined" - # error: [invalid-assignment] - maybe_td: TD = dict(x=missing, **kwargs) - takes_mapping(maybe_td) - -def takes_dict(value: dict[str, object]) -> None: ... -def takes_kwargs(**kwargs: object) -> None: ... -def _(data: TD): - reveal_type(dict(data)) # revealed: dict[str, object] - takes_dict(dict(data)) - takes_kwargs(**dict(data)) - -# Note: the second variant (`d5_dict`) is not technically allowed by the `dict.__init__` overloads -# in typeshed, which require the key type to be `str` when using keyword arguments. However, we -# special-case this pattern to match the behavior of `d5_literal`. -d5_literal: dict[Hashable, Callable[..., object]] = {"x": lambda: 1} -d5_dict: dict[Hashable, Callable[..., object]] = dict(x=lambda: 1) - -d6_dict: TD = {"x": 1} | {"x": 2} - -type IntFloatDict = dict[int, float] -type TypedDictOrDictAlias = TD | IntFloatDict -type TypedDictOrMapping = TD | Mapping[int, float] - -# The `dict[int, float]` fallback should still win when it is wrapped in an alias. -d7_alias_fallback: TypedDictOrDictAlias = {1: 5.2} -d8_mapping_fallback: TypedDictOrMapping = {1: 5.2} - -# A `Mapping` fallback should only suppress `TypedDict` diagnostics when it accepts the literal. -# error: [missing-typed-dict-key] -# error: [invalid-key] -d9_invalid_mapping_key: TypedDictOrMapping = {"y": 5.2} -# error: [missing-typed-dict-key] -# error: [invalid-key] -d10_invalid_mapping_value: TypedDictOrMapping = {1: "bad"} - -def takes_td_or_iterable(value: TD | Iterable[int]) -> None: - pass +def lst[T](x: T) -> list[T]: + return [x] + +def two_lists[T](x: list[T | int], y: list[T | str]) -> T: + raise NotImplementedError + +def two_lists_default(x: list[U | int], y: list[U | str]) -> U: + raise NotImplementedError + +def dct[K, V](k: K, v: V) -> dict[K, V]: + return {k: v} + +def two_dicts[T](x: dict[T | int, Any], y: dict[T | str, Any]) -> T: + raise NotImplementedError + +def two_dicts_default(x: dict[U | int, Any], y: dict[U | str, Any]) -> U: + raise NotImplementedError -takes_td_or_iterable({42: 42}) +def _(): + # revealed: list[int | X] + # revealed: list[str | X] + x1 = two_lists(reveal_type(lst(X())), reveal_type(lst(X()))) + reveal_type(x1) # revealed: X + + # revealed: list[int | X] + # revealed: list[str | X] + x2 = two_lists(reveal_type([X()]), reveal_type([X()])) + reveal_type(x2) # revealed: X + + # revealed: list[int | X] + # revealed: list[str | X] + x3 = two_lists_default(reveal_type(lst(X())), reveal_type(lst(X()))) + reveal_type(x3) # revealed: X + + # revealed: list[int | X] + # revealed: list[str | X] + x4 = two_lists_default(reveal_type([X()]), reveal_type([X()])) + reveal_type(x4) # revealed: X + + # revealed: dict[int | X, Any] + # revealed: dict[str | X, Any] + x5 = two_dicts(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) + reveal_type(x5) # revealed: X + + # revealed: dict[int | X, Any] + # revealed: dict[str | X, Any] + x6 = two_dicts(reveal_type({X(): X()}), reveal_type({X(): X()})) + reveal_type(x6) # revealed: X + + # revealed: dict[int | X, Any] + # revealed: dict[str | X, Any] + x7 = two_dicts_default(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) + reveal_type(x7) # revealed: X + + # revealed: dict[int | X, Any] + # revealed: dict[str | X, Any] + x8 = two_dicts_default(reveal_type({X(): X()}), reveal_type({X(): X()})) + reveal_type(x8) # revealed: X +``` -def return_literal() -> TD: - return {"x": 1} +## Prefer the declared type of generic classes and callables -def return_dict() -> TD: - return dict(x=1) +When inferring a generic call, we only use the declared type as type context if it is in +non-covariant position. The final annotated assignment binding still uses the declared type if the +inferred and declared types are mutually assignable: -def return_unpack(src: TD) -> TD: - return dict(**src) +```py +from typing import Any -def takes_td(value: TD) -> None: +class Bivariant[T]: pass -def pass_unpack(src: TD) -> None: - takes_td(dict(**src)) +class Covariant[T]: + def pop(self) -> T: + raise NotImplementedError + +class Contravariant[T]: + def push(self, value: T) -> None: + pass + +class Invariant[T]: + x: T + +def bivariant[T](x: T) -> Bivariant[T]: + return Bivariant() + +def covariant[T](x: T) -> Covariant[T]: + return Covariant() + +def contravariant[T](x: T) -> Contravariant[T]: + return Contravariant() + +def invariant[T](x: T) -> Invariant[T]: + return Invariant() + +x1 = bivariant(1) +x2 = covariant(1) +x3 = contravariant(1) +x4 = invariant(1) -def return_invalid_literal() -> TD: - # TODO: ideally, this would only emit the first error, but not `invalid-return-type` (like the `return_invalid_dict` case below). - # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" - # error: [invalid-return-type] - return {} +reveal_type(x1) # revealed: Bivariant[Literal[1]] +reveal_type(x2) # revealed: Covariant[Literal[1]] +reveal_type(x3) # revealed: Contravariant[int] +reveal_type(x4) # revealed: Invariant[int] -def return_invalid_dict() -> TD: - # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" - return dict() +x5: Bivariant[int | None] = bivariant(1) +x6: Covariant[int | None] = covariant(1) +x7: Contravariant[int | None] = contravariant(1) +x8: Invariant[int | None] = invariant(1) + +reveal_type(x5) # revealed: Bivariant[int | None] +reveal_type(x6) # revealed: Covariant[Literal[1]] +reveal_type(x7) # revealed: Contravariant[int | None] +reveal_type(x8) # revealed: Invariant[int | None] + +x9: Bivariant[Any] = bivariant(1) +x10: Covariant[Any] = covariant(1) +x11: Contravariant[Any] = contravariant(1) +x12: Invariant[Any] = invariant(1) + +reveal_type(x9) # revealed: Bivariant[Any] +reveal_type(x10) # revealed: Covariant[Any] +reveal_type(x11) # revealed: Contravariant[Any] +reveal_type(x12) # revealed: Invariant[Any] ``` -## Propagating return type annotation +This behavior also applies to invariant collection types: ```py -from typing import overload, Callable +from typing import Any -def list1[T](x: T) -> list[T]: +def f[T](x: T) -> list[T]: return [x] -def f() -> list[object]: - reveal_type(list1(1)) # revealed: list[int] - # `list[int]` and `list[object]` are incompatible, but the return type check passes here - # because the type of `list1(res)` is inferred by bidirectional type inference using the - # annotated return type, and the type of `res` is not used. - return list1(1) +def f2[T](x: T) -> list[T] | None: + return [x] -def f2() -> list[object] | None: - reveal_type(list1(1)) # revealed: list[int] - return list1(1) +def f3[T](x: T) -> list[T] | dict[T, T]: + return [x] -def deco[T](func: Callable[[], T]) -> Callable[[], T]: - return func +x1 = f(1) +reveal_type(x1) # revealed: list[int] -def outer() -> Callable[[], list[object]]: - @deco - def inner() -> list[object]: - reveal_type(list1(1)) # revealed: list[int] - return list1(1) - return inner +x2: list[Any] = f(1) +reveal_type(x2) # revealed: list[Any] -@overload -def f(x: int) -> list[int]: ... -@overload -def f(x: str) -> list[str]: ... -def f(x: int | str) -> list[int] | list[str]: - # `list[int] | list[str]` is disjoint from `list[int | str]`. - if isinstance(x, int): - return list1(x) - else: - return list1(x) +x3: list[Any] = [1] +reveal_type(x3) # revealed: list[Any] -reveal_type(f(1)) # revealed: list[int] -reveal_type(f("a")) # revealed: list[str] +x4: list[Any] | None = f(1) +reveal_type(x4) # revealed: list[Any] -async def g() -> list[int | str]: - return list1(1) +x5: list[Any] | None = [1] +reveal_type(x5) # revealed: list[Any] -def h[T](x: T, cond: bool) -> T | list[T]: - return i(x, cond) +x6: list[Any] | None = f2(1) +reveal_type(x6) # revealed: list[Any] | None -def i[T](x: T, cond: bool) -> T | list[T]: - return x if cond else [x] +x7: list[Any] | dict[Any, Any] = f3(1) +reveal_type(x7) # revealed: list[Any] | dict[Any, Any] ``` -The return type context should preserve the independent key and value types of a generic `dict` -constructor: +As well as constructors of generic classes: ```py -from collections.abc import Iterable, Mapping +class X[T]: + def __init__(self: "X[None]"): ... + def pop(self) -> T: + raise NotImplementedError -def dict_with_numeric_promotion( - keys: Iterable[float], - values: Iterable[int], -) -> Mapping[float, int]: - return dict(zip(keys, values)) +x1: X[int | None] = X() +reveal_type(x1) # revealed: X[None] ``` -## Type context sources - -Type context is sourced from various places, including annotated assignments: +We also prefer the declared type of `Callable` parameters, which are in contravariant position: ```py -from typing import Literal +from typing import Callable -a: list[Literal[1]] = [1] +type AnyToBool = Callable[[Any], bool] + +def wrap[**P, T](f: Callable[P, T]) -> Callable[P, T]: + return f + +def make_callable[T](x: T) -> Callable[[T], bool]: + raise NotImplementedError + +def maybe_make_callable[T](x: T) -> Callable[[T], bool] | None: + raise NotImplementedError + +x1: Callable[[Any], bool] = make_callable(0) +reveal_type(x1) # revealed: (Any, /) -> bool + +x2: AnyToBool = make_callable(0) +reveal_type(x2) # revealed: (Any, /) -> bool + +x3: Callable[[list[Any]], bool] = make_callable([0]) +reveal_type(x3) # revealed: (list[Any], /) -> bool + +x4: Callable[[Any], bool] = wrap(make_callable(0)) +reveal_type(x4) # revealed: (Any, /) -> bool + +x5: Callable[[Any], bool] | None = maybe_make_callable(0) +reveal_type(x5) # revealed: ((Any, /) -> bool) | None ``` -Function parameter annotations: +## Declared type preference sees through subtyping + +Similarly, if the inferred type is a subtype of the declared type, we prefer declared type +assignments that are in non-covariant position: ```py -def b(x: list[Literal[1]]): ... +import builtins +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, Callable, Iterable, Literal, MutableSequence, overload, Sequence -b([1]) +x1: Sequence[Any] = [1, 2, 3] +reveal_type(x1) # revealed: list[int] + +x2: MutableSequence[Any] = [1, 2, 3] +reveal_type(x2) # revealed: list[Any] + +x3: Iterable[Any] = [1, 2, 3] +reveal_type(x3) # revealed: list[int] + +x4: Iterable[Iterable[Any]] = [[1, 2, 3]] +reveal_type(x4) # revealed: list[list[int]] + +x5: list[Iterable[Any]] = [[1, 2, 3]] +reveal_type(x5) # revealed: list[Iterable[Any]] + +x6: Iterable[list[Any]] = [[1, 2, 3]] +reveal_type(x6) # revealed: list[list[Any]] + +x7: Sequence[Any] = [i for i in [1, 2, 3]] +reveal_type(x7) # revealed: list[int] + +x8: MutableSequence[Any] = [i for i in [1, 2, 3]] +reveal_type(x8) # revealed: list[Any] + +x9: Iterable[Any] = [i for i in [1, 2, 3]] +reveal_type(x9) # revealed: list[int] + +x10: Iterable[Iterable[Any]] = [[i] for i in [1, 2, 3]] +reveal_type(x10) # revealed: list[list[int]] + +x11: list[Iterable[Any]] = [[i] for i in [1, 2, 3]] +reveal_type(x11) # revealed: list[Iterable[Any]] + +x12: Iterable[list[Any]] = [[i] for i in [1, 2, 3]] +reveal_type(x12) # revealed: list[list[Any]] + +class X[T]: + value: T + + def __init__(self, value: T): ... + +class A[T](X[T]): ... + +def a[T](value: T) -> A[T]: + return A(value) + +x13: A[object] = A(1) +reveal_type(x13) # revealed: A[object] + +x14: X[object] = A(1) +reveal_type(x14) # revealed: A[object] + +x15: X[object] | None = A(1) +reveal_type(x15) # revealed: A[object] + +x16: X[object] | None = a(1) +reveal_type(x16) # revealed: A[object] + +def f[T](x: T) -> list[list[T]]: + return [[x]] + +x17: Sequence[Sequence[Any]] = f(1) +reveal_type(x17) # revealed: list[list[int]] + +x18: Sequence[list[Any]] = f(1) +reveal_type(x18) # revealed: list[list[Any]] + +x19: dict[int, dict[str, int]] = defaultdict(dict) +reveal_type(x19) # revealed: defaultdict[int, dict[str, int]] + +x20: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[str, list[str]] +x20["key"].append(1) # error: [invalid-argument-type] + +factory: Callable[[], list[str]] = reveal_type(list) # revealed: +reveal_type(factory()) # revealed: list[str] + +optional_factory: Callable[[], list[str]] | None = reveal_type(list) # revealed: + +qualified_factory: Callable[[], list[str]] = reveal_type(builtins.list) # revealed: +reveal_type(qualified_factory()) # revealed: list[str] + +type ListFactory = Callable[[], list[str]] + +alias_factory: ListFactory = reveal_type(list) # revealed: +reveal_type(alias_factory()) # revealed: list[str] + +gradual_factory: Callable[..., Any] = reveal_type(list) # revealed: +dynamic_factory: Callable[[Any], Any] = reveal_type(list) # revealed: + +class Wrapped[T]: + value: T + + def __new__(cls, value: T) -> "Wrapped[tuple[T]]": + raise NotImplementedError + +wrapped_factory: Callable[[str], Wrapped[tuple[str]]] = reveal_type(Wrapped) # revealed: +reveal_type(wrapped_factory("x")) # revealed: Wrapped[tuple[str]] + +class M[T]: + value: T + + def __new__[S](cls, value: S) -> "M[tuple[S]]": + raise NotImplementedError + +m_factory: Callable[[str], M[tuple[str]]] = reveal_type(M) # revealed: +reveal_type(m_factory("x")) # revealed: M[tuple[str]] + +class MultiPath[T]: + value: T + + @overload + def __init__(self, value: T) -> None: ... + @overload + def __init__(self, value: list[T]) -> None: ... + def __init__(self, value: object) -> None: ... + +# fmt: off +multi_path_factory: Callable[[list[int]], MultiPath[int] | MultiPath[list[int]]] = reveal_type(MultiPath) # revealed: +# fmt: on ``` -Bound method parameter annotations: +## Narrow union declared type for generic calls + +When a generic call is checked against a union declared type, the union is narrowed to the first +compatible element: ```py -class C: - def __init__(self, x: list[Literal[1]]): ... - def foo(self, x: list[Literal[1]]): ... +from typing import reveal_type, Any, Callable, TypedDict -C([1]).foo([1]) +def identity[T](x: T) -> T: + return x + +type Target = Any | list[str] | dict[str, str] | Callable[[str], None] | None + +def _(narrow: dict[str, str], target: Target): + target = identity(narrow) + reveal_type(target) # revealed: dict[str, str] + +def _(narrow: list[str], target: Target): + target = identity(narrow) + reveal_type(target) # revealed: list[str] + +def _(narrow: Callable[[str], None], target: Target): + target = identity(narrow) + reveal_type(target) # revealed: (str, /) -> None + +def _(narrow: list[str] | dict[str, str], target: Target): + target = identity(narrow) + reveal_type(target) # revealed: list[str] | dict[str, str] + +class TD(TypedDict): + x: int + +type TargetWithTD = Any | list[TD] | dict[str, TD] | Callable[[TD], None] | None + +def _(target: TargetWithTD): + target = identity([{"x": 1}]) + reveal_type(target) # revealed: list[TD] + +def _(target: TargetWithTD): + target = identity({"x": {"x": 1}}) + reveal_type(target) # revealed: dict[str, TD] + +def _(target: TargetWithTD): + def make_callable[T](x: T) -> Callable[[T], None]: + raise NotImplementedError + + target = identity(make_callable({"x": 1})) + reveal_type(target) # revealed: (TD, /) -> None ``` -Declared variable types: +```py +def identity[T](x: T) -> T: + return x + +def lst[T](x: T) -> list[T]: + return [x] + +def _(i: int): + x1: int | None = i + x2: int | None = identity(i) + x3: int | str | None = identity(i) + reveal_type(x1) # revealed: int + reveal_type(x2) # revealed: int + reveal_type(x3) # revealed: int + + x1: list[int | None] | None = [i] + x2: list[int | None] | None = identity([i]) + x3: list[int | None] | int | None = identity([i]) + reveal_type(x1) # revealed: list[int | None] + reveal_type(x2) # revealed: list[int | None] + reveal_type(x3) # revealed: list[int | None] + + x1: list[int | None] | None = [i] + x2: list[int | None] | None = lst(i) + x3: list[int | None] | int | None = lst(i) + reveal_type(x1) # revealed: list[int | None] + reveal_type(x2) # revealed: list[int | None] + reveal_type(x3) # revealed: list[int | None] + + x1: list | None = [] # error: [missing-type-argument] + x2: list | None = identity([]) # error: [missing-type-argument] + x3: list | int | None = identity([]) # error: [missing-type-argument] + reveal_type(x1) # revealed: list[Unknown] + reveal_type(x2) # revealed: list[Unknown] + reveal_type(x3) # revealed: list[Unknown] + +def f[T](x: list[T]) -> T: + return x[0] + +def _(a: int, b: str, c: int | str): + x1: int = f(lst(a)) + reveal_type(x1) # revealed: int + + x2: int | str = f(lst(a)) + reveal_type(x2) # revealed: int + + x3: int | None = f(lst(a)) + reveal_type(x3) # revealed: int + + x4: str = f(lst(b)) + reveal_type(x4) # revealed: str + + x5: int | str = f(lst(b)) + reveal_type(x5) # revealed: str + + x6: str | int = f(lst(b)) + reveal_type(x6) # revealed: str + + x7: str | None = f(lst(b)) + reveal_type(x7) # revealed: str + + x8: int | str = f(lst(c)) + reveal_type(x8) # revealed: int | str + + x9: int | str = f(lst(c)) + reveal_type(x9) # revealed: int | str + + # TODO: Ideally this would reveal `int | str`. This is a known limitation of our + # call inference solver, and would require an extra inference attempt without type + # context, or with type context of subsets of the union, both of which are impractical + # for performance reasons. + x10: int | str | None = f(lst(c)) + reveal_type(x10) # revealed: int | str | None +``` + +This applies to built-in collection constructors as well, mirroring the behavior of collection +literals: ```py -d: list[Literal[1]] -d = [1] +from typing import Mapping, Sequence + +x1: list[int | str] | list[int | None] = list((1, 2, 3)) +reveal_type(x1) # revealed: list[int | str] + +x2: Sequence[int | str] | Sequence[int | None] = list((1, 2, 3)) +reveal_type(x2) # revealed: list[int] + +x3: list[int] | list[int | None] | list[str | None] = list(("1", "2")) +reveal_type(x3) # revealed: list[str | None] + +x4: dict[str, list[int | None]] | dict[str, list[str | None]] = dict([("a", ["b"])]) +reveal_type(x4) # revealed: dict[str, list[str | None]] + +x5: Mapping[str, list[int | None]] | Mapping[str, list[str | None]] = dict([("a", ["b"])]) +reveal_type(x5) # revealed: dict[str, list[str | None]] + +def _(x6: list[dict[str, list[int] | int] | dict[str, list[int]]]): + x6.append(reveal_type(dict([("b", 1)]))) # revealed: dict[str, list[int] | int] + +type EitherList = list[int | str] | list[int | None] + +x7: EitherList = list((None, None)) +reveal_type(x7) # revealed: list[int | None] + +x8: EitherList = list(("1", "2", "3")) +reveal_type(x8) # revealed: list[int | str] ``` -Declared attribute types: +## Assignability diagnostics ignore declared type + +The type displayed in an invalid assignment diagnostic should account for the type context, e.g., to +avoid literal promotion: ```py -class E: - a: list[Literal[1]] - b: list[Literal[1]] +from typing import Literal, TypedDict -def _(e: E): - e.a = [1] - E.b = [1] +def f[T](x: T) -> list[T]: + return [x] + +# error: [invalid-assignment] "Object of type `list[Literal["hello"] | int]` is not assignable to `list[Literal["hello"] | bool]`" +x1: list[Literal["hello"] | bool] = ["hello", 1] + +class A(TypedDict): + bar: int + +# error: [invalid-assignment] "Object of type `list[A | int]` is not assignable to `list[A | bool]`" +x2: list[A | bool] = [{"bar": 1}, 1] ``` -Function return types: +However, the declared type should be ignored if the specialization is not solvable: ```py -def f() -> list[Literal[1]]: - return [1] +from typing import Any, Callable + +def g[T](x: list[T]) -> T: + return x[0] + +def _(a: int | None): + # error: [invalid-assignment] "Object of type `list[int | None]` is not assignable to `list[str]`" + x1: list[str] = f(a) + + # error: [invalid-assignment] "Object of type `int | None` is not assignable to `str`" + x2: str = g(f(a)) + +def make_callable[T](x: T) -> Callable[[T], bool]: + raise NotImplementedError + +def _(a: int | None): + # error: [invalid-assignment] "Object of type `(int | None, /) -> bool` is not assignable to `(str, /) -> bool`" + x1: Callable[[str], bool] = make_callable(a) ``` ## Instance attributes @@ -611,7 +1394,7 @@ def _(flag: bool): reveal_type(x2) # revealed: list[int | None] ``` -## Peer type context for collection literals +## Collection literals in boolean and conditional expressions When a boolean or conditional expression combines a fresh collection literal with another operand, the other operand can provide type context for the literal: @@ -630,6 +1413,9 @@ def from_or(values: list[str] | None) -> None: for value in reveal_type(values or []): # revealed: list[str] reveal_type(value) # revealed: str +def constructor_fallback(values: list[int] | None) -> None: + reveal_type(values or list()) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown] + def from_and(values: list[str]) -> None: reveal_type(values and []) # revealed: list[str] @@ -655,6 +1441,16 @@ def non_empty_dict_fallback(values: dict[Key, int] | None) -> None: def non_empty_set_fallback(values: set[Key] | None) -> None: reveal_type(values or {"foo"}) # revealed: set[Literal["foo", "bar"]] +class TextContent: ... +class TagContent: ... + +def expects_list_content(content: list[TextContent | TagContent]) -> None: ... +def optional_content(content: list[TextContent | TagContent] | None) -> None: + expects_list_content(content or [TextContent()]) + +def invalid_fallback(content: list[TextContent | TagContent] | None) -> None: + expects_list_content(content or [object()]) # error: [invalid-argument-type] + def preserve_generic[T](value: T) -> T: return value @@ -933,7 +1729,7 @@ reveal_type(x1_sorted) # revealed: list[str] ``` Bare empty `list()`, `set()`, and `dict()` calls also participate in full-scope inference. Calls -through aliases and shadowed names are deliberately not refined. +through aliases and shadowed names are deliberately not refined: ```py list_result = list() @@ -1102,7 +1898,7 @@ reveal_type(x15) # revealed: list[Divergent] ``` Collection-use constraints must converge when multiple collection literals are used in a container -literal. This is a regression test for . +literal. This is a regression test for : ```py from typing import Any diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 53c19ebfa5..93b3ffb0eb 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -173,6 +173,11 @@ PEP 584-style immutable updates preserve the `TypedDict` type when the other ope reveal_type(bob | {"age": 27}) # revealed: Person reveal_type({"age": 27} | bob) # revealed: Person +class SingleField(TypedDict): + x: int + +both_literals: SingleField = {"x": 1} | {"x": 2} + carol_update = Person(name="Carol", age=31) reveal_type(bob | carol_update) # revealed: Person ``` @@ -817,6 +822,85 @@ def _(source: MergeExtraSource): MergeTarget({**source, "ccc": 3}) ``` +## Built-in `dict` constructors + +```py +from collections.abc import Mapping +from typing import Any, TypedDict +from typing_extensions import Never + +class TD(TypedDict): + x: int + +class BadTD(TypedDict): + x: str + +x1 = dict(x=1) +reveal_type(x1) # revealed: dict[str, int] + +x2: TD = dict(x=1) +x3: TD = dict(**x2) +reveal_type(x2) # revealed: TD +reveal_type(x3) # revealed: TD + +x4: TD = dict(x="1") # error: [invalid-argument-type] +reveal_type(x4) # revealed: TD + +def unpack_invalid_typed_dict(src: BadTD) -> TD: + # The fast path should validate TypedDict-shaped unpacks even when they are not assignable to + # the target. That preserves the key-level TypedDict diagnostic instead of falling back to a + # broad `dict[str, str]` assignment error. + # error: [invalid-argument-type] "Invalid argument to key "x" with declared type `int` on TypedDict `TD`: value of type `str`" + return dict(**src) + +def return_any_unpack(src: Any) -> TD: + return dict(**src) + +def takes_td(value: TD) -> None: + pass + +def pass_never_unpack(src: Never) -> None: + takes_td(dict(**src)) + +def pass_unpack(src: TD) -> None: + takes_td(dict(**src)) + +def takes_mapping(value: Mapping[str, object]) -> None: + pass + +def keep_keyword_diagnostics(kwargs: Mapping[str, object]) -> None: + # The TypedDict-aware `dict(...)` fast path should not lose diagnostics from named keywords + # when unsupported `**kwargs` forces it to fall back to ordinary dict inference. + # error: [unresolved-reference] "Name `missing` used when not defined" + # error: [invalid-assignment] + value: TD = dict(x=missing, **kwargs) + takes_mapping(value) + +def takes_dict(value: dict[str, object]) -> None: ... +def takes_kwargs(**kwargs: object) -> None: ... +def convert_typed_dict(data: TD) -> None: + reveal_type(dict(data)) # revealed: dict[str, object] + takes_dict(dict(data)) + takes_kwargs(**dict(data)) + +def return_dict() -> TD: + return dict(x=1) + +def return_unpack(src: TD) -> TD: + return dict(**src) + +def return_invalid_literal() -> TD: + # TODO: ideally, this would only emit the first error, but not `invalid-return-type` (like the + # `return_invalid_dict` case below). + # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" + # error: [invalid-return-type] + return {} + +def return_invalid_dict() -> TD: + # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" + return dict() +``` + ## Mixed positional and unpacked keyword constructors These calls mix a positional `TypedDict` argument with unpacked keyword arguments. They should @@ -964,6 +1048,36 @@ x1: NestedFoo | NestedBar = {"foo": [{"foo": 1, "bar": 1}]} reveal_type(x1) # revealed: NestedFoo | NestedBar ``` +```py +from collections.abc import Iterable, Mapping +from typing import TypedDict + +class TD(TypedDict): + x: int + +IntFloatDict = dict[int, float] +TypedDictOrDict = TD | IntFloatDict +TypedDictOrMapping = TD | Mapping[int, float] + +# The `dict[int, float]` fallback should still win when it is wrapped in an alias. +x1: TypedDictOrDict = {1: 5.2} +x2: TypedDictOrMapping = {1: 5.2} + +# A `Mapping` fallback should only suppress `TypedDict` diagnostics when it accepts the literal. +# error: [missing-typed-dict-key] +# error: [invalid-key] +x3: TypedDictOrMapping = {"y": 5.2} + +# error: [missing-typed-dict-key] +# error: [invalid-key] +x4: TypedDictOrMapping = {1: "bad"} + +def takes_td_or_iterable(value: TD | Iterable[int]) -> None: + pass + +takes_td_or_iterable({42: 42}) +``` + ## Type ignore compatibility issues Users should be able to ignore TypedDict validation errors with `# type: ignore` From 32bb6cfef970cfa5514418dcd09d8f53315eba19 Mon Sep 17 00:00:00 2001 From: Joosboy Date: Fri, 10 Jul 2026 02:11:39 +0530 Subject: [PATCH 003/222] [`flake8-datetimez`] Clarify naming guidance for `datetime.today` (`DTZ002`) (#26658) ## Summary This updates the DTZ002 documentation to clarify the `datetime.today()` naming confusion raised in #23392. - Notes that `datetime.datetime.today()` is named like a date helper, but it returns a local `datetime`. - Keeps the existing guidance to use timezone-aware `datetime.now(...)` when you need an aware timestamp. ## Why this changed The code behavior is already correct; this is a docs-only improvement to make the rule intent easier to understand. ## Test Plan - `cargo dev generate-docs` - `cargo fmt --check --all` - `PATH="$PWD/target/debug:$PATH" uv run --only-group dev --locked python scripts/check_docs_formatted.py` --------- Co-authored-by: Mahadev Annabhimoju <219508079+Joosboy@users.noreply.github.com> Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> --- .../src/rules/flake8_datetimez/rules/call_datetime_today.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs index 4545302ba0..061583826b 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/call_datetime_today.rs @@ -22,6 +22,10 @@ use crate::rules::flake8_datetimez::helpers; /// `datetime.datetime.today()` creates a "naive" object; instead, use /// `datetime.datetime.now(tz=...)` to create a timezone-aware object. /// +/// The name `today()` can be misleading, because it suggests a calendar date, +/// but it actually returns the current local date and time as a `datetime`. +/// That can make intent harder to infer when reading code. +/// /// ## Example /// ```python /// import datetime From a907a12075d1b70743d2cd8848ccd3728b0d84f1 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:45:20 -0400 Subject: [PATCH 004/222] [`ruff`] Add rule to replace `noqa` comments with `ruff:ignore` (`RUF105`) (#26423) Summary -- This is the first in a series of migration rules to help users move from `noqa` comments with rule codes to `ruff:ignore` comments with names. This rule simply replaces `noqa: codes` with `ruff:ignore[codes]` (and the file-level variants), deferring the transformation of codes to names to a follow-up rule. This rule may be useful on its own without the codes -> names rule, assuming we stabilize `ruff:ignore` in the next minor release without stabilizing human-readable names. The rule skips any comment consisting entirely of known `external` selectors, while still emitting diagnostics for totally unknown codes. We also skip file-level `flake8: noqa` directives assuming they are intended to be shared with flake8. I initially wrote a small, standalone implementation in the rule file itself, but it had to be combined with the `RUF100` implementation to leave unused `noqa` codes out of `RUF105` to be cleaned up by `RUF100` instead, as I note in the mdtest for this case. Test Plan -- New mdtests I also expect a huge number of ecosystem results on this PR, possibly so many that the check times out. --------- Co-authored-by: Micha Reiser --- .../resources/mdtest/ruff/noqa-comments.md | 399 ++++++++++++++++++ .../resources/mdtest/suppression/ignore.md | 2 +- crates/ruff_linter/src/checkers/noqa.rs | 96 +++-- crates/ruff_linter/src/codes.rs | 1 + crates/ruff_linter/src/noqa.rs | 129 +++++- crates/ruff_linter/src/registry.rs | 4 +- .../ruff_linter/src/rules/ruff/rules/mod.rs | 2 + .../src/rules/ruff/rules/noqa_comments.rs | 201 +++++++++ crates/ruff_linter/src/suppression.rs | 41 +- python/ruff-ecosystem/ruff_ecosystem/check.py | 7 +- ruff.schema.json | 2 + 11 files changed, 817 insertions(+), 67 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/ruff/noqa-comments.md create mode 100644 crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs diff --git a/crates/ruff_linter/resources/mdtest/ruff/noqa-comments.md b/crates/ruff_linter/resources/mdtest/ruff/noqa-comments.md new file mode 100644 index 0000000000..c84e2a8568 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/ruff/noqa-comments.md @@ -0,0 +1,399 @@ +# `noqa-comments` (`RUF105`) + +```toml +[lint] +preview = true +select = ["noqa-comments", "F401", "F402", "F403"] +``` + +## File-level comments + +### Single code + +```py +# snapshot: noqa-comments +# ruff: noqa: F401 +import math +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa: F401 + | ^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead + | +1 | # snapshot: noqa-comments + - # ruff: noqa: F401 +2 + # ruff:file-ignore[F401] +3 | import math + | +``` + +### Multiple codes + +```py +# snapshot: noqa-comments +# ruff: noqa: F401, F402, F403 +import math +import os +from module import * +for os in []: + pass +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa: F401, F402, F403 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead + | +1 | # snapshot: noqa-comments + - # ruff: noqa: F401, F402, F403 +2 + # ruff:file-ignore[F401, F402, F403] +3 | import math + | +``` + +### Multiple codes followed by a reason + +```py +# snapshot: noqa-comments +# ruff: noqa: F401, F402, F403 for some reason +import math +import os +from module import * +for os in []: + pass +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa: F401, F402, F403 for some reason + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead + | +1 | # snapshot: noqa-comments + - # ruff: noqa: F401, F402, F403 for some reason +2 + # ruff:file-ignore[F401, F402, F403] for some reason +3 | import math + | +``` + +### Multiple codes followed by a nested (pragma) comment + +```py +# snapshot: noqa-comments +# ruff: noqa: F401, F402, F403 # fmt:skip +import math +import os +from module import * +for os in []: + pass +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa: F401, F402, F403 # fmt:skip + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead + | +1 | # snapshot: noqa-comments + - # ruff: noqa: F401, F402, F403 # fmt:skip +2 + # ruff:file-ignore[F401, F402, F403] # fmt:skip +3 | import math + | +``` + +### Unknown codes still receive a diagnostic + +In case the unknown code is a typo rather than an intentionally external code, we emit both +`invalid-rule-code` and `noqa-comments`: + +```toml +[lint] +preview = true +select = ["noqa-comments", "unused-noqa", "invalid-rule-code", "F401"] +``` + +```py +# error: [invalid-rule-code] +# snapshot: noqa-comments +import math # noqa: F401, UNK001 +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:3:14 + | +3 | import math # noqa: F401, UNK001 + | ^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:ignore` instead + | +2 | # snapshot: noqa-comments + - import math # noqa: F401, UNK001 +3 + import math # ruff:ignore[F401, UNK001] + | +``` + +### External codes + +```toml +[lint] +preview = true +select = ["noqa-comments", "unused-noqa", "invalid-rule-code", "F401"] +external = ["EXT"] +``` + +If all of the codes are marked `external`, no diagnostic is emitted: + +```py +# error: [unused-import] +import math # noqa: EXT001, EXT002 +``` + +However, if only some of the codes are `external`, a diagnostic is emitted without an autofix. In +this case, the external codes likely need to remain in a `noqa` comment, while the codes known by +Ruff could potentially move into a `ruff:ignore` comment. + +```py +# snapshot: noqa-comments +import math # noqa: F401, EXT001 +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:4:14 + | +4 | import math # noqa: F401, EXT001 + | ^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:ignore` instead +``` + +### Any unmatched code disables the fix + +This leaves an unused `noqa` comment to be cleaned up by `RUF100` instead, which can be especially +important in the case of a standalone `noqa` comment, which has no effect (in almost all cases), but +could become an effectful own-line `ruff:ignore` comment if `RUF105` applied. + +```py +# snapshot: noqa-comments +# ruff: noqa: F401, F402 +import math +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa: F401, F402 + | ^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead +``` + +### Flake8 comments are ignored + +```py +# flake8: noqa: F401 +import math +``` + +## Inline comments + +### Basic + +```py +# snapshot: noqa-comments +import math # noqa: F401 +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:2:14 + | +2 | import math # noqa: F401 + | ^^^^^^^^^^^^ + | +help: Use `ruff:ignore` instead + | +1 | # snapshot: noqa-comments + - import math # noqa: F401 +2 + import math # ruff:ignore[F401] + | +``` + +### One unmatched code + +Just like the file-level version above, this disables the autofix but not the rule. + +```py +# snapshot: noqa-comments +import os # noqa: F401, F402 +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:2:12 + | +2 | import os # noqa: F401, F402 + | ^^^^^^^^^^^^^^^^^^ + | +help: Use `ruff:ignore` instead +``` + +### Nested pragma comment before the directive + +```py +# snapshot: noqa-comments +import math # fmt:skip # noqa: F401 +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:2:25 + | +2 | import math # fmt:skip # noqa: F401 + | ^^^^^^^^^^^^ + | +help: Use `ruff:ignore` instead + | +1 | # snapshot: noqa-comments + - import math # fmt:skip # noqa: F401 +2 + import math # fmt:skip # ruff:ignore[F401] + | +``` + +## Blanket comments + +### Inline + +For inline comments, `RUF105` flags blanket comments and offers a fix containing the codes that are +actually suppressed: + +```py +# snapshot: noqa-comments +import math # noqa +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:2:14 + | +2 | import math # noqa + | ^^^^^^ + | +help: Use `ruff:ignore` instead + | +1 | # snapshot: noqa-comments + - import math # noqa +2 + import math # ruff:ignore[F401] +3 | # snapshot: noqa-comments + | +``` + +Multiple diagnostics on the same line don't cause duplicate codes in the final comment: + +```py +# snapshot: noqa-comments +import foo, bar # noqa +``` + +```snapshot +error[RUF105]: `noqa` comment used instead of `ruff:ignore` + --> src/mdtest_snippet.py:4:18 + | +4 | import foo, bar # noqa + | ^^^^^^ + | +help: Use `ruff:ignore` instead + | +3 | # snapshot: noqa-comments + - import foo, bar # noqa +4 + import foo, bar # ruff:ignore[F401] + | +``` + +### File-level + +For file-level comments, only a diagnostic is emitted, without a fix: + +```py +# snapshot: noqa-comments +# ruff: noqa +import math +``` + +```snapshot +error[RUF105]: `ruff: noqa` comment used instead of `ruff:file-ignore` + --> src/mdtest_snippet.py:2:1 + | +2 | # ruff: noqa + | ^^^^^^^^^^^^ + | +help: Use `ruff:file-ignore` instead +``` + +## Inline self-suppression + +```toml +[lint] +preview = true +select = ["noqa-comments", "unused-noqa", "F401"] +``` + +It should be possible to suppress `RUF105` with a `noqa` comment: + +```py +value = 1 # noqa: RUF105 +``` + +But a suppression for `RUF100` should not prevent the rule from firing: + +```py +# error: [noqa-comments] +import math # noqa: RUF100, F401 +``` + +## Suppression with `ruff:ignore` + +```toml +[lint] +preview = true +select = ["noqa-comments", "unused-noqa", "F401"] +``` + +### Inline suppression + +```py +import math # noqa: F401 # ruff:ignore[RUF105] +``` + +### Standalone suppression + +```py +# ruff:ignore[RUF105] +# ruff: noqa: F401 +import math +``` + +### File-level suppression + +```py +# ruff:file-ignore[RUF105] +# ruff: noqa: F401 +import math +``` diff --git a/crates/ruff_linter/resources/mdtest/suppression/ignore.md b/crates/ruff_linter/resources/mdtest/suppression/ignore.md index 5c7b5f0d01..9eed7d3559 100644 --- a/crates/ruff_linter/resources/mdtest/suppression/ignore.md +++ b/crates/ruff_linter/resources/mdtest/suppression/ignore.md @@ -668,7 +668,7 @@ import foo ```toml [lint] preview = true -select = ["F401", "RUF10", "FIX002"] +select = ["F401", "RUF100", "FIX002"] ``` Nested suppression comments on a comment-only line are treated as trailing on the comment itself and diff --git a/crates/ruff_linter/src/checkers/noqa.rs b/crates/ruff_linter/src/checkers/noqa.rs index fbd82a6ed8..afd190c66c 100644 --- a/crates/ruff_linter/src/checkers/noqa.rs +++ b/crates/ruff_linter/src/checkers/noqa.rs @@ -117,15 +117,14 @@ pub(crate) fn check_noqa( } } - // Diagnostics for unused/invalid range suppressions - suppressions.check_suppressions(context, locator); - - // Enforce that the noqa directive was actually used (RUF100), unless RUF100 was itself - // suppressed. - if context.is_rule_enabled(Rule::UnusedNOQA) + // Only migrate directives that don't require RUF100 cleanup first. + let check_unused_noqa = context.is_rule_enabled(Rule::UnusedNOQA) && analyze_directives - && !exemption.includes(Rule::UnusedNOQA) - { + && !exemption.includes(Rule::UnusedNOQA); + let check_noqa_comment = + context.is_rule_enabled(Rule::NoqaComments) && !exemption.enumerates(Rule::NoqaComments); + + if check_unused_noqa || check_noqa_comment { let directives = noqa_directives .lines() .iter() @@ -138,36 +137,59 @@ pub(crate) fn check_noqa( ); for (directive, matches, is_file_level) in directives { match directive { - Directive::All(directive) => { - if matches.is_empty() { - let edit = delete_comment(directive.range(), locator); + Directive::All(all) => { + if check_unused_noqa && matches.is_empty() { + let edit = delete_comment(all.range(), locator); let mut diagnostic = context.report_diagnostic( UnusedNOQA { codes: None, kind: ruff::rules::UnusedNOQAKind::Noqa, }, - directive.range(), + all.range(), ); diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); diagnostic.set_fix(Fix::safe_edit(edit)); + } else if check_noqa_comment { + ruff::rules::noqa_comments( + context, + locator, + is_file_level, + matches.is_empty(), + directive, + matches, + suppressions, + ); } } - Directive::Codes(directive) => { + Directive::Codes(codes) => { let mut disabled_codes = vec![]; let mut duplicated_codes = vec![]; - let mut unknown_codes = vec![]; let mut unmatched_codes = vec![]; let mut valid_codes = vec![]; let mut seen_codes = FxHashSet::default(); let mut self_ignore = false; - for original_code in directive.iter().map(Code::as_str) { + let mut suppress_noqa_comment = false; + for original_code in codes.iter().map(Code::as_str) { let code = get_redirect_target(original_code).unwrap_or(original_code); - if Rule::UnusedNOQA.noqa_code() == code { - self_ignore = true; - break; - } - if seen_codes.insert(original_code) { + if Rule::UnusedNOQA.noqa_code() == code { + self_ignore = true; + if context.is_rule_enabled(Rule::UnusedNOQA) { + valid_codes.push(original_code); + } else { + disabled_codes.push(original_code); + } + continue; + } + + if context.is_rule_enabled(Rule::NoqaComments) + && Rule::NoqaComments.noqa_code() == code + { + suppress_noqa_comment = true; + valid_codes.push(original_code); + continue; + } + let is_code_used = if is_file_level { context.iter().any(|diag| { diag.secondary_code().is_some_and(|noqa| *noqa == code) @@ -187,26 +209,21 @@ pub(crate) fn check_noqa( } else { disabled_codes.push(original_code); } - } else { - unknown_codes.push(original_code); } } else { duplicated_codes.push(original_code); } } - if self_ignore { - continue; - } - - if !(disabled_codes.is_empty() + let has_unused_codes = !(disabled_codes.is_empty() && duplicated_codes.is_empty() - && unmatched_codes.is_empty()) - { + && unmatched_codes.is_empty()); + + if check_unused_noqa && !self_ignore && has_unused_codes { let edit = if valid_codes.is_empty() { - delete_comment(directive.range(), locator) + delete_comment(codes.range(), locator) } else { - let original_text = locator.slice(directive.range()); + let original_text = locator.slice(codes.range()); let prefix = if is_file_level { if original_text.contains("flake8") { "# flake8: noqa: " @@ -218,7 +235,7 @@ pub(crate) fn check_noqa( }; Edit::range_replacement( format!("{}{}", prefix, valid_codes.join(", ")), - directive.range(), + codes.range(), ) }; let mut diagnostic = context.report_diagnostic( @@ -230,16 +247,29 @@ pub(crate) fn check_noqa( }), kind: ruff::rules::UnusedNOQAKind::Noqa, }, - directive.range(), + codes.range(), ); diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Unnecessary); diagnostic.set_fix(Fix::safe_edit(edit)); + } else if check_noqa_comment && !suppress_noqa_comment { + ruff::rules::noqa_comments( + context, + locator, + is_file_level, + has_unused_codes, + directive, + matches, + suppressions, + ); } } } } } + // Diagnostics for unused/invalid range suppressions + suppressions.check_suppressions(context, locator); + if context.is_rule_enabled(Rule::RedirectedNOQA) && !exemption.includes(Rule::RedirectedNOQA) { ruff::rules::redirected_noqa(context, &noqa_directives); ruff::rules::redirected_file_noqa(context, &file_noqa_directives); diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 4a6490eaa3..a51f84a442 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1089,6 +1089,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "102") => rules::ruff::rules::InvalidRuleCode, (Ruff, "103") => rules::ruff::rules::InvalidSuppressionComment, (Ruff, "104") => rules::ruff::rules::UnmatchedSuppressionComment, + (Ruff, "105") => rules::ruff::rules::NoqaComments, (Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml, #[cfg(any(feature = "test-rules", test))] diff --git a/crates/ruff_linter/src/noqa.rs b/crates/ruff_linter/src/noqa.rs index eb645456d8..f7fe29b14b 100644 --- a/crates/ruff_linter/src/noqa.rs +++ b/crates/ruff_linter/src/noqa.rs @@ -65,6 +65,15 @@ pub(crate) enum Directive<'a> { Codes(Codes<'a>), } +impl Ranged for Directive<'_> { + fn range(&self) -> TextRange { + match self { + Directive::All(all) => all.range(), + Directive::Codes(codes) => codes.range(), + } + } +} + #[derive(Debug)] pub(crate) struct All { range: TextRange, @@ -122,6 +131,10 @@ impl Codes<'_> { self.iter() .any(|code| *needle == get_redirect_target(code.as_str()).unwrap_or(code.as_str())) } + + pub(crate) fn len(&self) -> usize { + self.codes.len() + } } impl Ranged for Codes<'_> { @@ -1355,7 +1368,7 @@ mod tests { use crate::rules::pycodestyle::rules::{AmbiguousVariableName, UselessSemicolon}; use crate::rules::pyflakes::rules::UnusedVariable; use crate::rules::pyupgrade::rules::PrintfStringFormatting; - use crate::settings::{LinterSettings, flags, types::PreviewMode}; + use crate::settings::{LinterSettings, flags}; use crate::source_kind::SourceKind; use crate::suppression::Suppressions; use crate::test::{print_messages, test_contents}; @@ -1417,18 +1430,8 @@ mod tests { fn add_suppressions_in( source: &str, suppression_kind: SuppressionKind, - preview: PreviewMode, + settings: &LinterSettings, ) -> Result { - let settings = LinterSettings { - preview, - ..LinterSettings::for_rules([ - Rule::MissingTypeFunctionArgument, - Rule::MissingReturnTypeUndocumentedPublicFunction, - Rule::UnsortedImports, - Rule::UnusedFunctionArgument, - Rule::UndocumentedPublicFunction, - ]) - }; let path = Path::new(""); let source_map = SourceMap::default(); let source_kind = SourceKind::from_source_code( @@ -1437,7 +1440,7 @@ mod tests { )? .ok_or_else(|| anyhow!("test file should be Python"))?; - let (count, fixed) = add_suppressions(path, &source_kind, &settings, suppression_kind); + let (count, fixed) = add_suppressions(path, &source_kind, settings, suppression_kind); let plural = if count == 1 { "" } else { "s" }; let mut output = String::new(); writeln!( @@ -1447,7 +1450,7 @@ mod tests { let source_kind = source_kind.updated(fixed, &source_map); let (second_count, fixed) = - add_suppressions(path, &source_kind, &settings, suppression_kind); + add_suppressions(path, &source_kind, settings, suppression_kind); if second_count > 0 { writeln!( output, @@ -1456,7 +1459,7 @@ mod tests { } let source_kind = source_kind.updated(fixed, &source_map); - let (diagnostics, _) = test_contents(&source_kind, path, &settings); + let (diagnostics, _) = test_contents(&source_kind, path, settings); if !diagnostics.is_empty() { writeln!( output, @@ -3054,7 +3057,12 @@ mod tests { pass "#, SuppressionKind::Noqa, - PreviewMode::Disabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + Rule::UnusedFunctionArgument, + Rule::UndocumentedPublicFunction, + ]), )?, @" Added 1 suppression @@ -3079,7 +3087,13 @@ mod tests { pass "#, SuppressionKind::Noqa, - PreviewMode::Enabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + Rule::UnusedFunctionArgument, + Rule::UndocumentedPublicFunction, + ]) + .with_preview_mode(), )?, @" Added 1 suppression @@ -3104,7 +3118,13 @@ mod tests { pass "#, SuppressionKind::Ignore, - PreviewMode::Enabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + Rule::UnusedFunctionArgument, + Rule::UndocumentedPublicFunction, + ]) + .with_preview_mode(), )?, @" Added 1 suppression @@ -3120,6 +3140,56 @@ mod tests { Ok(()) } + #[test] + fn add_noqa_ruf105() -> Result<()> { + let settings = + LinterSettings::for_rules([Rule::NoqaComments, Rule::UnusedImport]).with_preview_mode(); + + assert_snapshot!( + add_suppressions_in( + "import math # noqa: F401", + SuppressionKind::Noqa, + &settings, + )?, + @" + Added 1 suppression + + ## Fixed source + + ```py + import math # noqa: F401, RUF105 + + ``` + " + ); + Ok(()) + } + + #[test] + fn add_ignore_ruf105() -> Result<()> { + let settings = + LinterSettings::for_rules([Rule::NoqaComments, Rule::UnusedImport]).with_preview_mode(); + + assert_snapshot!( + add_suppressions_in( + "import math # noqa: F401", + SuppressionKind::Ignore, + &settings, + )?, + @" + Added 1 suppression + + ## Fixed source + + ```py + import math # noqa: F401 # ruff:ignore[noqa-comments] + + ``` + " + ); + Ok(()) + } + #[test] fn add_ignore_to_existing_ignore() -> Result<()> { assert_snapshot!( @@ -3129,7 +3199,13 @@ mod tests { pass "#, SuppressionKind::Ignore, - PreviewMode::Enabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + Rule::UnusedFunctionArgument, + Rule::UndocumentedPublicFunction, + ]) + .with_preview_mode(), )?, @" Added 1 suppression @@ -3154,7 +3230,12 @@ mod tests { pass "#, SuppressionKind::Ignore, - PreviewMode::Enabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + Rule::UndocumentedPublicFunction, + ]) + .with_preview_mode(), )?, @" Added 1 suppression @@ -3180,7 +3261,7 @@ mod tests { import a "#, SuppressionKind::Ignore, - PreviewMode::Enabled, + &LinterSettings::for_rules([Rule::UnsortedImports]).with_preview_mode(), )?, @" Added 1 suppression @@ -3208,7 +3289,11 @@ mod tests { return x "#, SuppressionKind::Ignore, - PreviewMode::Enabled, + &LinterSettings::for_rules([ + Rule::MissingTypeFunctionArgument, + Rule::MissingReturnTypeUndocumentedPublicFunction, + ]) + .with_preview_mode(), )?, @r#" Added 1 suppression diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 766eee9852..5afccdda5a 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -250,7 +250,9 @@ impl Rule { pub const fn lint_source(&self) -> LintSource { match self { Rule::InvalidPyprojectToml => LintSource::PyprojectToml, - Rule::BlanketNOQA | Rule::RedirectedNOQA | Rule::UnusedNOQA => LintSource::Noqa, + Rule::BlanketNOQA | Rule::NoqaComments | Rule::RedirectedNOQA | Rule::UnusedNOQA => { + LintSource::Noqa + } Rule::BidirectionalUnicode | Rule::BlankLineWithWhitespace | Rule::DocLineTooLong diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 09e55de4f7..e470647b0f 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -40,6 +40,7 @@ pub(crate) use never_union::*; pub(crate) use non_empty_init_module::*; pub(crate) use non_octal_permissions::*; pub(crate) use none_not_at_end_of_union::*; +pub(crate) use noqa_comments::*; pub(crate) use os_path_commonprefix::*; pub(crate) use parenthesize_chained_operators::*; pub(crate) use post_init_default::*; @@ -118,6 +119,7 @@ mod never_union; mod non_empty_init_module; mod non_octal_permissions; mod none_not_at_end_of_union; +mod noqa_comments; mod os_path_commonprefix; mod parenthesize_chained_operators; mod post_init_default; diff --git a/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs new file mode 100644 index 0000000000..c255d5ea53 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs @@ -0,0 +1,201 @@ +use itertools::Itertools; + +use ruff_diagnostics::{Edit, Fix}; +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_text_size::{Ranged, TextRange}; + +use crate::{ + FixAvailability, Locator, Violation, checkers::ast::LintContext, codes::Rule, noqa::Directive, + suppression::Suppressions, +}; + +/// ## What it does +/// +/// Checks for the use of `noqa` comments instead of Ruff-specific `ruff:ignore` comments. +/// +/// ## Why is this bad? +/// +/// `ruff:ignore` comments allow the use of rule names instead of codes and can be used in more +/// places than `noqa` comments. +/// +/// Note that this is an opinionated, stylistic rule. `noqa` comments may be needed for backwards +/// compatibility with other tools. You should also feel free to disable this rule if you simply +/// prefer `noqa` comments. +/// +/// ## Example +/// +/// ```python +/// import os # noqa: F401 +/// ``` +/// +/// Use instead: +/// ```python +/// import os # ruff:ignore[F401] +/// ``` +/// +/// Or if you prefer the own-line form: +/// +/// ```python +/// # ruff:ignore[unused-import] +/// import os +/// ``` +/// +/// ## Options +/// +/// This rule will flag `noqa` comments containing rule codes that are unknown to Ruff, even if they +/// are valid for other tools. You can tell Ruff to ignore such codes by configuring the list of +/// known "external" rule codes with the following option: +/// +/// - `lint.external` +/// +/// Ruff will still emit a diagnostic without a fix if `external` and known codes are present in the +/// same `noqa` comment, assuming that only the `external` codes need to remain in the `noqa` +/// comment. +/// +/// ## See also +/// +/// This rule avoids offering a fix if any of the rule codes in a `noqa` comment are unused. See +/// `unused-noqa` for a rule that will remove these and allow the remaining codes to be moved into a +/// `ruff:ignore` comment. +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +pub(crate) struct NoqaComments { + file_level: bool, +} + +impl Violation for NoqaComments { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + if !self.file_level { + "`noqa` comment used instead of `ruff:ignore`".to_string() + } else { + "`ruff: noqa` comment used instead of `ruff:file-ignore`".to_string() + } + } + + fn fix_title(&self) -> Option { + Some(if self.file_level { + "Use `ruff:file-ignore` instead".to_string() + } else { + "Use `ruff:ignore` instead".to_string() + }) + } +} + +/// RUF105 +pub(crate) fn noqa_comments( + context: &LintContext, + locator: &Locator, + file_level: bool, + has_unused_codes: bool, + directive: &Directive, + matches: &[Rule], + suppressions: &Suppressions, +) { + let codes = Codes::from_directive(directive, matches); + + let range = codes.range; + + if file_level && locator.slice(range).contains("flake8") { + return; + } + + let has_external_codes = if let CodesKind::Codes(codes) = codes.kind { + let external_codes = codes + .iter() + .filter(|code| { + context + .settings() + .external + .iter() + .any(|prefix| code.as_str().starts_with(prefix)) + }) + .count(); + + // Avoid a diagnostic if all of the codes are external. + if external_codes == codes.len() { + return; + } + + external_codes > 0 + } else { + false + }; + + if suppressions.check_rule(Rule::NoqaComments, range, None) { + return; + } + + let mut diagnostic = context.report_diagnostic(NoqaComments { file_level }, range); + + // If some codes are external, return without a fix. + if has_external_codes { + return; + } + + // Similarly, return without a fix if any unused codes are present. This avoids potentially + // activating an unused `noqa` comment on its own line like: + // + // ```py + // # noqa: F401 + // import math + // ``` + // + // by converting it to a valid `ruff:ignore` comment. + if has_unused_codes { + return; + } + + let edit = Edit::range_replacement( + format!( + "# ruff:{action}[{codes}]", + action = if file_level { "file-ignore" } else { "ignore" }, + ), + codes.range, + ); + diagnostic.set_fix(Fix::safe_edit(edit)); +} + +struct Codes<'a> { + kind: CodesKind<'a>, + range: TextRange, +} + +enum CodesKind<'a> { + Codes(&'a crate::noqa::Codes<'a>), + Rules(&'a [Rule]), +} + +impl<'a> Codes<'a> { + fn from_directive(directive: &'a Directive, matches: &'a [Rule]) -> Self { + let kind = match directive { + Directive::All(_) => CodesKind::Rules(matches), + Directive::Codes(codes) => CodesKind::Codes(codes), + }; + + Self { + kind, + range: directive.range(), + } + } +} + +impl std::fmt::Display for Codes<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.kind { + CodesKind::Codes(codes) => write!(f, "{}", codes.iter().join(", ")), + CodesKind::Rules(rules) => write!( + f, + "{}", + rules + .iter() + .map(Rule::noqa_code) + .sorted() + .dedup() + .join(", ") + ), + } + } +} diff --git a/crates/ruff_linter/src/suppression.rs b/crates/ruff_linter/src/suppression.rs index d6f2d9059c..85c2d7b0f1 100644 --- a/crates/ruff_linter/src/suppression.rs +++ b/crates/ruff_linter/src/suppression.rs @@ -345,22 +345,49 @@ impl Suppressions { return false; }; + self.check_suppression( + diagnostic.secondary_code(), + diagnostic.name(), + range, + diagnostic.parent(), + ) + } + + /// Check whether a rule is suppressed at the given range and mark the suppression as used. + pub(crate) fn check_rule( + &self, + rule: Rule, + range: TextRange, + parent: Option, + ) -> bool { + self.check_suppression(Some(&rule.noqa_code()), rule.name().as_str(), range, parent) + } + + /// Check whether the given rule code or name corresponds to a valid suppression comment at + /// `range` itself or the `parent` offset. + fn check_suppression( + &self, + code: Option<&C>, + name: &str, + range: TextRange, + parent: Option, + ) -> bool + where + C: for<'a> PartialEq<&'a str>, + { for suppression in &self.valid { let suppression_code = get_redirect_target(suppression.code.as_str()).unwrap_or(suppression.code.as_str()); - let code_matches = diagnostic - .secondary_code() - .is_some_and(|code| *code == suppression_code); - - let name_matches = is_human_readable_names_enabled(self.preview) - && diagnostic.name() == suppression_code; + let code_matches = code.is_some_and(|code| code == &suppression_code); + let name_matches = + is_human_readable_names_enabled(self.preview) && name == suppression_code; if !code_matches && !name_matches { continue; } - if suppression.applies_to_diagnostic(range, diagnostic.parent()) { + if suppression.applies_to_diagnostic(range, parent) { suppression.used.set(true); return true; } diff --git a/python/ruff-ecosystem/ruff_ecosystem/check.py b/python/ruff-ecosystem/ruff_ecosystem/check.py index 0775e7bb61..78fae8f162 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/check.py +++ b/python/ruff-ecosystem/ruff_ecosystem/check.py @@ -565,9 +565,10 @@ async def ruff_check( if proc.returncode != 0: raise ToolError(err.decode("utf8")) - # Strip summary lines so the diff is only diagnostic lines - return [ + # Strip summary lines so the diff is only diagnostic lines. Also sort the lines so that + # reordering isn't presented as an addition/deletion pair. + return sorted( line for line in result.decode("utf8").splitlines() if not CHECK_SUMMARY_LINE_RE.match(line) - ] + ) diff --git a/ruff.schema.json b/ruff.schema.json index 3c844e66bb..2507c70894 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4265,6 +4265,7 @@ "RUF102", "RUF103", "RUF104", + "RUF105", "RUF2", "RUF20", "RUF200", @@ -5037,6 +5038,7 @@ "none-not-at-end-of-union", "nonlocal-and-global", "nonlocal-without-binding", + "noqa-comments", "not-in-test", "not-is-test", "numeric-literal-too-long", From fb15b89a6b0ffee0eceb1913e403f6527b128eda Mon Sep 17 00:00:00 2001 From: Dmitriy Kaufman Date: Fri, 10 Jul 2026 01:57:34 +0300 Subject: [PATCH 005/222] [`pylint`] Ignore mutable type updates in `redefined-loop-name` (`PLW2901`) (#25733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses #24986 The rule has been updated to ignore cases where a mutable object is updated in-place. This makes the behavior consistent with how method‑based updates are already ignored. ## Test Plan Added new test cases --- .../mdtest/pylint/redefined-loop-name.md | 86 +++++++++++++++++++ .../rules/pylint/rules/redefined_loop_name.rs | 19 +++- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/ruff_linter/resources/mdtest/pylint/redefined-loop-name.md diff --git a/crates/ruff_linter/resources/mdtest/pylint/redefined-loop-name.md b/crates/ruff_linter/resources/mdtest/pylint/redefined-loop-name.md new file mode 100644 index 0000000000..6b360389ce --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pylint/redefined-loop-name.md @@ -0,0 +1,86 @@ +# `redefined-loop-name` (`PLW2901`) + +```toml +[lint] +select = ["PLW2901"] +``` + +## Augmented assignment + +Ignore in-place update of a mutable type. + +```py +for i in []: + i += [1] + +for i in []: + i = [1] # snapshot: redefined-loop-name + +for i in []: + i |= {"a": 1} + +for i in []: + i = {"b": 2} # snapshot: redefined-loop-name + +for i in []: + i |= {1} + +for i in []: + i &= {1} + +for i in []: + i ^= {1} + +for i in []: + i -= {1} + +for i in []: + i = {1} # snapshot: redefined-loop-name + +for i in []: + i += (1,) # snapshot: redefined-loop-name + +for i in []: + i += "a" # snapshot: redefined-loop-name +``` + +```snapshot +error[PLW2901]: `for` loop variable `i` overwritten by assignment target + --> src/mdtest_snippet.py:5:5 + | +5 | i = [1] # snapshot: redefined-loop-name + | ^ + | + + +error[PLW2901]: `for` loop variable `i` overwritten by assignment target + --> src/mdtest_snippet.py:11:5 + | +11 | i = {"b": 2} # snapshot: redefined-loop-name + | ^ + | + + +error[PLW2901]: `for` loop variable `i` overwritten by assignment target + --> src/mdtest_snippet.py:26:5 + | +26 | i = {1} # snapshot: redefined-loop-name + | ^ + | + + +error[PLW2901]: `for` loop variable `i` overwritten by assignment target + --> src/mdtest_snippet.py:29:5 + | +29 | i += (1,) # snapshot: redefined-loop-name + | ^ + | + + +error[PLW2901]: `for` loop variable `i` overwritten by assignment target + --> src/mdtest_snippet.py:32:5 + | +32 | i += "a" # snapshot: redefined-loop-name + | ^ + | +``` diff --git a/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs b/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs index b77f07f2a2..316412b733 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/redefined_loop_name.rs @@ -7,6 +7,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::comparable::ComparableExpr; use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt}; use ruff_python_semantic::SemanticModel; +use ruff_python_semantic::analyze::typing::is_mutable_expr; use ruff_text_size::Ranged; use crate::Violation; @@ -193,7 +194,23 @@ impl<'b> StatementVisitor<'b> for InnerForWithAssignTargetsVisitor<'_, 'b> { ), ); } - Stmt::AugAssign(ast::StmtAugAssign { target, .. }) => { + Stmt::AugAssign(ast::StmtAugAssign { + target, value, op, .. + }) => { + // Check for in-place update of mutable type + if is_mutable_expr(value, self.context) + && matches!( + op, + ast::Operator::Add + | ast::Operator::Sub + | ast::Operator::BitOr + | ast::Operator::BitAnd + | ast::Operator::BitXor + ) + { + return; + } + self.assignment_targets.extend( assignment_targets_from_expr(target, self.dummy_variable_rgx).map(|expr| { ExprWithInnerBindingKind { From 04d831a7202b1274a1e1973c4d8443d754a51faa Mon Sep 17 00:00:00 2001 From: Joosboy Date: Fri, 10 Jul 2026 04:45:11 +0530 Subject: [PATCH 006/222] [`ruff`] Clarify intentional async contexts for `unused-async` (`RUF029`) (#26641) ## Summary This updates the RUF029 documentation to mention that an async function can sometimes be intentional even when it does not directly await anything. One example is an async test or callback that needs to run inside an async execution context, such as code that uses a ContextVar. The docs now point users toward either adding a real await when appropriate or disabling the rule for that specific function when the async boundary is intentional. Refs #23196. ## Test Plan - `cargo dev generate-docs` - `cargo fmt --check --all` - `PATH="$PWD/target/debug:$PATH" uv run --only-group dev --locked python scripts/check_docs_formatted.py` - `PATH="$PWD/target/debug:$PATH" uv run --only-group dev --locked prek run --files crates/ruff_linter/src/rules/ruff/rules/unused_async.rs` - `cargo test -p ruff_linter rules::ruff::tests::rules::rule_unusedasync_path_new_ruf029_py_expects` Co-authored-by: Mahadev Annabhimoju <219508079+Joosboy@users.noreply.github.com> --- crates/ruff_linter/src/rules/ruff/rules/unused_async.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs b/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs index 542a42055e..2b898822de 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/unused_async.rs @@ -18,7 +18,11 @@ use crate::rules::fastapi::rules::is_fastapi_route; /// ## Why is this bad? /// Declaring a function `async` when it's not is usually a mistake, and will artificially limit the /// contexts where that function may be called. In some cases, labeling a function `async` is -/// semantically meaningful (e.g. with the trio library). +/// semantically meaningful. For example, an async test or callback may need to run in an async +/// execution context, even if it only uses a `ContextVar`. +/// +/// If the async context is intentional, add an actual await expression, such as +/// `await asyncio.sleep(0)`, or disable this rule for the function. /// /// ## Example /// ```python From 5233e4ff88d6c3250754deb5f8a499ac73af4b1e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 9 Jul 2026 20:29:43 -0400 Subject: [PATCH 007/222] [ty] Support cached properties in protocols (#26681) ## Summary Prior to this change, we treated a protocol member produced by a descriptor decorator as the descriptor object itself. This caused a class using `cached_property` to fail an otherwise matching protocol because we compared the instance read type, `str`, with `cached_property[str]`: ```python from functools import cached_property from typing import Protocol class HasName(Protocol): @cached_property def name(self) -> str: ... class WithName: @cached_property def name(self) -> str: return "example" has_name: HasName = WithName() ``` We now resolve descriptor-decorated protocol methods through their instance interface. The result of `__get__` determines the readable member type, and the value parameter of a single ordinary `__set__` signature determines the writable member type. This lets `cached_property` implementations satisfy the protocol and preserves the expected type when reading from or assigning through a protocol instance. This PR is focused on the simple case and leaves others as TODOs (with our existing, conservative behavior). Closes https://github.com/astral-sh/ty/issues/3953. --- .../resources/mdtest/protocols.md | 239 ++++++++++++++++++ .../src/types/protocol_class.rs | 115 +++++++++ 2 files changed, 354 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 69e1fc6992..8e6dc21c78 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2442,6 +2442,245 @@ static_assert(is_subtype_of(PropertyWithSelfSetter, HasConcretePropertySetter)) static_assert(is_assignable_to(PropertyWithSelfSetter, HasConcretePropertySetter)) ``` +## Protocol members defined using descriptor decorators + +### Descriptor reads and writes + +On an instance, a protocol member defined using a descriptor decorator has the type returned by +`__get__`, not the type of the descriptor stored on the protocol class. If the descriptor defines +`__set__`, its value parameter determines which assignments are valid: + +```py +from typing import Protocol + +class StringDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> str: + return "example" + + def __set__(self, instance: object, value: str) -> None: ... + +class HasName(Protocol): + @StringDescriptor + def name(self) -> object: ... + +class WithName: + name: str = "example" + +has_name: HasName = WithName() +reveal_type(has_name.name) # revealed: str +has_name.name = "updated" +has_name.name = 1 # error: [invalid-assignment] +``` + +### `cached_property` + +The standard-library `cached_property` descriptor uses the same behavior: + +```py +from functools import cached_property +from typing import Protocol + +class HasCachedName(Protocol): + @cached_property + def name(self) -> str: ... + +class WithCachedName: + @cached_property + def name(self) -> str: + return "example" + +has_name: HasCachedName = WithCachedName() +``` + +### Generic descriptor result types + +Applying a generic descriptor decorator to a generic protocol method currently loses the protocol's +type variable and produces `cached_property[Unknown]`. The protocol must preserve that descriptor +type instead of reducing it to a bare `Unknown`, which would allow an incompatible implementation. + +```py +from functools import cached_property +from typing import Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, reveal_protocol_interface + +T = TypeVar("T") + +class HasValue(Protocol[T]): + @cached_property + def value(self) -> T: ... + +class StrValue: + @cached_property + def value(self) -> str: + return "value" + +static_assert(not is_assignable_to(StrValue, HasValue[int])) + +# TODO: This should be a property with an `int` read type once decorator calls preserve enclosing +# type variables. +# revealed: {"value": AttributeMember(`cached_property[Unknown]`)} +reveal_protocol_interface(HasValue[int]) +``` + +### Descriptor values in annotations + +Only a descriptor produced by decorating a protocol method changes how that member is read and +written through an instance. An annotation whose type implements the descriptor protocol still +declares an ordinary attribute whose protocol member type is the descriptor object. We reveal the +protocol interface here because ordinary instance access would invoke `cached_property.__get__` and +reveal `str` in both cases: + +```py +from functools import cached_property +from typing import Protocol +from ty_extensions._internal import reveal_protocol_interface + +class StoresDescriptor(Protocol): + name: cached_property[str] + +# revealed: {"name": AttributeMember(`cached_property[str]`)} +reveal_protocol_interface(StoresDescriptor) +``` + +### Overloaded setters selected by receiver type + +An overloaded `__set__` method can accept different values for different receiver types. For +`HasValue`, the overloads with an `object` receiver accept `int` and `bytes`; the overload for +`Other` does not apply. Until these overloads can be analyzed, `Unknown` is used as the write type. +This preserves the writable requirement without rejecting assignments. + +```py +from typing import Protocol, final, overload + +@final +class Other: ... + +class ReceiverSensitiveDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + raise NotImplementedError + + @overload + def __set__(self, instance: object, value: int) -> None: ... + @overload + def __set__(self, instance: object, value: bytes) -> None: ... + @overload + def __set__(self, instance: Other, value: str) -> None: ... + def __set__(self, instance: object, value: int | bytes | str) -> None: ... + +class HasValue(Protocol): + @ReceiverSensitiveDescriptor + def value(self) -> int: ... + +class ReadOnlyValue: + @property + def value(self) -> int: + return 1 + +read_only: HasValue = ReadOnlyValue() # error: [invalid-assignment] + +def update_value(value: HasValue) -> None: + value.value = 1 + value.value = b"valid" + # TODO: This assignment should be rejected. + value.value = "bad" +``` + +### Union descriptor types + +If a decorator can return either of two descriptors, an assignment must be accepted by both possible +descriptors. Here, only `str` is accepted by both. + +```py +from typing import Generic, Protocol, TypeVar + +T = TypeVar("T") + +class Descriptor(Generic[T]): + def __get__(self, instance: object, owner: type | None = None) -> T: + raise NotImplementedError + + def __set__(self, instance: object, value: T) -> None: ... + +def either_descriptor(getter: object) -> Descriptor[int | str] | Descriptor[str | bytes]: + raise NotImplementedError + +class HasEitherValue(Protocol): + @either_descriptor + def either_value(self) -> object: ... + +def update_either_value(value: HasEitherValue) -> None: + value.either_value = "valid" + # TODO: This assignment should be rejected. + value.either_value = 1 +``` + +### Overloaded setters selected by descriptor type + +An overload can also restrict the type of the descriptor itself. The decorator below returns +`SelfSensitiveDescriptor[int]`, so only the overload accepting an `int` value applies. + +```py +from __future__ import annotations + +from typing import Generic, Protocol, TypeVar, overload + +T = TypeVar("T") + +class SelfSensitiveDescriptor(Generic[T]): + def __get__(self, instance: object, owner: type | None = None) -> T: + raise NotImplementedError + + @overload + def __set__(self: SelfSensitiveDescriptor[int], instance: object, value: int) -> None: ... + @overload + def __set__(self: SelfSensitiveDescriptor[str], instance: object, value: str) -> None: ... + def __set__(self, instance: object, value: int | str) -> None: ... + +def int_descriptor(getter: object) -> SelfSensitiveDescriptor[int]: + raise NotImplementedError + +class HasIntValue(Protocol): + @int_descriptor + def int_value(self) -> int: ... + +def update_int_value(value: HasIntValue) -> None: + value.int_value = 1 + # TODO: This assignment should be rejected. + value.int_value = "bad" +``` + +### Generic setter value types + +A setter that uses a method type variable directly as its value parameter accepts every value +allowed by that type variable's upper bound. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T", bound=int) + +class BoundedDescriptor: + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + def __set__(self, instance: object, value: T) -> None: ... + +def bounded_descriptor(getter: object) -> BoundedDescriptor: + raise NotImplementedError + +class HasBoundedValue(Protocol): + @bounded_descriptor + def bounded_value(self) -> int: ... + +def update_bounded_value(value: HasBoundedValue) -> None: + value.bounded_value = 1 + # TODO: This assignment should be rejected. + value.bounded_value = "bad" +``` + ## Variance of generic protocols with `Final` members A `Final` attribute is readable but not writable, so it constrains an inferred type parameter diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 711153c43d..0f3ca723b6 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -1023,6 +1023,109 @@ fn property_set_member_type<'db>( )) } +/// Derive the observable instance capabilities of a descriptor-decorated protocol member. +fn descriptor_decorated_protocol_member<'db>( + db: &'db dyn Db, + descriptor_ty: Type<'db>, + protocol: ClassType<'db>, + definition: Option>, +) -> Option> { + // Applying a generic descriptor decorator to a method that refers to an enclosing type + // variable can currently materialize that variable as `Unknown`. Reducing the descriptor to + // its `__get__` result would then erase the remaining descriptor structure and weaken the + // protocol member to a bare `Unknown`. + if super::visitor::any_over_type(db, descriptor_ty, false, |ty| ty.is_unknown()) { + return None; + } + + let Place::Defined(DefinedPlace { + definedness: Definedness::AlwaysDefined, + .. + }) = descriptor_ty + .class_member_with_policy(db, "__get__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .place + else { + return None; + }; + + let receiver_ty = Type::instance(db, protocol); + let (read_ty, _) = + descriptor_ty.try_call_dunder_get(db, Some(receiver_ty), receiver_ty.to_meta_type(db))?; + let read = Some(ProtocolMemberType::with_definition(read_ty, definition)); + + let write = if let Place::Defined(DefinedPlace { + ty: setter_ty, + definedness: Definedness::AlwaysDefined, + .. + }) = descriptor_ty + .class_member_with_policy(db, "__set__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .place + { + Some(ProtocolMemberType::with_definition( + descriptor_setter_write_type(db, setter_ty, descriptor_ty, receiver_ty) + .unwrap_or_else(Type::unknown), + definition, + )) + } else { + None + }; + + Some(ProtocolMemberData::property(read, write, definition)) +} + +/// Derive a write type from a single ordinary setter signature. +fn descriptor_setter_write_type<'db>( + db: &'db dyn Db, + setter_ty: Type<'db>, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> Option> { + let callable = setter_ty.try_upcast_to_callable(db)?.exactly_one()?; + let signatures = callable.signatures(db); + let [signature] = signatures.overloads.as_ref() else { + return None; + }; + + // A method type variable cannot be used as the write type directly: it is inferred separately + // for each call. Deriving its accepted values requires generic call analysis. + if signature.generic_context.is_some_and(|generic_context| { + generic_context.variables(db).any(|typevar| { + !typevar.typevar(db).is_self(db) + && typevar + .binding_context(db) + .definition() + .is_some_and(|definition| definition.kind(db).is_function_def()) + }) + }) { + return None; + } + + let parameters = signature.parameters(); + if parameters.len() != 3 { + return None; + } + let descriptor_parameter = parameters + .get_positional(0)? + .annotated_type() + .bind_self_typevars(db, descriptor_ty); + let receiver_parameter = parameters + .get_positional(1)? + .annotated_type() + .bind_self_typevars(db, descriptor_ty); + if !descriptor_ty.is_assignable_to(db, descriptor_parameter) + || !receiver_ty.is_assignable_to(db, receiver_parameter) + { + return None; + } + + Some( + parameters + .get_positional(2)? + .annotated_type() + .bind_self_typevars(db, descriptor_ty), + ) +} + fn property_set_type<'db>( db: &'db dyn Db, property: PropertyInstanceType<'db>, @@ -1983,6 +2086,18 @@ fn cached_protocol_interface<'db>( Type::FunctionLiteral(function) if bound_on_class.is_yes() => { ProtocolMemberData::method(function.into_callable_type(db), definition) } + _ if bound_on_class.is_yes() + && definition + .is_some_and(|definition| definition.kind(db).is_function_def()) => + { + if let Some(descriptor) = + descriptor_decorated_protocol_member(db, ty, class, definition) + { + descriptor + } else { + ProtocolMemberData::attribute(ty, qualifiers, definition) + } + } _ => ProtocolMemberData::attribute(ty, qualifiers, definition), }; From 7a7bba8d90e63b979a1d7be1d7e729680444ee2a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 9 Jul 2026 22:21:00 -0400 Subject: [PATCH 008/222] [ty] Fix protocol matching for class variables (#26669) ## Summary Prior to this change, we rejected an unhashable class even though its `__hash__` matched the protocol: ```python from typing import ClassVar, Protocol class NotHashableProto(Protocol): __hash__: ClassVar[None] class NotHashable: __hash__: ClassVar[None] = None def accepts(value: NotHashableProto) -> None: ... # Before: rejected # After: accepted accepts(NotHashable()) ``` The problem is that we were checking the inherited `type.__hash__` instead of the class's own `__hash__ = None`. For this case, class-object reads already used the correct precedence: metaclass data descriptor, then class attribute, then metaclass non-data descriptor. For writes, though, we treated _any_ metaclass member as the primary and only consulted the class attribute as a fallback. We now let an always-defined class member take precedence over an ordinary metaclass member that is known not to be a data descriptor, while preserving uncertainty for gradual metaclass members. Closes https://github.com/astral-sh/ty/issues/3951. --- .../resources/mdtest/descriptor_protocol.md | 5 + .../resources/mdtest/protocols.md | 32 +++- .../resources/mdtest/type_qualifiers/final.md | 6 + crates/ty_python_semantic/src/types.rs | 19 +++ .../src/types/attribute_write.rs | 159 +++++++++++++++--- .../src/types/infer/builder.rs | 4 +- .../types/infer/builder/final_attribute.rs | 34 ++-- 7 files changed, 208 insertions(+), 51 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md index 8d11c5f4ac..b3b92fc7b3 100644 --- a/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md +++ b/crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md @@ -365,6 +365,7 @@ class C(metaclass=Meta): attribute: int = 1 reveal_type(C.attribute) # revealed: Any +C.attribute = "could be accepted by the dynamic descriptor" class UnionMeta(type): attribute: Any | DataDescriptor = DataDescriptor() @@ -545,6 +546,10 @@ reveal_type(C3.meta_attribute1) # revealed: Literal["value on class"] reveal_type(C3.meta_attribute2) # revealed: Literal["class level data descriptor"] reveal_type(C3.meta_non_data_descriptor1) # revealed: Literal["value on class"] reveal_type(C3.meta_non_data_descriptor2) # revealed: Literal["class level data descriptor"] + +C3.meta_non_data_descriptor1 = "value on class" +# error: [invalid-assignment] "Object of type `Literal["invalid"]` is not assignable to attribute `meta_non_data_descriptor1` of type `Literal["value on class"]`" +C3.meta_non_data_descriptor1 = "invalid" ``` Finally, metaclass attributes and metaclass non-data descriptors are only accessible when they are diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 8e6dc21c78..20248739f5 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -1834,7 +1834,7 @@ the non-callable attribute must be readable with the same type through both an i `classvars.py`: ```py -from typing import ClassVar, Protocol +from typing import Any, ClassVar, Protocol from ty_extensions import static_assert from ty_extensions._internal import is_subtype_of, is_assignable_to @@ -1866,6 +1866,36 @@ class ClassVarX: static_assert(is_assignable_to(ClassVarX, ClassVarXProto)) static_assert(is_subtype_of(ClassVarX, ClassVarXProto)) + +class XMeta(type): + def x(cls) -> str: + return "" + +class ClassVarXWithConflictingMetaclass(metaclass=XMeta): + x: ClassVar[int] = 42 + +static_assert(is_assignable_to(ClassVarXWithConflictingMetaclass, ClassVarXProto)) +static_assert(is_subtype_of(ClassVarXWithConflictingMetaclass, ClassVarXProto)) + +class GenericMeta(type): + x: list[Any] = [] + +class ClassVarXWithGenericMetaclass(metaclass=GenericMeta): + x: ClassVar[int] = 42 + +static_assert(is_assignable_to(ClassVarXWithGenericMetaclass, ClassVarXProto)) +static_assert(is_subtype_of(ClassVarXWithGenericMetaclass, ClassVarXProto)) + +# A class-level attribute shadows a non-data descriptor on the metaclass. In particular, +# `NotHashable.__hash__` takes precedence over the non-data `type.__hash__` descriptor. +class NotHashableProto(Protocol): + __hash__: ClassVar[None] + +class NotHashable: + __hash__: ClassVar[None] = None + +static_assert(is_assignable_to(NotHashable, NotHashableProto)) +static_assert(is_subtype_of(NotHashable, NotHashableProto)) ``` This is mentioned by the diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md index e0c337c6cd..dc86e1d631 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md @@ -259,9 +259,13 @@ class Meta(type): META_FINAL_A: Final[int] = 1 META_FINAL_B: Final = 1 + def CLASS_FINAL_SHADOWING_NON_DATA_DESCRIPTOR(cls) -> int: + return 1 + class C(metaclass=Meta): CLASS_FINAL_A: Final[int] = 1 CLASS_FINAL_B: Final = 1 + CLASS_FINAL_SHADOWING_NON_DATA_DESCRIPTOR: Final[int] = 1 def __init__(self): self.INSTANCE_FINAL_A: Final[int] = 1 @@ -282,6 +286,8 @@ C.CLASS_FINAL_A = 2 C.CLASS_FINAL_B = 2 # error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type ``" C.CLASS_FINAL_A += 1 +# error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_SHADOWING_NON_DATA_DESCRIPTOR` on type ``" +C.CLASS_FINAL_SHADOWING_NON_DATA_DESCRIPTOR = 2 c = C() # error: [invalid-assignment] "Cannot assign to final attribute `CLASS_FINAL_A` on type `C`" diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index fde0f72190..55b6d51d1b 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3518,6 +3518,25 @@ impl<'db> Type<'db> { self.is_data_descriptor_impl(d, true) } + /// Returns whether this type is known not to be a data descriptor. + /// + /// Descriptor uncertainty only propagates through outer unions, intersections, and aliases; + /// type arguments do not affect the runtime descriptor class. + pub(crate) fn is_definitely_non_data_descriptor(self, db: &'db dyn Db) -> bool { + match self { + Type::Dynamic(_) | Type::Divergent(_) | Type::TypeVar(_) => false, + Type::Union(union) => union + .elements(db) + .iter() + .all(|ty| ty.is_definitely_non_data_descriptor(db)), + Type::Intersection(intersection) => intersection + .iter_positive(db) + .all(|ty| ty.is_definitely_non_data_descriptor(db)), + Type::TypeAlias(alias) => alias.value_type(db).is_definitely_non_data_descriptor(db), + _ => !self.may_be_data_descriptor(db), + } + } + fn is_data_descriptor_impl(self, db: &'db dyn Db, any_of_union: bool) -> bool { match self { Type::Dynamic(_) => !any_of_union, diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index c9a1ccdc2b..6015f7f3e5 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -86,8 +86,9 @@ pub(super) enum InstanceAttributeWriteMember<'db> { /// The member that governs a write through a class object. /// -/// The primary lookup is on the metaclass. If that lookup is absent or possibly undefined, the -/// class object's own attributes form the fallback. +/// A data descriptor on the metaclass takes precedence over the class object's own attributes, +/// which in turn take precedence over definitely non-data metaclass members. If the metaclass +/// member is absent or possibly undefined, the class object's own attributes form the fallback. pub(super) enum ClassAttributeWriteMember<'db> { /// A metaclass member governs the write, optionally alongside a class-attribute fallback. Explicit { @@ -143,6 +144,61 @@ pub(super) enum FallbackAttributeWriteRequirement<'db> { PossiblyMissing, } +/// The members that can govern an attribute write. +/// +/// For a class-object receiver, the type member is found on the metaclass while the receiver member +/// is found on the class's own MRO: +/// +/// ```python +/// class Descriptor: +/// def __set__(self, instance: object, value: object) -> None: ... +/// +/// class Meta(type): +/// data = Descriptor() # Type member: Meta.data +/// plain = object() # Type member: Meta.plain +/// +/// class C(metaclass=Meta): +/// data: int # Receiver member: C.data +/// plain: int # Receiver member: C.plain +/// +/// C.data = 1 +/// C.plain = 1 +/// ``` +pub(super) enum AssignmentAttributeMembers<'db> { + /// The type member governs the write, as `Meta.data` does above because it is a data descriptor. + /// If the type member may be missing, the corresponding receiver member (`C.data`) is retained + /// as `receiver_fallback`. + TypeMember { + member: PlaceAndQualifiers<'db>, + receiver_fallback: Option>, + }, + /// The receiver member governs the write, as `C.plain` does above because `Meta.plain` is + /// definitely not a data descriptor. + ReceiverMember(PlaceAndQualifiers<'db>), +} + +impl<'db> AssignmentAttributeMembers<'db> { + /// Return the member whose descriptor protocol applies to the receiver, if any. + pub(super) fn type_member(self) -> Option> { + match self { + Self::TypeMember { member, .. } => Some(member), + Self::ReceiverMember(_) => None, + } + } + + /// Iterate over every member that can govern the write at runtime. + pub(super) fn effective_members(self) -> impl Iterator> { + let members = match self { + Self::TypeMember { + member, + receiver_fallback, + } => [Some(member), receiver_fallback], + Self::ReceiverMember(member) => [Some(member), None], + }; + members.into_iter().flatten() + } +} + /// Resolve the receiver-level requirements for writing `object_ty.attribute`. /// /// This expands aliases, preserves the all-arms rule for unions and the any-positive-arm rule for @@ -259,13 +315,23 @@ fn instance_attribute_write_member_requirement<'db>( object_ty: Type<'db>, attribute: &str, ) -> InstanceAttributeWriteMember<'db> { - let Some((meta_attr, fallback_attr)) = assignment_attribute_members(db, object_ty, attribute) - else { + let Some(members) = assignment_attribute_members(db, object_ty, attribute) else { return InstanceAttributeWriteMember::SetAttr; }; + let (type_member, receiver_fallback) = match members { + AssignmentAttributeMembers::TypeMember { + member, + receiver_fallback, + } => (member, receiver_fallback), + AssignmentAttributeMembers::ReceiverMember(member) => { + return InstanceAttributeWriteMember::Instance(instance_fallback_write_requirement( + db, object_ty, attribute, member, + )); + } + }; - match meta_attr { - meta_attr if meta_attr.is_class_var() => InstanceAttributeWriteMember::ClassVar, + match type_member { + type_member if type_member.is_class_var() => InstanceAttributeWriteMember::ClassVar, PlaceAndQualifiers { place: Place::Defined(DefinedPlace { ty, .. }), qualifiers, @@ -277,14 +343,14 @@ fn instance_attribute_write_member_requirement<'db>( ty.bind_self_typevars(db, object_ty), qualifiers, ), - fallback: fallback_attr.map(|fallback| { + fallback: receiver_fallback.map(|fallback| { instance_fallback_write_requirement(db, object_ty, attribute, fallback) }), }, PlaceAndQualifiers { place: Place::Undefined, .. - } => match fallback_attr { + } => match receiver_fallback { Some( fallback @ PlaceAndQualifiers { place: Place::Defined(_), @@ -307,28 +373,41 @@ fn class_attribute_write_requirement<'db>( object_ty: Type<'db>, attribute: &str, ) -> AttributeWriteRequirement<'db> { - let Some((meta_attr, fallback_attr)) = assignment_attribute_members(db, object_ty, attribute) - else { + let Some(members) = assignment_attribute_members(db, object_ty, attribute) else { return AttributeWriteRequirement::Unconstrained; }; let Some(class_attr_self_ty) = object_ty.to_instance(db) else { return AttributeWriteRequirement::Unconstrained; }; + let (type_member, receiver_fallback) = match members { + AssignmentAttributeMembers::TypeMember { + member, + receiver_fallback, + } => (member, receiver_fallback), + AssignmentAttributeMembers::ReceiverMember(member) => { + return AttributeWriteRequirement::Class { + object_ty, + member: ClassAttributeWriteMember::ClassAttribute( + class_fallback_write_requirement(db, object_ty, class_attr_self_ty, member), + ), + }; + } + }; - let member = match meta_attr { + let member = match type_member { PlaceAndQualifiers { place: Place::Defined(DefinedPlace { ty, .. }), qualifiers, } => ClassAttributeWriteMember::Explicit { member: explicit_attribute_write_requirement(db, object_ty, attribute, ty, qualifiers), - fallback: fallback_attr.map(|fallback| { + fallback: receiver_fallback.map(|fallback| { class_fallback_write_requirement(db, object_ty, class_attr_self_ty, fallback) }), }, PlaceAndQualifiers { place: Place::Undefined, .. - } => match fallback_attr { + } => match receiver_fallback { Some( fallback @ PlaceAndQualifiers { place: Place::Defined(_), @@ -494,12 +573,36 @@ pub(super) fn property_setter_returns_never<'db>( }) } -/// Return the primary and optional fallback members considered by attribute assignment. +/// Return the class member that takes precedence over a definitely non-data metaclass member. +fn class_member_preceding_non_data_metaclass_member<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, + type_member: PlaceAndQualifiers<'db>, +) -> Option> { + if !matches!( + object_ty, + Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) + ) || !type_member + .place + .ignore_possibly_undefined()? + .is_definitely_non_data_descriptor(db) + { + return None; + } + + object_ty + .find_name_in_mro_with_policy(db, attribute, MemberLookupPolicy::default()) + .filter(|class_attr| !class_attr.place.is_undefined()) +} + +/// Return the members considered by attribute assignment in lookup-precedence order. /// -/// The primary member comes from class-member lookup. The fallback is queried only when that -/// member is absent or possibly undefined, and is an instance member for ordinary receivers or a -/// class-object member for class receivers. Composite and dynamic receiver types return `None`; -/// their callers either decompose them before this point or handle them without member lookup. +/// The type member comes from class-member lookup. A member found directly on the receiver is +/// queried when the type member is absent or possibly undefined. For class objects, a class-MRO +/// member instead takes precedence over a definitely non-data metaclass member. Composite and +/// dynamic receiver types return `None`; their callers either decompose them before this point or +/// handle them without member lookup. /// /// This helper deliberately does not bind `Self` or interpret descriptors so that assignment, /// protocol compatibility, and `Final` validation share exactly the same lookup precedence. @@ -507,10 +610,10 @@ pub(super) fn assignment_attribute_members<'db>( db: &'db dyn Db, object_ty: Type<'db>, attribute: &str, -) -> Option<(PlaceAndQualifiers<'db>, Option>)> { +) -> Option> { // Precise `functools.partial` instances synthesize a refined `__call__` member instead of // using the broad signature from typeshed. - let meta_attr = if attribute == "__call__" + let type_member = if attribute == "__call__" && matches!( object_ty, Type::KnownInstance(KnownInstanceType::FunctoolsPartial(_)) @@ -519,14 +622,19 @@ pub(super) fn assignment_attribute_members<'db>( } else { object_ty.class_member(db, attribute.into()) }; - let needs_fallback = matches!( - meta_attr.place, + if let Some(receiver_member) = + class_member_preceding_non_data_metaclass_member(db, object_ty, attribute, type_member) + { + return Some(AssignmentAttributeMembers::ReceiverMember(receiver_member)); + } + let needs_receiver_fallback = matches!( + type_member.place, Place::Defined(DefinedPlace { definedness: Definedness::PossiblyUndefined, .. }) | Place::Undefined ); - let fallback_attr = if needs_fallback { + let receiver_fallback = if needs_receiver_fallback { Some(match object_ty { Type::NominalInstance(..) | Type::ProtocolInstance(_) @@ -565,5 +673,8 @@ pub(super) fn assignment_attribute_members<'db>( } else { None }; - Some((meta_attr, fallback_attr)) + Some(AssignmentAttributeMembers::TypeMember { + member: type_member, + receiver_fallback, + }) } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e9829913c7..352d3d73f1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -40,7 +40,7 @@ use crate::place::{ }; use crate::reachability::{ReachabilityEvaluationCache, evaluate_reachability_with_cache}; use crate::types::add_inferred_python_version_hint_to_diagnostic; -use crate::types::attribute_write::assignment_attribute_members; +use crate::types::attribute_write::{AssignmentAttributeMembers, assignment_attribute_members}; use crate::types::call::bind::MatchingOverloadIndex; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; @@ -2857,7 +2857,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }), .. }) = assignment_attribute_members(db, object_ty, attribute) - .map(|(meta_attr, _)| meta_attr) + .and_then(AssignmentAttributeMembers::type_member) { let attr_ty = attr_ty.bind_self_typevars(db, object_ty); let delete_dunder_call_result = attr_ty.try_call_dunder( diff --git a/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs b/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs index 5de7f6b16e..89220edb77 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs @@ -317,25 +317,19 @@ impl<'db> TypeInferenceBuilder<'db, '_> { object_ty: Type<'db>, attribute: &str, ) { - let Some((meta_attr, fallback_attr)) = - assignment_attribute_members(self.db(), object_ty, attribute) - else { + let Some(members) = assignment_attribute_members(self.db(), object_ty, attribute) else { return; }; - if !self.invalid_assignment_to_final_attribute( - object_ty, - target, - attribute, - meta_attr.qualifiers, - ) && let Some(fallback_attr) = fallback_attr - { - self.invalid_assignment_to_final_attribute( + for member in members.effective_members() { + if self.invalid_assignment_to_final_attribute( object_ty, target, attribute, - fallback_attr.qualifiers, - ); + member.qualifiers, + ) { + break; + } } } @@ -346,24 +340,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { attribute: &str, emit_diagnostics: bool, ) -> bool { - let Some((meta_attr, fallback_attr)) = - assignment_attribute_members(self.db(), object_ty, attribute) - else { + let Some(members) = assignment_attribute_members(self.db(), object_ty, attribute) else { return false; }; - self.invalid_deletion_of_final_attribute( - object_ty, - target, - attribute, - meta_attr.qualifiers, - emit_diagnostics, - ) || fallback_attr.is_some_and(|fallback_attr| { + members.effective_members().any(|member| { self.invalid_deletion_of_final_attribute( object_ty, target, attribute, - fallback_attr.qualifiers, + member.qualifiers, emit_diagnostics, ) }) From 00ba5d70ce12da81409636a268d45d0ea4251ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Thu, 9 Jul 2026 19:22:22 -0700 Subject: [PATCH 009/222] [ty] Make structured hard breaks visible in snapshots (#26686) ## Summary This improves our tests by making significant whitespace visible in snapshot output. Two trailing spaces at the end of a Markdown paragraph preserve a hard break (rendered as `` in snapshots) at the end of that line. ## Test Plan See included tests. --- .../src/docstring/markdown/structured.rs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/ty_ide/src/docstring/markdown/structured.rs b/crates/ty_ide/src/docstring/markdown/structured.rs index 3b983afce4..999eb2a7ec 100644 --- a/crates/ty_ide/src/docstring/markdown/structured.rs +++ b/crates/ty_ide/src/docstring/markdown/structured.rs @@ -409,6 +409,7 @@ mod tests { #[test] fn sections_render_in_canonical_order() { + let _snap = bind_markdown_snapshot_filters(); let section = section_block(vec![ SectionItem::new( SectionKind::Raises, @@ -456,31 +457,31 @@ mod tests { assert_snapshot!(render_markdown(§ion), @r" ## Parameters - **value**: `str` + **value**: `str` The value. ## Keyword Arguments - **limit**: `int` + **limit**: `int` Maximum result count. ## Other Parameters - **kw\_only**: `str` + **kw\_only**: `str` Less common option. ## Attributes - **cache**: `dict[str, object]` + **cache**: `dict[str, object]` Cached data. ## Returns - `bool` + `bool` Whether validation passed. ## Yields - **item**: `Iterator[int]` + **item**: `Iterator[int]` Generated values. ## Raises - `ValueError` + `ValueError` Invalid value. "); } @@ -510,6 +511,7 @@ mod tests { #[test] fn section_items_escape_bold_names() { + let _snap = bind_markdown_snapshot_filters(); let section = section_block(vec![ SectionItem::new( SectionKind::Parameters, @@ -533,13 +535,13 @@ mod tests { assert_snapshot!(render_markdown(§ion), @r" ## Parameters - **\*args** + **\*args** Escaped name. - **\_\_value\_\_** + **\_\_value\_\_** Escaped name. - **<value> & \[docs\](target) \| \~deleted\~** + **<value> & \[docs\](target) \| \~deleted\~** Escaped name. "); } @@ -647,11 +649,12 @@ Rendered as code: #[test] fn following_prose_does_not_continue_a_rendered_parameter_paragraph() { + let _snap = bind_markdown_snapshot_filters(); let rendered = render_parameter_docstring("Value.", "After."); assert_snapshot!(rendered, @" ## Parameters - **value** + **value** Value. After. @@ -724,6 +727,7 @@ Rendered as code: #[test] fn adjacent_sections_are_separated() { + let _snap = bind_markdown_snapshot_filters(); let raw = "ab"; let rendered = render_sections( raw, @@ -751,11 +755,11 @@ Rendered as code: assert_snapshot!(rendered, @" ## Parameters - **value** + **value** The value. ## Returns - `bool` + `bool` The result. "); } From 20740c5d7da2718054ac4fce671474f223759e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Thu, 9 Jul 2026 23:06:00 -0700 Subject: [PATCH 010/222] [ty] Rename type for Google docstring sections that do not participate in structural rendering. (#26691) ## Summary This just picks a new name for a type that makes more sense with [upcoming markdown rendering for Google-style docstrings](https://github.com/astral-sh/ruff/pull/26599). ## Test Plan Relies on existing test coverage. --- crates/ty_ide/src/docstring/document/google.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 69be103779..389c005292 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -366,7 +366,7 @@ fn section_item_indent(header: SectionHeader, line: ParsedLine<'_>) -> Option !trimmed.is_empty(), - HeaderKind::Container => false, + HeaderKind::Opaque => false, }; is_item.then_some(line.raw_indent) } @@ -402,7 +402,7 @@ struct SectionHeader { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HeaderKind { Structured(SectionKind), - Container, + Opaque, } impl HeaderKind { @@ -424,7 +424,7 @@ impl HeaderKind { "raise" | "raises" => Self::Structured(SectionKind::Raises), "attention" | "caution" | "danger" | "error" | "example" | "examples" | "hint" | "important" | "methods" | "note" | "notes" | "references" | "see also" | "tip" - | "todo" | "todos" | "warning" | "warnings" | "warns" => Self::Container, + | "todo" | "todos" | "warning" | "warnings" | "warns" => Self::Opaque, _ => return None, }) } From 7041b52c9ef3cf1b67184f66af9d4343de157aa6 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 10 Jul 2026 08:44:47 +0200 Subject: [PATCH 011/222] Adopt recently stabilized Rust APIs (#26668) --- Cargo.lock | 1 - .../src/renderer/display_list.rs | 32 +++++----------- crates/ruff_db/src/diagnostic/stylesheet.rs | 32 +++++----------- crates/ruff_linter/src/locator.rs | 13 +------ crates/ruff_notebook/Cargo.toml | 1 - crates/ruff_notebook/src/cell.rs | 9 ++--- crates/ruff_notebook/src/notebook.rs | 5 +-- crates/ty_module_resolver/src/typeshed.rs | 30 ++++++--------- crates/ty_project/src/metadata/options.rs | 38 ++++++++----------- crates/ty_python_semantic/src/types/mro.rs | 5 +-- 10 files changed, 54 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 407f6c57a1..4d9967cfb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3450,7 +3450,6 @@ name = "ruff_notebook" version = "0.0.4" dependencies = [ "anyhow", - "itertools 0.15.0", "rand 0.10.1", "ruff_diagnostics", "ruff_source_file", diff --git a/crates/ruff_annotate_snippets/src/renderer/display_list.rs b/crates/ruff_annotate_snippets/src/renderer/display_list.rs index 3785ab6749..85396c0a30 100644 --- a/crates/ruff_annotate_snippets/src/renderer/display_list.rs +++ b/crates/ruff_annotate_snippets/src/renderer/display_list.rs @@ -1928,31 +1928,19 @@ pub(super) fn fmt_with_hyperlink<'a, T>( where T: std::fmt::Display + 'a, { - struct FmtHyperlink<'a, T> { - content: T, - url: Option<&'a str>, - } - - impl std::fmt::Display for FmtHyperlink<'_, T> - where - T: std::fmt::Display, - { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(url) = self.url { - write!(f, "\x1B]8;;{url}\x1B\\")?; - } + let url = if stylesheet.hyperlink { url } else { None }; - self.content.fmt(f)?; + fmt::from_fn(move |f| { + if let Some(url) = url { + write!(f, "\x1B]8;;{url}\x1B\\")?; + } - if self.url.is_some() { - f.write_str("\x1B]8;;\x1B\\")?; - } + content.fmt(f)?; - Ok(()) + if url.is_some() { + f.write_str("\x1B]8;;\x1B\\")?; } - } - - let url = if stylesheet.hyperlink { url } else { None }; - FmtHyperlink { content, url } + Ok(()) + }) } diff --git a/crates/ruff_db/src/diagnostic/stylesheet.rs b/crates/ruff_db/src/diagnostic/stylesheet.rs index f0e13d0cd4..ed4baf37b9 100644 --- a/crates/ruff_db/src/diagnostic/stylesheet.rs +++ b/crates/ruff_db/src/diagnostic/stylesheet.rs @@ -41,33 +41,21 @@ pub(super) fn fmt_with_hyperlink<'a, T>( where T: std::fmt::Display + 'a, { - struct FmtHyperlink<'a, T> { - content: T, - url: Option<&'a str>, - } - - impl std::fmt::Display for FmtHyperlink<'_, T> - where - T: std::fmt::Display, - { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if let Some(url) = self.url { - write!(f, "\x1B]8;;{url}\x1B\\")?; - } + let url = if stylesheet.hyperlink { url } else { None }; - self.content.fmt(f)?; + std::fmt::from_fn(move |f| { + if let Some(url) = url { + write!(f, "\x1B]8;;{url}\x1B\\")?; + } - if self.url.is_some() { - f.write_str("\x1B]8;;\x1B\\")?; - } + content.fmt(f)?; - Ok(()) + if url.is_some() { + f.write_str("\x1B]8;;\x1B\\")?; } - } - - let url = if stylesheet.hyperlink { url } else { None }; - FmtHyperlink { content, url } + Ok(()) + }) } #[derive(Clone, Debug)] diff --git a/crates/ruff_linter/src/locator.rs b/crates/ruff_linter/src/locator.rs index 40653e672e..87afaae8bf 100644 --- a/crates/ruff_linter/src/locator.rs +++ b/crates/ruff_linter/src/locator.rs @@ -68,8 +68,6 @@ impl<'a> Locator<'a> { /// Finds the closest [`TextSize`] not exceeding the offset for which `is_char_boundary` is /// `true`. /// - /// Can be replaced with `str::floor_char_boundary` once it's stable. - /// /// ## Examples /// /// ``` @@ -106,16 +104,7 @@ impl<'a> Locator<'a> { /// ); /// ``` pub fn floor_char_boundary(&self, offset: TextSize) -> TextSize { - if offset >= self.text_len() { - self.text_len() - } else { - // We know that the character boundary is within four bytes. - (0u32..=3u32) - .map(TextSize::from) - .filter_map(|index| offset.checked_sub(index)) - .find(|offset| self.contents.is_char_boundary(offset.to_usize())) - .unwrap_or_default() - } + TextSize::try_from(self.contents.floor_char_boundary(offset.to_usize())).unwrap() } /// Take the source code between the given [`TextRange`]. diff --git a/crates/ruff_notebook/Cargo.toml b/crates/ruff_notebook/Cargo.toml index f211741de7..8802dd74cd 100644 --- a/crates/ruff_notebook/Cargo.toml +++ b/crates/ruff_notebook/Cargo.toml @@ -16,7 +16,6 @@ ruff_source_file = { workspace = true, features = ["serde"] } ruff_text_size = { workspace = true } anyhow = { workspace = true } -itertools = { workspace = true } rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/ruff_notebook/src/cell.rs b/crates/ruff_notebook/src/cell.rs index 1c7b749e05..51beeba55d 100644 --- a/crates/ruff_notebook/src/cell.rs +++ b/crates/ruff_notebook/src/cell.rs @@ -1,8 +1,6 @@ use std::fmt; use std::ops::{Deref, DerefMut}; -use itertools::Itertools; - use ruff_text_size::{TextRange, TextSize}; use crate::schema::{Cell, SourceValue}; @@ -284,7 +282,7 @@ impl CellOffsets { /// Returns the range of the cell containing the given offset, if any. pub fn containing_range(&self, offset: TextSize) -> Option { - self.iter().tuple_windows().find_map(|(start, end)| { + self.array_windows::<2>().find_map(|[start, end]| { if *start <= offset && offset < *end { Some(TextRange::new(*start, *end)) } else { @@ -325,9 +323,8 @@ impl CellOffsets { /// Returns an iterator over [`TextRange`]s covered by each cell. pub fn ranges(&self) -> impl Iterator { - self.iter() - .tuple_windows() - .map(|(start, end)| TextRange::new(*start, *end)) + self.array_windows::<2>() + .map(|[start, end]| TextRange::new(*start, *end)) } /// Returns an iterator over the concatenated source ranges covered by each cell's actual diff --git a/crates/ruff_notebook/src/notebook.rs b/crates/ruff_notebook/src/notebook.rs index 62889dbf9d..76f8fa854f 100644 --- a/crates/ruff_notebook/src/notebook.rs +++ b/crates/ruff_notebook/src/notebook.rs @@ -1,4 +1,3 @@ -use itertools::Itertools; use rand::{RngExt, SeedableRng}; use serde::Serialize; use serde_json::error::Category; @@ -303,10 +302,10 @@ impl Notebook { fn update_cell_content(&mut self, transformed: &str) -> bool { let mut missing_separator = false; - for (&idx, (start, end)) in self + for (&idx, &[start, end]) in self .valid_code_cells .iter() - .zip(self.cell_offsets.iter().tuple_windows::<(_, _)>()) + .zip(self.cell_offsets.array_windows::<2>()) { let cell_content = transformed .get(start.to_usize()..end.to_usize()) diff --git a/crates/ty_module_resolver/src/typeshed.rs b/crates/ty_module_resolver/src/typeshed.rs index f8290129ab..1fcac4050d 100644 --- a/crates/ty_module_resolver/src/typeshed.rs +++ b/crates/ty_module_resolver/src/typeshed.rs @@ -244,27 +244,19 @@ impl PyVersionRange { /// Display the version range in a way that is suitable for rendering in user-facing diagnostics. pub fn diagnostic_display(&self) -> impl std::fmt::Display { - struct DiagnosticDisplay<'a>(&'a PyVersionRange); - - impl fmt::Display for DiagnosticDisplay<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.0 { - PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start), - PyVersionRange::AvailableWithin(range_inclusive) => { - // Don't trust the start Python version if it's 3.0 or lower. - // Typeshed doesn't attempt to give accurate start versions if a module was added - // in the Python 2 era. - if range_inclusive.start() <= &(PythonVersion { major: 3, minor: 0 }) { - write!(f, "<={}", range_inclusive.end()) - } else { - write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end()) - } - } + fmt::from_fn(|f| match self { + PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start), + PyVersionRange::AvailableWithin(range_inclusive) => { + // Don't trust the start Python version if it's 3.0 or lower. + // Typeshed doesn't attempt to give accurate start versions if a module was added + // in the Python 2 era. + if range_inclusive.start() <= &(PythonVersion { major: 3, minor: 0 }) { + write!(f, "<={}", range_inclusive.end()) + } else { + write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end()) } } - } - - DiagnosticDisplay(self) + }) } } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 63e5c05895..f5917229bf 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1966,29 +1966,21 @@ pub struct ToSettingsError { impl ToSettingsError { pub fn pretty<'a>(&'a self, db: &'a dyn Db) -> impl fmt::Display + use<'a> { - struct DisplayPretty<'a> { - db: &'a dyn ruff_db::Db, - error: &'a ToSettingsError, - } - - impl fmt::Display for DisplayPretty<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_config = DisplayDiagnosticConfig::new("ty") - .format(self.error.output_format.into()) - .color(self.error.color); - - write!( - f, - "{}", - self.error - .diagnostic - .to_diagnostic() - .display(&self.db, &display_config) - ) - } - } - - DisplayPretty { db, error: self } + let db: &dyn ruff_db::Db = db; + + fmt::from_fn(move |f| { + let display_config = DisplayDiagnosticConfig::new("ty") + .format(self.output_format.into()) + .color(self.color); + + write!( + f, + "{}", + self.diagnostic + .to_diagnostic() + .display(&db, &display_config) + ) + }) } pub fn into_diagnostic(self) -> OptionDiagnostic { diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 66b34b1b48..8b23222751 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -821,10 +821,9 @@ fn c3_merge(mut sequences: Vec>) -> Option { mro.push(mro_entry); // Make sure we don't try to add the candidate to the MRO twice: + let mro_entry_identity = mro_entry.mro_identity(); for sequence in &mut sequences { - if sequence[0].mro_identity() == mro_entry.mro_identity() { - sequence.pop_front(); - } + sequence.pop_front_if(|base| base.mro_identity() == mro_entry_identity); } } } From 517028f8fe766e60abdf3248aa75b95143a21fbb Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 10 Jul 2026 11:27:34 +0200 Subject: [PATCH 012/222] [ty] Retain raw `Options` by precedence (#26662) --- .../benches/module_resolution.rs | 2 +- crates/ruff_benchmark/benches/ty.rs | 17 +- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- crates/ty/src/lib.rs | 18 +- crates/ty/tests/cli/rule_selection.rs | 49 ++++ crates/ty/tests/file_watching.rs | 240 +++++++++++++----- crates/ty_completion_bench/src/main.rs | 9 +- crates/ty_completion_eval/src/main.rs | 11 +- crates/ty_project/src/db.rs | 21 +- crates/ty_project/src/db/changes.rs | 147 ++++------- crates/ty_project/src/metadata.rs | 209 ++++++++++++--- crates/ty_project/src/metadata/options.rs | 34 --- crates/ty_project/src/metadata/settings.rs | 12 +- .../ty_project/src/watch/project_watcher.rs | 7 +- crates/ty_server/src/session.rs | 24 +- crates/ty_server/src/session/options.rs | 27 +- crates/ty_server/src/session/settings.rs | 22 +- .../e2e__commands__debug_command.snap | 4 +- crates/ty_wasm/src/lib.rs | 50 ++-- 19 files changed, 553 insertions(+), 352 deletions(-) diff --git a/crates/ruff_benchmark/benches/module_resolution.rs b/crates/ruff_benchmark/benches/module_resolution.rs index 4cdbfddb51..c33fb80371 100644 --- a/crates/ruff_benchmark/benches/module_resolution.rs +++ b/crates/ruff_benchmark/benches/module_resolution.rs @@ -62,7 +62,7 @@ fn setup_case(n: usize) -> Case { fs.write_file_all(&importing_path, "").unwrap(); let mut metadata = ProjectMetadata::discover(SystemPath::new("/src"), &system).unwrap(); - metadata.apply_options(Options { + metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(SupportedPythonVersion::Py312)), extra_paths: Some(extra_paths), diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index 4a4f6314b8..03b0fada67 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -85,7 +85,7 @@ fn setup_tomllib_case() -> Case { let src_root = SystemPath::new("/src"); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); - metadata.apply_options(Options { + metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(SupportedPythonVersion::Py312)), ..EnvironmentOptions::default() @@ -163,13 +163,10 @@ fn benchmark_incremental(criterion: &mut Criterion) { fn incremental(case: &mut Case) { let Case { db, .. } = case; - db.apply_changes( - &[ChangeEvent::Changed { - path: case.file_path.clone(), - kind: ChangedKind::FileContent, - }], - None, - ); + db.apply_changes(&[ChangeEvent::Changed { + path: case.file_path.clone(), + kind: ChangedKind::FileContent, + }]); let result = db.check(); @@ -267,7 +264,7 @@ fn setup_micro_case_inner(code: &str, venv_path: Option<&Path>) -> Case { let src_root = SystemPath::new("/src"); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); - metadata.apply_options(Options { + metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(SupportedPythonVersion::Py312)), python, @@ -1725,7 +1722,7 @@ impl<'a> ProjectBenchmark<'a> { let src_root = SystemPath::new("/"); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); - metadata.apply_options(Options { + metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(self.project.config.python_version)), python: Some(RelativePathBuf::cli(SystemPath::new(".venv"))), diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 61c1fd58e4..a926dd1c40 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -43,7 +43,7 @@ impl<'a> Benchmark<'a> { let mut metadata = ProjectMetadata::discover(&root, &system).unwrap(); - metadata.apply_options(Options { + metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(installed_project.config.python_version)), python: Some(RelativePathBuf::cli(SystemPath::new(".venv"))), diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 0a33a3b6b4..c6bb8d7f4c 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -24,7 +24,6 @@ use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use ruff_db::{STACK_SIZE, max_parallelism}; use ruff_diagnostics::Applicability; use salsa::Database; -use ty_project::metadata::options::ProjectOptionsOverrides; use ty_project::metadata::settings::TerminalSettings; use ty_project::watch::ProjectWatcher; use ty_project::{CollectReporter, Db, watch}; @@ -162,8 +161,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result { project_metadata.apply_configuration_files(&system)?; - let project_options_overrides = ProjectOptionsOverrides::new(config_file, args.into_options()); - project_metadata.apply_overrides(&project_options_overrides); + project_metadata.apply_override_options(args.into_options()); let mut db = ProjectDatabase::fallible(project_metadata, system)?; let project = db.project(); @@ -189,8 +187,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result { db.freeze(); } - let (main_loop, main_loop_cancellation_token) = - MainLoop::new(mode, project_options_overrides, printer); + let (main_loop, main_loop_cancellation_token) = MainLoop::new(mode, printer); // Listen to Ctrl+C and abort the watch mode. let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token)); @@ -280,8 +277,6 @@ struct MainLoop { /// Interface for displaying information to the user. printer: Printer, - project_options_overrides: ProjectOptionsOverrides, - /// Cancellation token that gets set by Ctrl+C. /// Used for long-running operations on the main thread. Operations on background threads /// use Salsa's cancellation mechanism. @@ -289,11 +284,7 @@ struct MainLoop { } impl MainLoop { - fn new( - mode: MainLoopMode, - project_options_overrides: ProjectOptionsOverrides, - printer: Printer, - ) -> (Self, MainLoopCancellationToken) { + fn new(mode: MainLoopMode, printer: Printer) -> (Self, MainLoopCancellationToken) { let (sender, receiver) = crossbeam_channel::bounded(10); let cancellation_token_source = CancellationTokenSource::new(); @@ -305,7 +296,6 @@ impl MainLoop { sender: sender.clone(), receiver, watcher: None, - project_options_overrides, printer, cancellation_token, }, @@ -476,7 +466,7 @@ impl MainLoop { revision += 1; // Automatically cancels any pending queries and waits for them to complete. - db.apply_changes(&changes, Some(&self.project_options_overrides)); + db.apply_changes(&changes); if let Some(watcher) = self.watcher.as_mut() { watcher.update(db); } diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index c553169e9c..78f6743f47 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -410,6 +410,55 @@ fn overrides_precedence() -> anyhow::Result<()> { Ok(()) } +/// Multiple matching overrides inherit global options from higher-precedence layers. +#[test] +fn multiple_overrides_inherit_cli_rules() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [[tool.ty.overrides]] + include = ["test.py"] + [tool.ty.overrides.rules] + division-by-zero = "warn" + + [[tool.ty.overrides]] + include = ["test.py"] + [tool.ty.overrides.rules] + possibly-unresolved-reference = "ignore" + "#, + ), + ( + "test.py", + r#" + y = 4 / 0 + prin(y) + "#, + ), + ])?; + + assert_cmd_snapshot!( + case.command().args(["--ignore", "unresolved-reference"]), + @" + success: false + exit_code: 1 + ----- stdout ----- + warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero + --> test.py:2:5 + | + 2 | y = 4 / 0 + | ^^^^^ + | + + Found 1 diagnostic + + ----- stderr ----- + " + ); + + Ok(()) +} + /// Override with exclude patterns #[test] fn overrides_exclude() -> anyhow::Result<()> { diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs index 455a46ae69..0810f2f847 100644 --- a/crates/ty/tests/file_watching.rs +++ b/crates/ty/tests/file_watching.rs @@ -10,11 +10,9 @@ use ruff_db::system::{ file_time_now, }; use ruff_python_ast::PythonVersion; -use ruff_ranged_value::RangedValue; +use ruff_ranged_value::{RangedValue, ValueSource}; use ty_module_resolver::{Module, ModuleName, resolve_module_confident}; -use ty_project::metadata::options::{ - EnvironmentOptions, Options, ProjectOptionsOverrides, SrcOptions, -}; +use ty_project::metadata::options::{EnvironmentOptions, Options, SrcOptions}; use ty_project::metadata::pyproject::{PyProject, Tool}; use ty_project::metadata::python_version::SupportedPythonVersion; use ty_project::metadata::value::{RelativeGlobPattern, RelativePathBuf}; @@ -179,12 +177,8 @@ impl TestCase { Ok(all_events) } - fn apply_changes( - &mut self, - changes: &[ChangeEvent], - project_options_overrides: Option<&ProjectOptionsOverrides>, - ) -> ChangeResult { - self.db.apply_changes(changes, project_options_overrides) + fn apply_changes(&mut self, changes: &[ChangeEvent]) -> ChangeResult { + self.db.apply_changes(changes) } fn update_options(&mut self, options: Options) -> anyhow::Result<()> { @@ -199,7 +193,7 @@ impl TestCase { .context("Failed to write configuration")?; let changes = self.take_watch_changes(event_for_file("pyproject.toml")); - self.apply_changes(&changes, None); + self.apply_changes(&changes); if let Some(watcher) = &mut self.watcher { watcher.update(&self.db); @@ -280,6 +274,9 @@ struct SetupContext<'a> { system: &'a OsSystem, root_path: &'a SystemPath, options: Option, + config_file_override: Option, + override_options: Option, + fallback_options: Option, included_paths: Option>, } @@ -344,6 +341,18 @@ impl<'a> SetupContext<'a> { self.options = Some(options); } + fn set_config_file_override(&mut self, path: impl AsRef) { + self.config_file_override = Some(self.join_project_path(path)); + } + + fn set_override_options(&mut self, options: Options) { + self.override_options = Some(options); + } + + fn set_fallback_options(&mut self, options: Options) { + self.fallback_options = Some(options); + } + fn set_included_paths(&mut self, paths: Vec) { self.included_paths = Some(paths); } @@ -408,6 +417,9 @@ where system: &os_system, root_path: &root_path, options: None, + config_file_override: None, + override_options: None, + fallback_options: None, included_paths: None, }; @@ -415,7 +427,16 @@ where .setup(&mut setup_context) .context("Failed to setup test files")?; - if let Some(options) = setup_context.options { + let SetupContext { + options, + config_file_override, + override_options, + fallback_options, + included_paths, + .. + } = setup_context; + + if let Some(options) = options { std::fs::write( project_path.join("pyproject.toml").as_std_path(), toml::to_string(&PyProject { @@ -427,13 +448,22 @@ where .context("Failed to write configuration")?; } - let included_paths = setup_context.included_paths; - - let mut project = ProjectMetadata::discover(&project_path, &system)?; + let mut project = if let Some(config_file_override) = config_file_override { + ProjectMetadata::from_config_file(config_file_override, &project_path, &system)? + } else { + ProjectMetadata::discover(&project_path, &system)? + }; + if let Some(fallback_options) = fallback_options { + project.apply_fallback_options(fallback_options); + } project.apply_configuration_files(&system)?; + if let Some(override_options) = override_options { + project.apply_override_options(override_options); + } // We need a chance to create the directories here. - if let Some(environment) = project.options().environment.as_ref() { + let merged_options = project.to_merged_options(); + if let Some(environment) = merged_options.options().environment.as_ref() { for path in environment .extra_paths .as_deref() @@ -545,7 +575,7 @@ fn new_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let foo = case.system_file(&foo_path).expect("foo.py to exist."); @@ -583,7 +613,7 @@ fn new_directory_with_python_files() -> anyhow::Result<()> { ) }); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let init = case.system_file(&init_path).expect("__init__.py to exist"); let module = case.system_file(&module_path).expect("module.py to exist"); @@ -613,7 +643,7 @@ fn new_non_python_file_is_not_indexed() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("README.md")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&readme_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -635,7 +665,7 @@ fn new_ignored_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&foo_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -662,7 +692,7 @@ fn new_file_in_ignored_directory() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("bad.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&bad_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -688,7 +718,7 @@ fn new_file_in_parent_ignored_directory() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("bad.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&bad_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -713,7 +743,7 @@ fn new_ignore_file_in_ignored_directory() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file(".gitignore")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&nested_ignore_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -732,7 +762,7 @@ fn ignore_file_change_reloads_files_not_project_metadata() -> anyhow::Result<()> update_file(case.project_path(".ignore"), "")?; let changes = case.stop_watch(event_for_file(".ignore")); - let result = case.apply_changes(&changes, None); + let result = case.apply_changes(&changes); assert!(!result.project_changed()); case.assert_indexed_project_files([bar, foo]); @@ -796,7 +826,7 @@ fn new_non_project_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("black.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(case.system_file(&black_path).is_ok()); @@ -837,7 +867,7 @@ fn new_files_with_explicit_included_paths() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("test2.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist"); @@ -882,7 +912,7 @@ fn new_file_in_included_out_of_project_directory() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("script2.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let src_a_file = case.system_file(&src_a).unwrap(); let outside_b_file = case.system_file(&outside_b_path).unwrap(); @@ -909,7 +939,7 @@ fn changed_file() -> anyhow::Result<()> { assert!(!changes.is_empty()); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); case.assert_indexed_project_files([foo]); @@ -933,7 +963,7 @@ fn deleted_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(!foo.exists(case.db())); assert!(resolve_module_confident(case.db(), &ModuleName::new_static("foo").unwrap()).is_none()); @@ -966,7 +996,7 @@ fn move_file_to_trash() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(!foo.exists(case.db())); case.assert_indexed_project_files([]); @@ -993,7 +1023,7 @@ fn move_file_to_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let foo_in_project = case.system_file(&foo_in_project)?; @@ -1018,7 +1048,7 @@ fn rename_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("bar.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(!foo.exists(case.db())); @@ -1057,7 +1087,7 @@ fn directory_moved_to_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let init_file = case .system_file(sub_new_path.join("__init__.py")) @@ -1106,7 +1136,7 @@ fn directory_moved_to_trash() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); // `import sub.a` should no longer resolve assert!( @@ -1157,7 +1187,7 @@ fn directory_renamed() -> anyhow::Result<()> { // Linux and windows only emit an event for the newly created root directory, but not for every new component. let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); // `import sub.a` should no longer resolve assert!( @@ -1218,7 +1248,7 @@ fn directory_deleted() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - let result = case.apply_changes(&changes, None); + let result = case.apply_changes(&changes); assert!(!result.project_changed()); // `import sub.a` should no longer resolve @@ -1261,7 +1291,7 @@ fn search_path() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("a.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(resolve_module_confident(case.db(), &ModuleName::new_static("a").unwrap()).is_some()); case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]); @@ -1292,7 +1322,7 @@ fn add_search_path() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("a.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(resolve_module_confident(case.db(), &ModuleName::new_static("a").unwrap()).is_some()); @@ -1496,7 +1526,7 @@ fn changed_versions_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("VERSIONS")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert!(resolve_module_confident(case.db(), &ModuleName::new_static("os").unwrap()).is_some()); @@ -1550,7 +1580,7 @@ fn hard_links_in_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); @@ -1621,7 +1651,7 @@ fn hard_links_to_target_outside_project() -> anyhow::Result<()> { let changes = case.stop_watch(ChangeEvent::is_changed); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')"); @@ -1660,7 +1690,7 @@ mod unix { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!( foo.permissions(case.db()), @@ -1740,7 +1770,7 @@ mod unix { let changes = case.take_watch_changes(event_for_file("baz.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!( source_text(case.db(), baz_file).as_str(), @@ -1753,7 +1783,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!( source_text(case.db(), baz_file).as_str(), @@ -1821,7 +1851,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); // The file watcher is guaranteed to emit one event for the changed file, but it isn't specified // if the event is emitted for the "original" or linked path because both paths are watched. @@ -1935,7 +1965,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); assert_eq!( source_text(case.db(), baz_original_file).as_str(), @@ -1973,7 +2003,7 @@ fn active_project_config_change_reloads_project() -> anyhow::Result<()> { )?; let changes = case.stop_watch(event_for_file("pyproject.toml")); - let result = case.apply_changes(&changes, None); + let result = case.apply_changes(&changes); assert!(result.project_changed()); @@ -1993,7 +2023,7 @@ fn nested_project_config_change_is_cheap_if_active_project_unchanged() -> anyhow std::fs::write(nested_pyproject.as_std_path(), "[tool.ty]\n")?; let changes = case.stop_watch(event_for_file("pyproject.toml")); - let result = case.apply_changes(&changes, None); + let result = case.apply_changes(&changes); assert!(!result.project_changed()); assert_eq!(case.db().project().root(case.db()), &*project_root); @@ -2033,7 +2063,7 @@ fn nested_projects_delete_root() -> anyhow::Result<()> { let changes = case.stop_watch(ChangeEvent::is_deleted); - case.apply_changes(&changes, None); + case.apply_changes(&changes); // It should now pick up the outer project. assert_eq!(case.db().project().root(case.db()), case.root_path()); @@ -2097,9 +2127,9 @@ fn changes_to_user_configuration() -> anyhow::Result<()> { "#, )?; - let changes = case.stop_watch(event_for_file("ty.toml")); + let changes = case.take_watch_changes(event_for_file("ty.toml")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); let diagnostics = case.db().check_file(foo); @@ -2108,6 +2138,93 @@ fn changes_to_user_configuration() -> anyhow::Result<()> { "Expected exactly one diagnostic but got: {diagnostics:#?}" ); + // Removing the option from the user configuration must not retain the old warning level. + update_file(case.root_path().join("home/.config/ty/ty.toml"), "")?; + + let changes = case.stop_watch(event_for_file("ty.toml")); + case.apply_changes(&changes); + + let diagnostics = case.db().check_file(foo); + assert!( + diagnostics.is_empty(), + "Expected no diagnostics but got: {diagnostics:#?}" + ); + + Ok(()) +} + +#[test] +fn project_reload_preserves_override_options() -> anyhow::Result<()> { + let mut case = setup(|context: &mut SetupContext| { + context.write_project_file("foo.py", "prin('hello')")?; + context.set_options(Options::default()); + context.set_override_options(Options::from_toml_str( + r#" + [rules] + unresolved-reference = "ignore" + "#, + ValueSource::Cli, + )?); + Ok(()) + })?; + + let foo = case + .system_file(case.project_path("foo.py")) + .expect("foo.py to exist"); + assert!(case.db().check_file(foo).is_empty()); + + case.update_options(Options::from_toml_str( + r#" + [terminal] + error-on-warning = false + "#, + ValueSource::Cli, + )?)?; + + let diagnostics = case.db().check_file(foo); + assert!( + diagnostics.is_empty(), + "Expected override options to survive reload but got: {diagnostics:#?}" + ); + + Ok(()) +} + +#[test] +fn project_reload_preserves_fallback_options() -> anyhow::Result<()> { + let mut case = setup(|context: &mut SetupContext| { + context.write_project_file("foo.py", "a = 10 / 0")?; + context.set_options(Options::default()); + context.set_fallback_options(Options::from_toml_str( + r#" + [rules] + division-by-zero = "warn" + "#, + ValueSource::Editor, + )?); + Ok(()) + })?; + + let foo = case + .system_file(case.project_path("foo.py")) + .expect("foo.py to exist"); + assert_eq!(case.db().check_file(foo).len(), 1); + + case.update_options(Options::from_toml_str( + r#" + [terminal] + error-on-warning = false + "#, + ValueSource::Cli, + )?)?; + + let diagnostics = case.db().check_file(foo); + assert_eq!( + diagnostics.len(), + 1, + "Expected fallback options to survive reload but got: {diagnostics:#?}" + ); + Ok(()) } @@ -2134,6 +2251,7 @@ fn changes_to_config_file_override() -> anyhow::Result<()> { division-by-zero = "ignore" "#, )?; + context.set_config_file_override("ty-override.toml"); Ok(()) })?; @@ -2159,13 +2277,7 @@ fn changes_to_config_file_override() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("ty-override.toml")); - case.apply_changes( - &changes, - Some(&ProjectOptionsOverrides::new( - Some(case.project_path("ty-override.toml")), - Options::default(), - )), - ); + case.apply_changes(&changes); let diagnostics = case.db().check_file(foo); @@ -2230,7 +2342,7 @@ fn rename_files_casing_only() -> anyhow::Result<()> { .context("Failed to rename `temp.py` to `Lib.py`")?; let changes = case.stop_watch(event_for_file("Lib.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); // Resolving `lib` should now fail but `Lib` should now succeed assert_eq!( @@ -2260,7 +2372,7 @@ fn submodule_cache_invalidation_created() -> anyhow::Result<()> { std::fs::write(case.project_path("bar/wazoo.py").as_std_path(), "")?; let changes = case.stop_watch(event_for_file("wazoo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); insta::assert_snapshot!( case.sorted_submodule_names("bar").join("\n"), @@ -2294,7 +2406,7 @@ fn submodule_cache_invalidation_deleted() -> anyhow::Result<()> { std::fs::remove_file(case.project_path("bar/wazoo.py").as_std_path())?; let changes = case.stop_watch(event_for_file("wazoo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); insta::assert_snapshot!( case.sorted_submodule_names("bar").join("\n"), @@ -2317,11 +2429,11 @@ fn submodule_cache_invalidation_created_then_deleted() -> anyhow::Result<()> { std::fs::write(case.project_path("bar/wazoo.py").as_std_path(), "")?; let changes = case.take_watch_changes(event_for_file("wazoo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); std::fs::remove_file(case.project_path("bar/wazoo.py").as_std_path())?; let changes = case.stop_watch(event_for_file("wazoo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); insta::assert_snapshot!( case.sorted_submodule_names("bar").join("\n"), @@ -2347,7 +2459,7 @@ fn submodule_cache_invalidation_after_pyproject_created() -> anyhow::Result<()> std::fs::write(case.project_path("bar/wazoo.py").as_std_path(), "")?; let changes = case.take_watch_changes(event_for_file("wazoo.py")); - case.apply_changes(&changes, None); + case.apply_changes(&changes); insta::assert_snapshot!( case.sorted_submodule_names("bar").join("\n"), diff --git a/crates/ty_completion_bench/src/main.rs b/crates/ty_completion_bench/src/main.rs index 65d975c826..914fe754a3 100644 --- a/crates/ty_completion_bench/src/main.rs +++ b/crates/ty_completion_bench/src/main.rs @@ -1,6 +1,6 @@ -/*! -A simple command line tool for ad hoc completion benchmarking. -*/ +//! +//! A simple command line tool for ad hoc completion benchmarking. +//! // This is a developer tool and is therefore fine to use `eprintln!`. #![allow(clippy::print_stderr)] @@ -91,14 +91,13 @@ fn main() -> anyhow::Result { let system = OsSystem::new(&project_dir); let mut project_metadata = ProjectMetadata::discover(&project_dir, &system)?; // Explicitly point ty to the .venv to avoid any set VIRTUAL_ENV variable to take precedence. - project_metadata.apply_options(Options { + project_metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python: Some(RelativePathBuf::cli(".venv")), ..EnvironmentOptions::default() }), ..Options::default() }); - project_metadata.apply_configuration_files(&system)?; let db = ProjectDatabase::fallible(project_metadata, system)?; let start = std::time::Instant::now(); diff --git a/crates/ty_completion_eval/src/main.rs b/crates/ty_completion_eval/src/main.rs index d19ec12d52..87496beace 100644 --- a/crates/ty_completion_eval/src/main.rs +++ b/crates/ty_completion_eval/src/main.rs @@ -1,8 +1,6 @@ -/*! -A simple command line tool for running a completion evaluation. - -See `crates/ty_completion_eval/README.md` for examples and more docs. -*/ +//! A simple command line tool for running a completion evaluation. +//! +//! See `crates/ty_completion_eval/README.md` for examples and more docs. use std::io::Write; use std::process::ExitCode; @@ -282,14 +280,13 @@ impl Task { let system = OsSystem::new(project_path); let mut project_metadata = ProjectMetadata::discover(project_path, &system)?; // Explicitly point ty to the .venv to avoid any set VIRTUAL_ENV variable to take precedence. - project_metadata.apply_options(Options { + project_metadata.apply_override_options(Options { environment: Some(EnvironmentOptions { python: Some(RelativePathBuf::cli(".venv")), ..EnvironmentOptions::default() }), ..Options::default() }); - project_metadata.apply_configuration_files(&system)?; let db = ProjectDatabase::fallible(project_metadata, system)?; Ok(Task { db, diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index b8d2fb6fb9..474a7ed7f6 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -124,10 +124,11 @@ impl ProjectDatabase { // cache key before loading the DB. Because of that, access to the `db` (other than system and vendored) is // strictly forbidden before resolving the `program_settings`. + let merged_options = project_metadata.to_merged_options(); + // Initialize the `Program` singleton - let (program_settings, program_settings_diagnostics) = strategy.to_anyhow( - project_metadata.to_program_settings(db.system(), db.vendored(), strategy), - )?; + let (program_settings, program_settings_diagnostics) = strategy + .to_anyhow(merged_options.to_program_settings(db.system(), db.vendored(), strategy))?; // This must be called before `from_settings`, or the `SearchPath` root // will take precedence over the `Project` root, resulting in @@ -136,12 +137,10 @@ impl ProjectDatabase { Program::from_settings(&db, program_settings); - let (settings, settings_diagnostics) = strategy.map_err( - project_metadata - .options() - .to_settings(&db, project_metadata.root(), strategy), - |error| anyhow::anyhow!("{}", error.pretty(&db)), - )?; + let (settings, settings_diagnostics) = strategy + .map_err(merged_options.to_settings(&db, strategy), |error| { + anyhow::anyhow!("{}", error.pretty(&db)) + })?; db.project = Some(Project::from_metadata( &db, @@ -666,8 +665,8 @@ pub(crate) mod testing { }; let (settings, settings_diagnostics) = project - .options() - .to_settings(&db, project.root(), &FallibleStrategy) + .to_merged_options() + .to_settings(&db, &FallibleStrategy) .unwrap(); let project = Project::from_metadata(&db, project, settings, settings_diagnostics, Vec::new()); diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index 75e437c253..4f4e1a0d5c 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -1,5 +1,4 @@ use crate::db::{Db, ProjectDatabase}; -use crate::metadata::options::ProjectOptionsOverrides; use crate::watch::{ChangeEvent, CreatedKind, DeletedKind}; use crate::{ProjectMetadata, ProjectReloadResult}; use std::collections::BTreeSet; @@ -31,17 +30,11 @@ impl ChangeResult { } impl ProjectDatabase { - #[tracing::instrument(level = "debug", skip(self, changes, project_options_overrides))] - pub fn apply_changes( - &mut self, - changes: &[ChangeEvent], - project_options_overrides: Option<&ProjectOptionsOverrides>, - ) -> ChangeResult { + #[tracing::instrument(level = "debug", skip(self, changes))] + pub fn apply_changes(&mut self, changes: &[ChangeEvent]) -> ChangeResult { let project = self.project(); let project_root = project.root(self).to_path_buf(); - let config_file_override = - project_options_overrides.and_then(|options| options.config_file_override.clone()); - let extra_configuration_paths = project.metadata(self).extra_configuration_paths().to_vec(); + let configuration_paths = ConfigurationPaths::from_metadata(project.metadata(self)); let program = Program::get(self); let custom_stdlib_versions_path = program .custom_stdlib_search_path(self) @@ -73,12 +66,7 @@ impl ProjectDatabase { tracing::debug!("Handling file watcher change event: {:?}", change); if let Some(path) = change.system_path() { - if is_project_configuration_path( - path, - &project_root, - config_file_override.as_ref(), - &extra_configuration_paths, - ) { + if configuration_paths.is_configuration(path, &project_root) { File::sync_path(self, path); reload_project = true; @@ -211,12 +199,7 @@ impl ProjectDatabase { result.custom_stdlib_changed = true; } - if directory_may_contain_project_configuration( - path, - &project_root, - config_file_override.as_ref(), - &extra_configuration_paths, - ) { + if configuration_paths.may_contain_configuration(path, &project_root) { tracing::debug!( "Reload project because a configuration file may have been deleted." ); @@ -249,18 +232,7 @@ impl ProjectDatabase { Files::sync_all_recursive(self, sync_recursively); if reload_project { - // The active project root may have been deleted. Start rediscovery from - // the closest existing ancestor so ty can fall back to an enclosing project. - let rediscovery_path = project_root - .ancestors() - .find(|path| self.system().is_directory(path)) - .unwrap_or(&project_root); - let new_project_metadata = match config_file_override { - Some(config_file) => { - ProjectMetadata::from_config_file(config_file, &project_root, self.system()) - } - None => ProjectMetadata::discover(rediscovery_path, self.system()), - }; + let new_project_metadata = project.metadata(self).rediscover(self.system()); match new_project_metadata { Ok(mut metadata) => { if let Err(error) = metadata.apply_configuration_files(self.system()) { @@ -270,13 +242,10 @@ impl ProjectDatabase { ); } - if let Some(overrides) = project_options_overrides { - metadata.apply_overrides(overrides); - } - metadata.try_add_project_root(self); + let merged_options = metadata.to_merged_options(); - let program_settings_diagnostics = match metadata.to_program_settings( + let program_settings_diagnostics = match merged_options.to_program_settings( self.system(), self.vendored(), &FallibleStrategy, @@ -294,11 +263,9 @@ impl ProjectDatabase { } }; - let (settings, settings_diagnostics) = match metadata.options().to_settings( - self, - metadata.root(), - &FallibleStrategy, - ) { + let (settings, settings_diagnostics) = match merged_options + .to_settings(self, &FallibleStrategy) + { Ok((settings, diagnostics)) => (Some(settings), diagnostics), Err(error) => { tracing::warn!( @@ -348,21 +315,20 @@ impl ProjectDatabase { } if result.custom_stdlib_changed { - match project.metadata(self).to_program_settings( + let metadata = project.metadata(self); + let merged_options = metadata.to_merged_options(); + match merged_options.to_program_settings( self.system(), self.vendored(), &FallibleStrategy, ) { Ok((program_settings, program_settings_diagnostics)) => { + let settings_diagnostics = + match merged_options.to_settings(self, &FallibleStrategy) { + Ok((_, diagnostics)) => diagnostics, + Err(error) => vec![error.into_diagnostic()], + }; program.update_from_settings(self, program_settings); - let settings_diagnostics = match project.metadata(self).options().to_settings( - self, - project.metadata(self).root(), - &FallibleStrategy, - ) { - Ok((_, diagnostics)) => diagnostics, - Err(error) => vec![error.into_diagnostic()], - }; project.update_settings_diagnostics( self, settings_diagnostics, @@ -402,54 +368,53 @@ impl ProjectDatabase { } } -fn is_project_configuration_path( - path: &SystemPath, - project_root: &SystemPath, - config_file_override: Option<&SystemPathBuf>, - extra_configuration_paths: &[SystemPathBuf], -) -> bool { - if extra_configuration_paths - .iter() - .any(|config_path| config_path.as_path() == path) - { - return true; - } +struct ConfigurationPaths { + normal_discovery: bool, + extra: Box<[SystemPathBuf]>, +} - if let Some(config_path) = config_file_override { - config_path.as_path() == path - } else { - path.parent() - .is_some_and(|parent| project_root.starts_with(parent)) - && is_project_config_file(path) +impl ConfigurationPaths { + fn from_metadata(metadata: &ProjectMetadata) -> Self { + Self { + normal_discovery: metadata.config_file_override().is_none(), + extra: metadata + .extra_configuration_paths() + .map(SystemPath::to_path_buf) + .collect(), + } } -} -fn directory_may_contain_project_configuration( - directory: &SystemPath, - project_root: &SystemPath, - config_file_override: Option<&SystemPathBuf>, - extra_configuration_paths: &[SystemPathBuf], -) -> bool { - if extra_configuration_paths - .iter() - .any(|config_path| config_path.starts_with(directory)) - { - return true; + fn is_configuration(&self, path: &SystemPath, project_root: &SystemPath) -> bool { + if self + .extra + .iter() + .any(|config_path| config_path.as_path() == path) + { + return true; + } + + self.normal_discovery + && path + .parent() + .is_some_and(|parent| project_root.starts_with(parent)) + && matches!(path.file_name(), Some("ty.toml" | "pyproject.toml")) } - if let Some(config_path) = config_file_override { - config_path.starts_with(directory) - } else { + fn may_contain_configuration(&self, directory: &SystemPath, project_root: &SystemPath) -> bool { + if self + .extra + .iter() + .any(|config_path| config_path.starts_with(directory)) + { + return true; + } + // Deleting the project root or one of its ancestors can change rediscovery: // ty may need to fall back to an enclosing configuration. - project_root.starts_with(directory) + self.normal_discovery && project_root.starts_with(directory) } } fn is_ignore_file(path: &SystemPath) -> bool { matches!(path.file_name(), Some(".gitignore" | ".ignore")) } - -fn is_project_config_file(path: &SystemPath) -> bool { - matches!(path.file_name(), Some("ty.toml" | "pyproject.toml")) -} diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index f0e7e4ec09..03c43d7a8f 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -10,7 +10,6 @@ use ty_combine::Combine; use ty_python_core::program::{FallibleStrategy, MisconfigurationStrategy, ProgramSettings}; use crate::Db; -use crate::metadata::options::ProjectOptionsOverrides; use crate::metadata::options::{OptionDiagnostic, ProgramSettingsDiagnostic, ToSettingsError}; use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError}; use crate::metadata::settings::Settings; @@ -31,17 +30,33 @@ pub struct ProjectMetadata { pub(super) root: SystemPathBuf, - /// The raw options + /// The highest-precedence options, such as CLI flags or inline editor configuration. + #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))] + override_options: Option>, + + /// The raw (unmerged, unresolved) options from the project's configuration. + /// When [`Self::config_file_override`] is `None`, then these are the options from the + /// project's `ty.toml` or `pyproject.toml`. The options come from + /// the file specified by [`Self::config_file_override`] if it is `Some` (e.g. when using `--config-file `). pub(super) options: Options, - /// Paths of configurations other than the project's configuration that were combined into [`Self::options`]. + /// The user-level configuration path and its options. /// - /// This field stores the paths of the configuration files, mainly for - /// knowing which files to watch for changes. + /// Its options have lower precedence than [`Self::override_options`] and [`Self::options`], + /// but higher precedence than [`Self::fallback_options`]. + #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))] + user_configuration: Option>, + + /// The lowest-precedence options, such as the editor-selected Python environment. + #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))] + fallback_options: Option>, + + /// The explicit configuration file that replaces normal project discovery. /// - /// The path ordering doesn't imply precedence. - #[cfg_attr(test, serde(skip_serializing_if = "Vec::is_empty"))] - pub(super) extra_configuration_paths: Vec, + /// Can be specified using `--config-file `. When `Some`, [`Self::options`] were loaded from this file + /// instead of from the project's `pyproject.toml` or `ty.toml` file. + #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))] + config_file_override: Option, } impl ProjectMetadata { @@ -50,8 +65,11 @@ impl ProjectMetadata { Self { name: ProjectName::new(name), root, - extra_configuration_paths: Vec::default(), options: Options::default(), + override_options: None, + user_configuration: None, + fallback_options: None, + config_file_override: None, } } @@ -75,7 +93,10 @@ impl ProjectMetadata { name: ProjectName::new(root.file_name().unwrap_or("root")), root: root.to_path_buf(), options, - extra_configuration_paths: vec![path], + override_options: None, + user_configuration: None, + fallback_options: None, + config_file_override: Some(path), }) } @@ -130,7 +151,10 @@ impl ProjectMetadata { name, root, options, - extra_configuration_paths: Vec::new(), + override_options: None, + user_configuration: None, + fallback_options: None, + config_file_override: None, }) } @@ -264,6 +288,27 @@ impl ProjectMetadata { Ok(metadata) } + /// Rediscovers the project, while preserving applied options. + pub(crate) fn rediscover(&self, system: &dyn System) -> Result { + let mut metadata = if let Some(config_file) = self.config_file_override() { + Self::from_config_file(config_file.to_path_buf(), self.root(), system)? + } else { + // The active project root may have been deleted. Start rediscovery from the closest + // existing ancestor so ty can fall back to an enclosing project. + let rediscovery_path = self + .root() + .ancestors() + .find(|path| system.is_directory(path)) + .unwrap_or_else(|| self.root()); + Self::discover(rediscovery_path, system)? + }; + + metadata.override_options.clone_from(&self.override_options); + metadata.fallback_options.clone_from(&self.fallback_options); + + Ok(metadata) + } + pub fn root(&self) -> &SystemPath { &self.root } @@ -276,8 +321,18 @@ impl ProjectMetadata { &self.options } - pub fn extra_configuration_paths(&self) -> &[SystemPathBuf] { - &self.extra_configuration_paths + /// Returns the explicit configuration file that replaces normal project discovery, if any. + pub(crate) fn config_file_override(&self) -> Option<&SystemPath> { + self.config_file_override.as_deref() + } + + /// Returns configuration paths outside normal project discovery that should be watched. + pub fn extra_configuration_paths(&self) -> impl Iterator { + self.config_file_override().into_iter().chain( + self.user_configuration + .as_deref() + .map(|(path, _)| path.as_path()), + ) } pub(crate) fn try_add_project_root(&self, db: &dyn Db) { @@ -289,35 +344,59 @@ impl ProjectMetadata { .try_add_root(db, self.root(), FileRootKind::Project); } - pub fn to_program_settings( - &self, - system: &dyn System, - vendored: &VendoredFileSystem, - strategy: &Strategy, - ) -> Result<(ProgramSettings, Vec), Strategy::Error> - { - self.options - .to_program_settings(self.root(), self.name(), system, vendored, strategy) - } - - pub fn to_settings( - &self, - db: &dyn Db, - strategy: &Strategy, - ) -> Result<(Settings, Vec), Strategy::Error> { - self.options.to_settings(db, self.root(), strategy) + /// Applies higher-precedence options to this project. + /// + /// Options applied later take precedence over options applied earlier. + pub fn apply_override_options(&mut self, options: Options) { + if let Some(existing) = self.override_options.as_mut() { + let previous = std::mem::replace(existing.as_mut(), options); + existing.combine_with(previous); + } else { + self.override_options = Some(Box::new(options)); + } } - pub fn apply_overrides(&mut self, overrides: &ProjectOptionsOverrides) { - self.options = overrides.apply_to(std::mem::take(&mut self.options)); + /// Applies lower-precedence options to this project. + /// + /// Options applied later take precedence over options applied earlier, but all fallback options + /// have lower precedence than the raw and user-level options. + pub fn apply_fallback_options(&mut self, options: Options) { + if let Some(existing) = self.fallback_options.as_mut() { + let previous = std::mem::replace(existing.as_mut(), options); + existing.combine_with(previous); + } else { + self.fallback_options = Some(Box::new(options)); + } } - /// Combine the project options with the CLI options where the CLI options take precedence. - pub fn apply_options(&mut self, options: Options) { - self.options = options.combine(std::mem::take(&mut self.options)); + /// Returns the project's option layers from highest to lowest precedence. + /// + /// `options` is used as the raw base layer between the override and user-level options. + /// Layers can be merged by passing them to [`Options::combine_with`] in iterator order: + /// + /// ```ignore + /// let mut merged = Options::default(); + /// for layer in metadata.options_in_precedence_order(metadata.options()) { + /// merged.combine_with(layer.clone()); + /// } + /// ``` + pub(crate) fn options_in_precedence_order<'a>( + &'a self, + options: &'a Options, + ) -> impl Iterator { + self.override_options + .as_deref() + .into_iter() + .chain(std::iter::once(options)) + .chain( + self.user_configuration + .as_deref() + .map(|(_, options)| options), + ) + .chain(self.fallback_options.as_deref()) } - /// Applies the options from the configuration files to the project's options. + /// Loads the lower-precedence options from configuration files. /// /// This includes: /// @@ -326,22 +405,68 @@ impl ProjectMetadata { &mut self, system: &dyn System, ) -> Result<(), ConfigurationFileError> { + self.user_configuration = None; + if let Some(user) = ConfigurationFile::user(system)? { tracing::debug!( "Applying user-level configuration loaded from `{path}`.", path = user.path() ); - self.apply_configuration_file(user); + self.user_configuration = Some(Box::new((user.path().to_owned(), user.into_options()))); } Ok(()) } - /// Applies a lower-precedence configuration files to the project's options. - fn apply_configuration_file(&mut self, options: ConfigurationFile) { - self.extra_configuration_paths - .push(options.path().to_owned()); - self.options.combine_with(options.into_options()); + /// Returns all option layers merged according to their precedence. + pub fn to_merged_options(&self) -> MergedOptions<'_> { + let mut options = Options::default(); + + for layer in self.options_in_precedence_order(&self.options) { + options.combine_with(layer.clone()); + } + + MergedOptions { + metadata: self, + options, + } + } +} + +/// The merged options for a project and the metadata needed to resolve them. +pub struct MergedOptions<'a> { + metadata: &'a ProjectMetadata, + options: Options, +} + +impl MergedOptions<'_> { + /// Returns the merged raw options. + pub fn options(&self) -> &Options { + &self.options + } + + pub fn to_program_settings( + &self, + system: &dyn System, + vendored: &VendoredFileSystem, + strategy: &Strategy, + ) -> Result<(ProgramSettings, Vec), Strategy::Error> + { + self.options.to_program_settings( + self.metadata.root(), + self.metadata.name(), + system, + vendored, + strategy, + ) + } + + pub fn to_settings( + &self, + db: &dyn Db, + strategy: &Strategy, + ) -> Result<(Settings, Vec), Strategy::Error> { + self.options.to_settings(db, self.metadata.root(), strategy) } } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index f5917229bf..c23fe72a19 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -2186,40 +2186,6 @@ impl OptionDiagnostic { } } -/// This is a wrapper for options that actually get loaded from configuration files -/// and the CLI, which also includes a `config_file_override` option that overrides -/// default configuration discovery with an explicitly-provided path to a configuration file -#[derive(Debug, Default, PartialEq, Eq, Clone)] -pub struct ProjectOptionsOverrides { - pub config_file_override: Option, - pub fallback_python_version: Option>, - pub fallback_python: Option, - pub options: Options, -} - -impl ProjectOptionsOverrides { - pub fn new(config_file_override: Option, options: Options) -> Self { - Self { - config_file_override, - options, - ..Self::default() - } - } - - pub fn apply_to(&self, options: Options) -> Options { - let mut combined = self.options.clone().combine(options); - - // Set the fallback python version and path if set - combined.environment.combine_with(Some(EnvironmentOptions { - python_version: self.fallback_python_version.clone(), - python: self.fallback_python.clone(), - ..EnvironmentOptions::default() - })); - - combined - } -} - trait OrDefault { type Target: ToOwned; diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index eaf9966f40..28a35021da 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -188,12 +188,14 @@ fn merge_overrides(db: &dyn Db, overrides: Vec>, _: () merged.combine_with((*option).clone()); } - let global_options = db.project().metadata(db).options(); + let metadata = db.project().metadata(db); - merged.rules.combine_with(global_options.rules.clone()); - merged - .analysis - .combine_with(global_options.analysis.clone()); + // Merge with the project level options by replaying the individual options + // in the correct precedence order. + for options in metadata.options_in_precedence_order(metadata.options()) { + merged.rules.combine_with(options.rules.clone()); + merged.analysis.combine_with(options.analysis.clone()); + } if merged.rules.is_none() && merged.analysis.is_none() { return FileSettings::Global; diff --git a/crates/ty_project/src/watch/project_watcher.rs b/crates/ty_project/src/watch/project_watcher.rs index e1f39dad93..92d7451d83 100644 --- a/crates/ty_project/src/watch/project_watcher.rs +++ b/crates/ty_project/src/watch/project_watcher.rs @@ -69,12 +69,7 @@ impl ProjectWatcher { self.has_errored_paths = false; - let config_paths = db - .project() - .metadata(db) - .extra_configuration_paths() - .iter() - .map(SystemPathBuf::as_path); + let config_paths = db.project().metadata(db).extra_configuration_paths(); // Watch both the project root and any paths provided by the user on the CLI (removing any redundant nested paths). // This is necessary to observe changes to files that are outside the project root. diff --git a/crates/ty_server/src/session.rs b/crates/ty_server/src/session.rs index 03df62fd54..8493677cb2 100644 --- a/crates/ty_server/src/session.rs +++ b/crates/ty_server/src/session.rs @@ -413,18 +413,9 @@ impl Session { path: &AnySystemPath, changes: &[ChangeEvent], ) -> ChangeResult { - let overrides = path.as_system().and_then(|root| { - self.workspaces() - .for_path(root)? - .settings() - .project_options_overrides() - .cloned() - }); - self.bump_revision(); - self.project_db_mut(path) - .apply_changes(changes, overrides.as_ref()) + self.project_db_mut(path).apply_changes(changes) } /// Returns a mutable iterator over all project databases. @@ -594,10 +585,7 @@ impl Session { self.native_system.clone(), ); - let configuration_file = workspace - .settings - .project_options_overrides() - .and_then(|settings| settings.config_file_override.as_ref()); + let configuration_file = workspace.settings.configuration_file(); let metadata = if let Some(configuration_file) = configuration_file { ProjectMetadata::from_config_file(configuration_file.clone(), &root, &system) @@ -608,12 +596,16 @@ impl Session { let project = metadata .context("Failed to discover project configuration") .and_then(|mut metadata| { + if let Some(fallback_options) = workspace.settings.fallback_options() { + metadata.apply_fallback_options(fallback_options.clone()); + } + metadata .apply_configuration_files(&system) .context("Failed to apply configuration files")?; - if let Some(overrides) = workspace.settings.project_options_overrides() { - metadata.apply_overrides(overrides); + if let Some(override_options) = workspace.settings.override_options() { + metadata.apply_override_options(override_options.clone()); } ProjectDatabase::fallible(metadata, system.clone()) diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index a901a22416..18848bb40f 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -12,7 +12,7 @@ use ty_combine::Combine; use ty_ide::{CompletionSettings, InlayHintSettings}; use ty_project::CheckMode; use ty_project::metadata::Options as TyOptions; -use ty_project::metadata::options::ProjectOptionsOverrides; +use ty_project::metadata::options::EnvironmentOptions; use ty_project::metadata::python_version::SupportedPythonVersion; use ty_project::metadata::value::RelativePathBuf; @@ -256,23 +256,25 @@ impl WorkspaceOptions { } }); - let mut overrides = - ProjectOptionsOverrides::new(configuration_file, options_overrides.unwrap_or_default()); + let override_options = options_overrides + .filter(|options| options != &TyOptions::default()) + .map(Box::new); + let mut fallback_environment = EnvironmentOptions::default(); if let Some(extension) = self.python_extension && let Some(active_environment) = extension.active_environment { - overrides.fallback_python = Some(RelativePathBuf::python_extension( + fallback_environment.python = Some(RelativePathBuf::python_extension( active_environment.executable.sys_prefix, )); - overrides.fallback_python_version = active_environment + fallback_environment.python_version = active_environment .version .as_ref() .and_then(resolve_editor_python_version) .map(RangedValue::python_extension); - if let Some(python) = &overrides.fallback_python { + if let Some(python) = &fallback_environment.python { tracing::debug!( "Using the Python environment selected in your editor \ in case the configuration doesn't specify a Python environment: {python}", @@ -280,7 +282,7 @@ impl WorkspaceOptions { ); } - if let Some(version) = &overrides.fallback_python_version { + if let Some(version) = &fallback_environment.python_version { tracing::debug!( "Using the Python version selected in your editor: {version} \ in case the configuration doesn't specify a Python version", @@ -288,10 +290,13 @@ impl WorkspaceOptions { } } - let overrides = if overrides == ProjectOptionsOverrides::default() { + let fallback_options = if fallback_environment == EnvironmentOptions::default() { None } else { - Some(overrides) + Some(Box::new(TyOptions { + environment: Some(fallback_environment), + ..TyOptions::default() + })) }; WorkspaceSettings { @@ -304,7 +309,9 @@ impl WorkspaceOptions { .completions .map(CompletionOptions::into_settings) .unwrap_or_default(), - overrides, + configuration_file, + override_options, + fallback_options, } } } diff --git a/crates/ty_server/src/session/settings.rs b/crates/ty_server/src/session/settings.rs index eada1c20e4..b7d8a893d0 100644 --- a/crates/ty_server/src/session/settings.rs +++ b/crates/ty_server/src/session/settings.rs @@ -1,6 +1,8 @@ -use super::options::DiagnosticMode; +use ruff_db::system::SystemPathBuf; use ty_ide::{CompletionSettings, InlayHintSettings}; -use ty_project::metadata::options::ProjectOptionsOverrides; +use ty_project::metadata::Options; + +use super::options::DiagnosticMode; /// Resolved client settings that are shared across all workspaces. #[derive(Clone, Default, Debug, PartialEq)] @@ -32,7 +34,9 @@ pub(crate) struct WorkspaceSettings { pub(super) disable_language_services: bool, pub(super) inlay_hints: InlayHintSettings, pub(super) completions: CompletionSettings, - pub(super) overrides: Option, + pub(super) configuration_file: Option, + pub(super) override_options: Option>, + pub(super) fallback_options: Option>, } impl WorkspaceSettings { @@ -40,8 +44,16 @@ impl WorkspaceSettings { self.disable_language_services } - pub(crate) fn project_options_overrides(&self) -> Option<&ProjectOptionsOverrides> { - self.overrides.as_ref() + pub(crate) fn configuration_file(&self) -> Option<&SystemPathBuf> { + self.configuration_file.as_ref() + } + + pub(crate) fn override_options(&self) -> Option<&Options> { + self.override_options.as_deref() + } + + pub(crate) fn fallback_options(&self) -> Option<&Options> { + self.fallback_options.as_deref() } pub(crate) fn inlay_hints(&self) -> &InlayHintSettings { diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index fe4a338748..da77c081a3 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -24,7 +24,9 @@ Settings: WorkspaceSettings { auto_import: true, complete_function_parentheses: false, }, - overrides: None, + configuration_file: None, + override_options: None, + fallback_options: None, } Project at XXX diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index f5163a1ebd..35e0b6d6de 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -165,12 +165,13 @@ impl Workspace { ) .map_err(into_error)?; - let (program_settings, program_settings_diagnostics) = project + let merged_options = project.to_merged_options(); + let (program_settings, program_settings_diagnostics) = merged_options .to_program_settings(&self.system, self.db.vendored(), &FallibleStrategy) .map_err(into_error)?; Program::get(&self.db).update_from_settings(&mut self.db, program_settings); - let (settings, settings_diagnostics) = project + let (settings, settings_diagnostics) = merged_options .to_settings(&self.db, &FallibleStrategy) .map_err(into_error)?; @@ -194,13 +195,10 @@ impl Workspace { .write_file_all(&path, contents) .map_err(into_error)?; - self.db.apply_changes( - &[ChangeEvent::Created { - path: path.clone(), - kind: CreatedKind::File, - }], - None, - ); + self.db.apply_changes(&[ChangeEvent::Created { + path: path.clone(), + kind: CreatedKind::File, + }]); let file = system_path_to_file(&self.db, &path).expect("File to exist"); @@ -227,19 +225,16 @@ impl Workspace { .write_file(system_path, contents) .map_err(into_error)?; - self.db.apply_changes( - &[ - ChangeEvent::Changed { - path: system_path.to_path_buf(), - kind: ChangedKind::FileContent, - }, - ChangeEvent::Changed { - path: system_path.to_path_buf(), - kind: ChangedKind::FileMetadata, - }, - ], - None, - ); + self.db.apply_changes(&[ + ChangeEvent::Changed { + path: system_path.to_path_buf(), + kind: ChangedKind::FileContent, + }, + ChangeEvent::Changed { + path: system_path.to_path_buf(), + kind: ChangedKind::FileMetadata, + }, + ]); Ok(()) } @@ -261,13 +256,10 @@ impl Workspace { .remove_file(system_path) .map_err(into_error)?; - self.db.apply_changes( - &[ChangeEvent::Deleted { - path: system_path.to_path_buf(), - kind: DeletedKind::File, - }], - None, - ); + self.db.apply_changes(&[ChangeEvent::Deleted { + path: system_path.to_path_buf(), + kind: DeletedKind::File, + }]); } Ok(()) From 937ac6e6f57656928fa587705495d84a4b6f8028 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 07:22:53 -0400 Subject: [PATCH 013/222] [ty] Guard descriptor classification cycles (#26690) ## Summary Our data-descriptor predicates recursively inspect outer unions, intersections, and type aliases. An unguarded recursive alias can therefore revisit the same classification: ```python class Descriptor: def __set__(self, instance: object, value: object) -> None: ... type NonData = int | NonDataTail type NonDataTail = NonData type Data = Descriptor | DataTail type DataTail = Data ``` We now evaluate both descriptor classifiers as Salsa queries and route alias expansion back through those queries. Definite classifications use `true`, the identity for their all-of union folds, as cycle recovery; possible-data classification uses `false`, the identity for its any-of fold. The regression test exercises `is_data_descriptor`, `may_be_data_descriptor`, and `is_definitely_non_data_descriptor` for recursive aliases with and without a reachable data descriptor. --- crates/ty_python_semantic/src/types.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 55b6d51d1b..0e9b068f58 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3523,20 +3523,37 @@ impl<'db> Type<'db> { /// Descriptor uncertainty only propagates through outer unions, intersections, and aliases; /// type arguments do not affect the runtime descriptor class. pub(crate) fn is_definitely_non_data_descriptor(self, db: &'db dyn Db) -> bool { + self.is_definitely_non_data_descriptor_impl(db, ()) + } + + // Recursive aliases use `true`, the identity for the all-of classifications above. + #[salsa::tracked( + cycle_initial=|_, _, _, ()| true, + heap_size=ruff_memory_usage::heap_size + )] + fn is_definitely_non_data_descriptor_impl(self, db: &'db dyn Db, (): ()) -> bool { match self { Type::Dynamic(_) | Type::Divergent(_) | Type::TypeVar(_) => false, Type::Union(union) => union .elements(db) .iter() - .all(|ty| ty.is_definitely_non_data_descriptor(db)), + .all(|ty| ty.is_definitely_non_data_descriptor_impl(db, ())), Type::Intersection(intersection) => intersection .iter_positive(db) - .all(|ty| ty.is_definitely_non_data_descriptor(db)), - Type::TypeAlias(alias) => alias.value_type(db).is_definitely_non_data_descriptor(db), + .all(|ty| ty.is_definitely_non_data_descriptor_impl(db, ())), + Type::TypeAlias(alias) => alias + .value_type(db) + .is_definitely_non_data_descriptor_impl(db, ()), _ => !self.may_be_data_descriptor(db), } } + // Definite data descriptors use an all-of union fold; possible data descriptors use any-of. + // Seed recursive aliases with the corresponding identity value. + #[salsa::tracked( + cycle_initial=|_, _, _, any_of_union: bool| !any_of_union, + heap_size=ruff_memory_usage::heap_size + )] fn is_data_descriptor_impl(self, db: &'db dyn Db, any_of_union: bool) -> bool { match self { Type::Dynamic(_) => !any_of_union, @@ -3553,6 +3570,9 @@ impl<'db> Type<'db> { Type::Intersection(intersection) => intersection .iter_positive(db) .any(|ty| ty.is_data_descriptor_impl(db, any_of_union)), + Type::TypeAlias(alias) => alias + .value_type(db) + .is_data_descriptor_impl(db, any_of_union), _ => { !self .class_member_with_policy( From 4d37f1b633aaff05282755a769ed0b33e25627fa Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 10 Jul 2026 13:37:29 +0200 Subject: [PATCH 014/222] [ty] Pydantic: Support field metadata in `Annotated` (#26650) ## Summary Add support for `Annotated[T, Field(...)]`, `Strict(..)` and (transitively) also support for aliases like `StrictInt`. One challenge here is that aliases like `StrictInt` are defined as `StrictInt = Annotated[int, Strict()]`. When we see `StrictInt` in a field annotation, we have already lost the `Annotated` metadata. For now, we simply resolve this by following the alias back to it's original definition, since it seems wrong to store `Annotated` metadata (or a link back to its definition) inside the type for this specific use-case only. closes https://github.com/astral-sh/ty/issues/2130 closes https://github.com/astral-sh/ty/issues/3948 towards https://github.com/astral-sh/ty/issues/2403 ## Test Plan New and updated Markdown tests. --- .../resources/mdtest/external/pydantic.md | 78 +++++- .../src/types/class/known.rs | 31 ++- .../src/types/class/static_literal.rs | 12 +- .../src/types/dedicated/pydantic.rs | 258 +++++++++++++++++- .../src/types/infer/builder.rs | 3 + 5 files changed, 364 insertions(+), 18 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index fcb41a25e0..be6af80a8f 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -625,6 +625,43 @@ Person3(age=20) Person3(age="20") # error: [invalid-argument-type] ``` +Pydantic's strict aliases and `Strict()` metadata also enable strict validation for individual +fields: + +```py +from typing import Annotated +from pydantic import Field, Strict, StrictInt + +class StrictFields(BaseModel): + strict_int: StrictInt + strict_str: Annotated[str, Strict()] + +StrictFields(strict_int=1, strict_str="foo") +StrictFields(strict_int="1", strict_str="foo") # error: [invalid-argument-type] +StrictFields(strict_int=1, strict_str=b"foo") # error: [invalid-argument-type] + +class StrictMetadataOrder(BaseModel): + field_then_lax: Annotated[int, Field(strict=True), Strict(False)] + lax_then_field: Annotated[int, Strict(False), Field(strict=True)] + +StrictMetadataOrder(field_then_lax="1", lax_then_field=1) +StrictMetadataOrder(field_then_lax=1, lax_then_field="1") # error: [invalid-argument-type] +``` + +A field with `Strict(False)` can opt out of strict validation, even in a model with `strict=True`: + +```py +class LaxFieldInStrictModel(BaseModel): + model_config = ConfigDict(strict=True) + + strict_int: int + lax_int: Annotated[int, Strict(False)] + +LaxFieldInStrictModel(strict_int=1, lax_int=1) +LaxFieldInStrictModel(strict_int=1, lax_int="1") +LaxFieldInStrictModel(strict_int="1", lax_int=1) # error: [invalid-argument-type] +``` + ## `validate_by_name`, `validate_by_alias` By default, Pydantic only allows a field to be initialized by its alias name, not by its field name: @@ -794,17 +831,50 @@ class Person(BaseModel): id: Annotated[int, Field(default=0)] Person(name="Alice", id=1) -# TODO: This should not be an error -# error: [invalid-argument-type] Person(name=b"Alice", id=1) -# TODO: This should not be an error -# error: [missing-argument] Person(name="Alice") Person(name=None, id=1) # error: [invalid-argument-type] Person(id=1) # error: [missing-argument] ``` +Multiple `Field(...)` calls in `Annotated[...]` are merged: + +```py +class MultipleAnnotatedFields(BaseModel): + strict_then_default: Annotated[int, Field(strict=True), Field(default=0)] + default_then_strict: Annotated[int, Field(default=0), Field(strict=True)] + +MultipleAnnotatedFields() +MultipleAnnotatedFields(strict_then_default=1, default_then_strict=1) +MultipleAnnotatedFields(strict_then_default="1") # error: [invalid-argument-type] +MultipleAnnotatedFields(default_then_strict="1") # error: [invalid-argument-type] +``` + +Field metadata in the annotation and on the right hand side is also merged: + +```py +class AnnotatedAndAssignedFields(BaseModel): + strict_then_default: Annotated[int, Field(strict=True)] = Field(default=0) + default_then_strict: Annotated[int, Field(default=0)] = Field(strict=True) + +AnnotatedAndAssignedFields() +AnnotatedAndAssignedFields(strict_then_default=1, default_then_strict=1) +AnnotatedAndAssignedFields(strict_then_default="1") # error: [invalid-argument-type] +AnnotatedAndAssignedFields(default_then_strict="1") # error: [invalid-argument-type] +``` + +Field metadata is also collected through aliases: + +```py +AliasField = Annotated[int, Field(default=0)] + +class ModelWithAliasField(BaseModel): + value: AliasField + +ModelWithAliasField() +``` + ## Frozen models and fields There are various ways to make a field immutable. A model can be globally frozen using a class diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 2c8176fb55..406c3793d7 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -158,6 +158,7 @@ pub enum KnownClass { PydanticBaseSettings, PydanticConfigDict, PydanticRootModel, + PydanticStrict, } impl KnownClass { @@ -288,7 +289,8 @@ impl KnownClass { | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict - | Self::PydanticRootModel => Some(Truthiness::Ambiguous), + | Self::PydanticRootModel + | Self::PydanticStrict => Some(Truthiness::Ambiguous), Self::Tuple => None, } @@ -400,7 +402,8 @@ impl KnownClass { | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict - | KnownClass::PydanticRootModel => false, + | KnownClass::PydanticRootModel + | KnownClass::PydanticStrict => false, } } @@ -508,7 +511,8 @@ impl KnownClass { | KnownClass::FunctoolsPartial | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings - | KnownClass::PydanticRootModel => false, + | KnownClass::PydanticRootModel + | KnownClass::PydanticStrict => false, KnownClass::PydanticConfigDict => true, } @@ -618,7 +622,8 @@ impl KnownClass { | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict - | KnownClass::PydanticRootModel => false, + | KnownClass::PydanticRootModel + | KnownClass::PydanticStrict => false, } } @@ -739,7 +744,8 @@ impl KnownClass { | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict - | Self::PydanticRootModel => false, + | Self::PydanticRootModel + | Self::PydanticStrict => false, } } @@ -849,7 +855,8 @@ impl KnownClass { | KnownClass::PydanticBaseModel | KnownClass::PydanticBaseSettings | KnownClass::PydanticConfigDict - | KnownClass::PydanticRootModel => false, + | KnownClass::PydanticRootModel + | KnownClass::PydanticStrict => false, KnownClass::NamedTupleFallback | KnownClass::TypedDictFallback | KnownClass::ExtensionTypedDictFallback => true, @@ -973,6 +980,7 @@ impl KnownClass { Self::PydanticBaseSettings => "BaseSettings", Self::PydanticConfigDict => "ConfigDict", Self::PydanticRootModel => "RootModel", + Self::PydanticStrict => "Strict", } } @@ -1359,6 +1367,7 @@ impl KnownClass { Self::PydanticBaseSettings => KnownModule::PydanticSettingsMain, Self::PydanticConfigDict => KnownModule::PydanticConfig, Self::PydanticRootModel => KnownModule::PydanticRootModel, + Self::PydanticStrict => KnownModule::PydanticTypes, } } @@ -1469,7 +1478,8 @@ impl KnownClass { | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict - | Self::PydanticRootModel => Some(false), + | Self::PydanticRootModel + | Self::PydanticStrict => Some(false), Self::Tuple => None, } @@ -1583,7 +1593,8 @@ impl KnownClass { | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict - | Self::PydanticRootModel => false, + | Self::PydanticRootModel + | Self::PydanticStrict => false, } } @@ -1697,6 +1708,7 @@ impl KnownClass { "BaseSettings" => &[Self::PydanticBaseSettings], "ConfigDict" => &[Self::PydanticConfigDict], "RootModel" => &[Self::PydanticRootModel], + "Strict" => &[Self::PydanticStrict], _ => return None, }; @@ -1796,7 +1808,8 @@ impl KnownClass { | Self::PydanticBaseModel | Self::PydanticBaseSettings | Self::PydanticConfigDict - | Self::PydanticRootModel => module == self.canonical_module(db), + | Self::PydanticRootModel + | Self::PydanticStrict => module == self.canonical_module(db), Self::NoneType => matches!(module, KnownModule::Typeshed | KnownModule::Types), Self::SpecialForm | Self::TypeAliasType diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 87692a8743..8e66c80b4f 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -2185,13 +2185,21 @@ impl<'db> StaticClassLiteral<'db> { let mut alias = None; let mut converter = None; let mut strict = pydantic::ConfigBoolean::Unspecified; - if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty { + if field_policy.is_pydantic() { + let metadata = + pydantic::field_metadata(db, first_declaration, default_ty, specialization); + default_ty = metadata.default_ty; + init = metadata.init; + alias = metadata.alias; + strict = metadata.strict; + } else if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = + default_ty + { default_ty = field.default_type(db); init = field.init(db); kw_only = field.kw_only(db); alias.clone_from(field.alias(db)); converter = field.converter(db); - strict = field.strict(db); } let kind = match field_policy { diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index d0a782bca4..4baba9b692 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -1,18 +1,21 @@ use ruff_db::parsed::parsed_module; -use ruff_python_ast::{ExprDict, Keyword, name::Name}; +use ruff_python_ast::{Expr, ExprCall, ExprDict, Keyword, name::Name}; use rustc_hash::FxHashSet; use ty_module_resolver::{KnownModule, file_to_module}; use ty_python_core::definition::{Definition, DefinitionKind}; -use crate::Db; use crate::place::{DefinedPlace, Definedness, Place, Provenance, known_module_symbol}; use crate::types::class::CodeGeneratorKind; +use crate::types::ide_support::{ImportAliasResolution, definitions_for_name}; +use crate::types::known_instance::FieldInstance; use crate::types::member::class_member; +use crate::types::special_form::SpecialFormType; use crate::types::{ ClassBase, DataclassTransformerParams, FunctionType, KnownClass, KnownFunction, - KnownInstanceType, KnownUnion, Parameter, StaticClassLiteral, Type, UnionType, + KnownInstanceType, KnownUnion, Parameter, Specialization, StaticClassLiteral, Type, UnionType, definition_expression_type, }; +use crate::{Db, SemanticModel}; /// Metadata that controls Pydantic-specific model synthesis. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] @@ -64,6 +67,255 @@ impl<'db> ModelMetadata<'db> { } } +/// Pydantic-specific metadata resolved from a field's annotation (via `Annotation`) +/// and right-hand side `Field(...)` specifier. +/// +/// For example: +/// ```py +/// class Model(BaseModel): +/// value: Annotated[int, Strict()] = Field(default=0) +/// ``` +pub(in crate::types) struct FieldMetadata<'db> { + pub(in crate::types) default_ty: Option>, + pub(in crate::types) init: bool, + pub(in crate::types) alias: Option>, + pub(in crate::types) strict: ConfigBoolean, +} + +impl Default for FieldMetadata<'_> { + fn default() -> Self { + Self { + default_ty: None, + init: true, + alias: None, + strict: ConfigBoolean::Unspecified, + } + } +} + +impl<'db> FieldMetadata<'db> { + /// Collect Pydantic field metadata from the right-hand side of a field's assignment. + /// + /// For example, collect the default value and alias from the following field assignment: + /// ```py + /// field: int = Field(default=0, alias="field_alias") + /// ``` + fn collect_from_rhs_type( + &mut self, + db: &'db dyn Db, + rhs_type: Option>, + specialization: Option>, + ) { + match rhs_type { + Some(Type::KnownInstance(KnownInstanceType::Field(field))) => { + self.merge_field(db, field, specialization); + } + Some(rhs_type) => self.default_ty = Some(rhs_type), + None => {} + } + } + + /// Collect Pydantic field metadata from a field's annotation. + /// + /// For example, collect the strictness metadata from the following field annotation: + /// ```py + /// field: Annotated[int, Strict()] + /// ``` + /// + /// This method also handles the case where the annotation is an alias to an `Annotated` type, such as: + /// ```py + /// field: StrictInt + /// ``` + /// where `StrictInt` is defined as `StrictInt = Annotated[int, Strict()]`. + fn collect_from_annotation( + &mut self, + db: &'db dyn Db, + definition: Definition<'db>, + specialization: Option>, + ) { + let module = parsed_module(db, definition.file(db)).load(db); + let DefinitionKind::AnnotatedAssignment(assignment) = definition.kind(db) else { + return; + }; + let annotation = assignment.annotation(&module); + + if self.collect_from_annotated(db, definition, annotation, specialization) { + return; + } + + let Expr::Name(name) = annotation else { + return; + }; + + // The following part is unfortunate. Pydantic defines `StrictInt` and the other aliases + // using `StrictInt = Annotated[int, Strict()]`. Since we don't retain the `Annotated` + // metadata, we need to follow the alias back to its definition and parse the metadata + // from there. + let model = SemanticModel::new(db, definition.file(db)); + let Some(alias_definition) = definitions_for_name( + &model, + name.id.as_str(), + name.into(), + ImportAliasResolution::ResolveAliases, + ) + .into_iter() + .find_map(|resolved| resolved.definition()) else { + return; + }; + + let module = parsed_module(db, alias_definition.file(db)).load(db); + let kind = alias_definition.kind(db); + let value = match &kind { + DefinitionKind::Assignment(assignment) => assignment.value(&module), + DefinitionKind::AnnotatedAssignment(assignment) => { + let Some(value) = assignment.value(&module) else { + return; + }; + value + } + _ => return, + }; + + self.collect_from_annotated(db, alias_definition, value, specialization); + } + + /// Collect Pydantic field metadata from the `Annotated` part of a field's annotation. + fn collect_from_annotated( + &mut self, + db: &'db dyn Db, + definition: Definition<'db>, + annotation: &Expr, + specialization: Option>, + ) -> bool { + let Some(subscript) = annotation.as_subscript_expr() else { + return false; + }; + if definition_expression_type(db, definition, &subscript.value) + != Type::SpecialForm(SpecialFormType::Annotated) + { + return false; + } + let Some(arguments) = subscript + .slice + .as_tuple_expr() + .and_then(|tuple| tuple.elts.get(1..)) + else { + return false; + }; + + for metadata in arguments { + let Some(call) = metadata.as_call_expr() else { + continue; + }; + let callee = definition_expression_type(db, definition, &call.func); + + if callee + .as_class_literal() + .is_some_and(|class| class.is_known(db, KnownClass::PydanticStrict)) + { + let strict = call.arguments.find_argument_value("strict", 0).map_or( + ConfigBoolean::Enabled, + |strict| { + ConfigBoolean::from_type(definition_expression_type(db, definition, strict)) + }, + ); + self.merge_strict(strict); + } else if matches!( + callee, + Type::FunctionLiteral(function) + if function.is_known(db, KnownFunction::PydanticField) + ) { + let field_type = definition_expression_type(db, definition, metadata); + if let Type::KnownInstance(KnownInstanceType::Field(field)) = field_type { + self.merge_field(db, field, specialization); + } else { + self.merge_field_call(db, definition, call, field_type, specialization); + } + } + } + + true + } + + fn merge_strict(&mut self, strict: ConfigBoolean) { + self.strict = strict; + } + + fn merge_field( + &mut self, + db: &'db dyn Db, + field: FieldInstance<'db>, + specialization: Option>, + ) { + if let Some(default_type) = field.default_type(db) { + self.default_ty = Some(default_type.apply_optional_specialization(db, specialization)); + } + self.init &= field.init(db); + if let Some(alias) = field.alias(db) { + self.alias = Some(alias.clone()); + } + if !matches!(field.strict(db), ConfigBoolean::Unspecified) { + self.strict = field.strict(db); + } + } + + fn merge_field_call( + &mut self, + db: &'db dyn Db, + definition: Definition<'db>, + call: &ExprCall, + call_type: Type<'db>, + specialization: Option>, + ) { + if let Some(default) = call.arguments.find_argument_value("default", 0) { + let default_type = definition_expression_type(db, definition, default); + if !default_type.is_instance_of(db, KnownClass::EllipsisType) { + self.default_ty = + Some(default_type.apply_optional_specialization(db, specialization)); + } + } else if call.arguments.find_keyword("default_factory").is_some() { + self.default_ty = Some(call_type.apply_optional_specialization(db, specialization)); + } + + if let Some(init) = call.arguments.find_keyword("init") { + let init = definition_expression_type(db, definition, &init.value); + self.init &= !init.bool(db).is_always_false(); + } + + if let Some(alias) = call + .arguments + .find_keyword("validation_alias") + .or_else(|| call.arguments.find_keyword("alias")) + { + self.alias = definition_expression_type(db, definition, &alias.value) + .as_string_literal() + .map(|literal| Box::from(literal.value(db))); + } + + if let Some(strict) = call.arguments.find_keyword("strict") { + let strict = definition_expression_type(db, definition, &strict.value); + if !strict.is_none(db) { + self.merge_strict(ConfigBoolean::from_type(strict)); + } + } + } +} + +/// Resolve a Pydantic field's metadata from its annotation and right-hand side. +pub(in crate::types) fn field_metadata<'db>( + db: &'db dyn Db, + definition: Option>, + rhs_type: Option>, + specialization: Option>, +) -> FieldMetadata<'db> { + let mut metadata = FieldMetadata::default(); + if let Some(definition) = definition { + metadata.collect_from_annotation(db, definition, specialization); + } + metadata.collect_from_rhs_type(db, rhs_type, specialization); + metadata +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(crate) struct ModelConfig { /// The `extra` configuration controls whether the synthesized constructor accepts keyword diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 352d3d73f1..bb660a9589 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3853,10 +3853,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let annotation = assignment.annotation(self.module()); + // Pydantic supports field specifiers in annotations via `Annotated[T, Field(...)]`. + self.setup_dataclass_field_specifiers(); let mut declared = self.infer_annotation_expression_allow_pep_613( annotation, DeferredExpressionState::from(self.defer_annotations()), ); + self.dataclass_field_specifiers.clear(); // P.args and P.kwargs are only valid as annotations on *args and **kwargs, // not as variable annotations. Check both resolved type and AST form. From 2362111a3d72fd90d006f7cf6e6eebec102ecaae Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 07:38:04 -0400 Subject: [PATCH 015/222] [ty] Use purpose-specific types for completion and module text (#26664) ## Summary `Name` represents a Python identifier, but ty's IDE paths also used it for arbitrary text such as dotted module and qualified names, synthesized completion insertions, and dotted base-class references. Use `CompactString` for completion matching, display, insertion, and fully qualified symbol text, and construct `ModuleName` directly for module references used in `__all__` resolution. This keeps dotted and generated text out of the AST identifier type, avoids intermediate `String` allocations when formatting completion text and module paths, and preserves the existing completion and symbol behavior. --- crates/ty_ide/src/all_symbols.rs | 8 ++-- crates/ty_ide/src/completion.rs | 38 ++++++++++--------- crates/ty_ide/src/symbols.rs | 7 +++- .../ty_python_semantic/src/semantic_model.rs | 17 +++++---- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/crates/ty_ide/src/all_symbols.rs b/crates/ty_ide/src/all_symbols.rs index 3dbdad0f58..3b62813e0a 100644 --- a/crates/ty_ide/src/all_symbols.rs +++ b/crates/ty_ide/src/all_symbols.rs @@ -1,6 +1,6 @@ +use compact_str::CompactString; use rayon::prelude::*; use ruff_db::files::File; -use ruff_python_ast::name::Name; use ty_module_resolver::{Module, ModuleName, all_modules, resolve_real_shadowable_module}; use ty_project::{Db, parallel::ParallelIteratorExt}; @@ -99,7 +99,7 @@ pub struct AllSymbolInfo<'db> { /// When absent, this implies the symbol is the module itself. symbol: Option>, /// The fully qualified name of this symbol. - qualified: Name, + qualified: CompactString, /// The module containing the symbol. module: Module<'db>, /// The file containing the symbol. @@ -116,11 +116,11 @@ impl<'db> AllSymbolInfo<'db> { module: Module<'db>, file: File, ) -> AllSymbolInfo<'db> { - let qualified = Name::from(compact_str::format_compact!( + let qualified = compact_str::format_compact!( "{module_name}.{name}", module_name = module.name(db), name = symbol.name, - )); + ); AllSymbolInfo { symbol: Some(symbol), qualified, diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 2848a92d3c..0013eb60e9 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -1,6 +1,7 @@ use std::cmp::Ordering; use std::collections::{BinaryHeap, binary_heap}; +use compact_str::CompactString; use ruff_db::files::File; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_db::source::{SourceText, source_text}; @@ -284,18 +285,18 @@ impl<'db> Extend> for Completions<'db> { #[derive(Clone, Debug)] pub struct Completion<'db> { /// The name used when matching the query and ranking this suggestion. - pub name: Name, + pub name: CompactString, /// The label shown to the user for this suggestion. - pub label: Name, + pub label: CompactString, /// The fully qualified name, when available. /// /// This is only set when `module_name` is available. - pub qualified: Option, + pub qualified: Option, /// The text that should be inserted at the cursor /// when the completion is selected. /// /// When this is not set, [`Self::label`] is used. - pub insert: Option, + pub insert: Option, /// The format of [`Self::insert`]. pub insert_text_format: CompletionInsertTextFormat, /// The type of this completion, if available. @@ -351,7 +352,7 @@ pub struct Completion<'db> { } impl<'db> Completion<'db> { - fn builder(name: impl Into) -> CompletionBuilder<'db> { + fn builder(name: impl Into) -> CompletionBuilder<'db> { CompletionBuilder::new(name) } } @@ -361,9 +362,9 @@ impl<'db> Completion<'db> { #[expect(clippy::struct_excessive_bools)] struct CompletionBuilder<'db> { // See comments on `Completion` for the meaning of fields. - name: Name, - qualified: Option, - insert: Option, + name: CompactString, + qualified: Option, + insert: Option, ty: Option>, kind: Option, module_name: Option<&'db ModuleName>, @@ -382,7 +383,7 @@ impl<'db> CompletionBuilder<'db> { /// All other values given to the completion by default are /// valid, but callers will generally want to fill in as much /// as is appropriate. - fn new(name: impl Into) -> CompletionBuilder<'db> { + fn new(name: impl Into) -> CompletionBuilder<'db> { CompletionBuilder { name: name.into(), qualified: None, @@ -416,7 +417,7 @@ impl<'db> CompletionBuilder<'db> { /// /// This is just like `CompletionBuilder::new`, but sets the kind /// to "keyword." - fn keyword(name: impl Into) -> CompletionBuilder<'db> { + fn keyword(name: impl Into) -> CompletionBuilder<'db> { Completion::builder(name).kind(CompletionKind::Keyword) } @@ -480,10 +481,10 @@ impl<'db> CompletionBuilder<'db> { let label = self.insert.as_ref().unwrap_or(&self.name).clone(); let (insert, insert_text_format) = if ctx.should_complete_callable_parentheses(kind) { if ctx.capabilities.snippets { - let insert = Name::new(format!("{label}($0)")); + let insert = compact_str::format_compact!("{label}($0)"); (Some(insert), CompletionInsertTextFormat::Snippet) } else { - let insert = Name::new(format!("{label}()")); + let insert = compact_str::format_compact!("{label}()"); (Some(insert), CompletionInsertTextFormat::PlainText) } } else { @@ -508,12 +509,12 @@ impl<'db> CompletionBuilder<'db> { } } - fn qualified(mut self, qualified: impl Into) -> CompletionBuilder<'db> { + fn qualified(mut self, qualified: impl Into) -> CompletionBuilder<'db> { self.qualified = Some(qualified.into()); self } - fn insert(mut self, insert: impl Into) -> CompletionBuilder<'db> { + fn insert(mut self, insert: impl Into) -> CompletionBuilder<'db> { self.insert = Some(insert.into()); self } @@ -755,7 +756,7 @@ impl<'m> Context<'m> { // same scope (in which case the bases refer to the prior // definition). if !model.is_class_name_reassigned(class_def) { - bases.insert(class_def.name.id.clone()); + bases.insert(CompactString::new(class_def.name.as_str())); } bases }); @@ -1541,7 +1542,7 @@ struct CollectionContext<'db> { /// including the class being defined (unless its name was previously bound). /// Used to filter out duplicate and self-referential base class suggestions. /// This is only `Some` when we're in a class definition context. - existing_class_bases: Option>, + existing_class_bases: Option>, /// When set, the context dictates that only *these* keywords /// are acceptable in this context. valid_keywords: Option>, @@ -1615,11 +1616,12 @@ impl<'db> CollectionContext<'db> { /// /// For simple name references (e.g., `Foo`), returns the name as-is. /// For attribute accesses (e.g., `mod.Foo`), returns the full dotted path. -fn extract_base_class_names(class_def: &ast::StmtClassDef) -> FxHashSet { +fn extract_base_class_names(class_def: &ast::StmtClassDef) -> FxHashSet { class_def .bases() .iter() - .filter_map(|expr| UnqualifiedName::from_expr(expr).map(|name| Name::new(name.to_string()))) + .filter_map(UnqualifiedName::from_expr) + .map(|name| compact_str::format_compact!("{name}")) .collect() } diff --git a/crates/ty_ide/src/symbols.rs b/crates/ty_ide/src/symbols.rs index ee93323729..5bf37f31f7 100644 --- a/crates/ty_ide/src/symbols.rs +++ b/crates/ty_ide/src/symbols.rs @@ -592,7 +592,7 @@ impl<'db> Imports<'db> { &self, db: &'db dyn Db, importing_file: File, - name: &Name, + name: &ModuleName, ) -> Option<&'db FlatSymbols> { let module_name = match self.module_names.get(name.as_str())? { ImportModuleKind::Definitive(name) | ImportModuleKind::Possible(name) => { @@ -973,7 +973,10 @@ impl<'db> SymbolVisitor<'db> { if attr != "__all__" { return false; } - let possible_module_name = Name::new(rest.join(".")); + let Some(possible_module_name) = ModuleName::from_components(rest.iter().copied()) + else { + return false; + }; let Some(symbols) = self.imports .get_module_symbols(self.db, self.file, &possible_module_name) diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 231316cdc6..6106a1223a 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -1,3 +1,4 @@ +use compact_str::CompactString; use ruff_db::files::{File, FilePath}; use ruff_db::parsed::{parsed_module, parsed_string_annotation}; use ruff_db::source::{line_index, source_text}; @@ -134,7 +135,7 @@ impl<'db> SemanticModel<'db> { let builtin = module.is_known(self.db, KnownModule::Builtins); let ty = Type::module_literal(self.db, self.file, module); Completion { - name: Name::new(module.name(self.db).as_str()), + name: CompactString::new(module.name(self.db).as_str()), ty: Some(ty), builtin, } @@ -187,7 +188,7 @@ impl<'db> SemanticModel<'db> { )] for Member { name, ty } in all_members(self.db, ty) { completions.push(Completion { - name, + name: name.into(), ty: Some(ty), builtin, }); @@ -205,7 +206,7 @@ impl<'db> SemanticModel<'db> { let ty = Type::module_literal(self.db, self.file, *submodule); let base = submodule.name(self.db).last_component(); completions.push(Completion { - name: Name::new(base), + name: CompactString::new(base), ty: Some(ty), builtin, }); @@ -222,7 +223,7 @@ impl<'db> SemanticModel<'db> { all_members(self.db, ty) .into_iter() .map(|member| Completion { - name: member.name, + name: member.name.into(), ty: Some(member.ty), builtin: false, }) @@ -244,7 +245,7 @@ impl<'db> SemanticModel<'db> { completions.extend( all_reachable_members(self.db, file_scope.to_scope_id(self.db, self.file)).map( |memberdef| Completion { - name: memberdef.member.name, + name: memberdef.member.name.into(), ty: Some(memberdef.member.ty), builtin: false, }, @@ -258,7 +259,7 @@ impl<'db> SemanticModel<'db> { // not `str | None`). completions.extend( all_implicit_module_globals(self.db, self.file).map(|(name, ty)| Completion { - name, + name: name.into(), ty: Some(ty), builtin: true, }), @@ -619,7 +620,7 @@ pub enum NameKind { } impl NameKind { - pub fn classify(name: &Name) -> NameKind { + pub fn classify(name: &str) -> NameKind { // Dunder needs a prefix and suffix double underscore. // When there's only a prefix double underscore, this // results in explicit name mangling. We let that be @@ -640,7 +641,7 @@ impl NameKind { #[derive(Clone, Debug)] pub struct Completion<'db> { /// The label shown to the user for this suggestion. - pub name: Name, + pub name: CompactString, /// The type of this completion, if available. /// /// Generally speaking, this is always available From bd55d978ad3e97c3e55fe01dde91c787ed764f78 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 10 Jul 2026 14:46:06 +0200 Subject: [PATCH 016/222] [ty] Avoid allocation for every stdout write (#26698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary We allocated a new `Box` for every `Write` call (unless the compiler managed to remove it). This is rather expensive. This PR replaces the `handle() -> Box` function with `with_handle` that takes a callback instead. This avoids the allocation entirely. > On 50k diagnostics, concise improved from 497.1ms to 475.2ms—about 13.5% faster for rendering-only time. Related to astral-sh/ty#3958. ## Test Plan Testing: Passed the ty test suite and repository hooks. --- crates/ty/src/printer.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ty/src/printer.rs b/crates/ty/src/printer.rs index 39c113f0f7..f8d4a1ec7b 100644 --- a/crates/ty/src/printer.rs +++ b/crates/ty/src/printer.rs @@ -147,10 +147,11 @@ impl Stdout { self } - fn handle(&mut self) -> Box { + #[inline] + fn with_handle(&mut self, callback: impl FnOnce(&mut dyn std::io::Write) -> T) -> T { match self.lock.as_mut() { - Some(lock) => Box::new(lock), - None => Box::new(std::io::stdout()), + Some(lock) => callback(lock), + None => callback(&mut std::io::stdout()), } } @@ -162,14 +163,14 @@ impl Stdout { impl std::io::Write for Stdout { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self.status { - StreamStatus::Enabled => self.handle().write(buf), + StreamStatus::Enabled => self.with_handle(|handle| handle.write(buf)), StreamStatus::Disabled => Ok(buf.len()), } } fn flush(&mut self) -> std::io::Result<()> { match self.status { - StreamStatus::Enabled => self.handle().flush(), + StreamStatus::Enabled => self.with_handle(|handle| handle.flush()), StreamStatus::Disabled => Ok(()), } } @@ -179,7 +180,7 @@ impl std::fmt::Write for Stdout { fn write_str(&mut self, s: &str) -> std::fmt::Result { match self.status { StreamStatus::Enabled => { - let _ = write!(self.handle(), "{s}"); + let _ = self.with_handle(|handle| write!(handle, "{s}")); Ok(()) } StreamStatus::Disabled => Ok(()), From eac76aac4a89db28fe423c11ec6b9125004e2069 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 10 Jul 2026 15:06:53 +0200 Subject: [PATCH 017/222] [ty] Pydantic: Support custom `__init__` methods (#26699) ## Summary A Pydantic model with a base class with a custom `__init__` method previously prevented us from adding `extra: **Unknown` to the models constructor. ## Test Plan Added Markdown tests --- .../resources/mdtest/external/pydantic.md | 35 +++++++++++++++++++ .../resources/mdtest/external/sqlmodel.md | 2 +- .../src/types/dedicated/pydantic.rs | 17 ++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index be6af80a8f..42c27a55cb 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -777,6 +777,41 @@ class PersonAllowingExtras(BaseModel): PersonAllowingExtras(name="Alice", something_else=7) ``` +## Custom initializers and extra fields + +A custom initializer that accepts arbitrary keyword arguments does not prevent a subclass from +accepting extra data: + +```py +from typing import Any + +from pydantic import BaseModel + +class FrameworkBase(BaseModel): + def __init__(self, **data: Any) -> None: + super().__init__(**data) + +class User(FrameworkBase): + name: str + +reveal_type(User.__init__) # revealed: (self: User, *, name: LaxStr, **extra: Any) -> None +User(name="Alice", city="Berlin") +``` + +A fixed custom initializer continues to control the accepted arguments: + +```py +class RestrictiveBase(BaseModel): + def __init__(self, name: str) -> None: + super().__init__(name=name) + +class RestrictiveUser(RestrictiveBase): + name: str + +RestrictiveUser(name="Alice") +RestrictiveUser(name="Alice", city="Berlin") # error: [unknown-argument] +``` + ## Field named `extra` The variadic keyword parameter uses a collision-free name when the model already has a field named diff --git a/crates/ty_python_semantic/resources/mdtest/external/sqlmodel.md b/crates/ty_python_semantic/resources/mdtest/external/sqlmodel.md index a3775d7ead..f9ac8ac4cf 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/sqlmodel.md +++ b/crates/ty_python_semantic/resources/mdtest/external/sqlmodel.md @@ -23,7 +23,7 @@ user = User(id=1, name="John Doe") reveal_type(user.id) # revealed: int reveal_type(user.name) # revealed: str -reveal_type(User.__init__) # revealed: (self: User, *, id: LaxInt, name: LaxStr) -> None +reveal_type(User.__init__) # revealed: (self: User, *, id: LaxInt, name: LaxStr, **extra: Any) -> None User() # error: [missing-argument] ``` diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index 4baba9b692..fcfe86b2f0 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -923,9 +923,24 @@ pub(in crate::types) fn model_init_accepts_extra( return true; } - if !class_member(db, base.body_scope(db), "__init__").is_undefined() { + // These constructors use variadic keywords for specialized inputs, not arbitrary extras. + if base.is_known(db, KnownClass::PydanticRootModel) + || base.is_known(db, KnownClass::PydanticBaseSettings) + { return false; } + + let init = class_member(db, base.body_scope(db), "__init__"); + if !init.is_undefined() { + return init + .ignore_possibly_undefined() + .and_then(Type::as_function_literal) + .is_some_and(|init| { + init.signature(db) + .iter() + .any(|signature| signature.parameters().keyword_variadic().is_some()) + }); + } } false From 3edd10cdd7653339ec602bbe861bd87e4d411c53 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 10 Jul 2026 15:26:35 +0200 Subject: [PATCH 018/222] [ty] Buffer diagnostic output (#26702) --- crates/ty/src/lib.rs | 6 ++++-- crates/ty/src/main.rs | 20 +++++++++++--------- crates/ty/src/printer.rs | 12 ------------ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index c6bb8d7f4c..72767b7a33 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -5,7 +5,7 @@ mod python_version; mod rule; mod version; -use std::fmt::Write; +use std::io::{BufWriter, Write}; use std::process::{ExitCode, Termination}; use std::sync::Mutex; @@ -506,11 +506,12 @@ impl MainLoop { diagnostics => { let diagnostics_count = diagnostics.len(); - let mut stdout = self.printer.stream_for_details().lock(); + let stdout = self.printer.stream_for_details().lock(); // Only render diagnostics if they're going to be displayed, since doing // so is expensive. if stdout.is_enabled() { + let mut stdout = BufWriter::new(stdout); let display_config = DisplayDiagnosticConfig::new("ty") .format(terminal_settings.output_format.into()) .color(colored::control::SHOULD_COLORIZE.should_colorize()) @@ -523,6 +524,7 @@ impl MainLoop { "{}", DisplayDiagnostics::new(db, &display_config, diagnostics) )?; + stdout.flush()?; } if !self.cancellation_token.is_cancelled() && is_human_readable { diff --git a/crates/ty/src/main.rs b/crates/ty/src/main.rs index 102ec184e3..169145d0a1 100644 --- a/crates/ty/src/main.rs +++ b/crates/ty/src/main.rs @@ -22,6 +22,17 @@ pub fn main() -> ExitStatus { run().unwrap_or_else(|error| { use io::Write; + // Exit "gracefully" on broken pipe errors. + // + // See: https://github.com/BurntSushi/ripgrep/blob/bf63fe8f258afc09bae6caa48f0ae35eaf115005/crates/core/main.rs#L47C1-L61C14 + if error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|ioerr| ioerr.kind() == io::ErrorKind::BrokenPipe) + }) { + return ExitStatus::Success; + } + // Use `writeln` instead of `eprintln` to avoid panicking when the stderr pipe is broken. let mut stderr = io::stderr().lock(); @@ -32,15 +43,6 @@ pub fn main() -> ExitStatus { // the configuration it is help to chain errors ("resolving configuration failed" -> // "failed to read file: subdir/pyproject.toml") for cause in error.chain() { - // Exit "gracefully" on broken pipe errors. - // - // See: https://github.com/BurntSushi/ripgrep/blob/bf63fe8f258afc09bae6caa48f0ae35eaf115005/crates/core/main.rs#L47C1-L61C14 - if let Some(ioerr) = cause.downcast_ref::() { - if ioerr.kind() == io::ErrorKind::BrokenPipe { - return ExitStatus::Success; - } - } - writeln!(stderr, " {} {cause}", "Cause:".bold()).ok(); } diff --git a/crates/ty/src/printer.rs b/crates/ty/src/printer.rs index f8d4a1ec7b..d72e1ebb5a 100644 --- a/crates/ty/src/printer.rs +++ b/crates/ty/src/printer.rs @@ -175,15 +175,3 @@ impl std::io::Write for Stdout { } } } - -impl std::fmt::Write for Stdout { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - match self.status { - StreamStatus::Enabled => { - let _ = self.with_handle(|handle| write!(handle, "{s}")); - Ok(()) - } - StreamStatus::Disabled => Ok(()), - } - } -} From 9b3d7d7ebbc6a69db3d79936016a29e063d22f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Fri, 10 Jul 2026 09:46:12 -0700 Subject: [PATCH 019/222] [ty] Correct how we expand tabs in docstrings (#26679) ## Summary This corrects how we expand tabs as part of PEP-257 normalization of docstrings. Previously, we incorrectly replaced each tab in the docstring with 8 spaces. However, as per [the reference implementation](https://peps.python.org/pep-0257/#handling-docstring-indentation) (and the [`str.expandtabs` docs](https://docs.python.org/3/library/stdtypes.html#str.expandtabs)), we should instead treat a tab as a directive to advance to the next tab stop (the next column at a multiple of 8). This matters for Google-style parameter extraction when a tab follows spaces. For example, consider an `Args:` section whose first item is indented with two spaces followed by a tab, while the second uses eight spaces: ```python def example(first: str, second: str): """Summary. Args: \tfirst: First parameter. second: Second parameter. """ ``` The previous normalization produced this docstring. Because the parameter items have different indentation, `second` is treated as continuation text for `first` rather than as a separate parameter: ```text Summary. Args: first: First parameter. second: Second parameter. ``` Whereas the correct interpretation according to PEP 257 aligns the parameter items, allowing documentation to be extracted for both `first` and `second`: ```text Summary. Args: first: First parameter. second: Second parameter. ``` ## Test Plan See included tests. --- crates/ruff_python_trivia/src/whitespace.rs | 54 +++++++++++++++++++++ crates/ty_ide/src/docstring.rs | 27 +++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/ruff_python_trivia/src/whitespace.rs b/crates/ruff_python_trivia/src/whitespace.rs index c9c4b7294b..4e0f84b435 100644 --- a/crates/ruff_python_trivia/src/whitespace.rs +++ b/crates/ruff_python_trivia/src/whitespace.rs @@ -1,6 +1,40 @@ +use std::borrow::Cow; + use ruff_source_file::LineRanges; use ruff_text_size::{TextRange, TextSize}; +/// Expands tabs to the next eight-column tab stop, matching Python's `str.expandtabs`. +pub fn expand_tabs(source: &str) -> Cow<'_, str> { + const TAB_SIZE: usize = 8; + + if !source.contains('\t') { + return Cow::Borrowed(source); + } + + let mut expanded = String::with_capacity(source.len()); + let mut column = 0; + + for character in source.chars() { + match character { + '\t' => { + let spaces = TAB_SIZE - column % TAB_SIZE; + expanded.extend(std::iter::repeat_n(' ', spaces)); + column += spaces; + } + '\r' | '\n' => { + expanded.push(character); + column = 0; + } + _ => { + expanded.push(character); + column += 1; + } + } + } + + Cow::Owned(expanded) +} + /// Extract the leading indentation from a line. pub fn indentation_at_offset(offset: TextSize, source: &str) -> Option<&str> { let line_start = source.line_start(offset); @@ -78,3 +112,23 @@ impl PythonWhitespace for str { self.trim_end_matches(is_python_whitespace) } } + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use super::expand_tabs; + + #[test] + fn tab_expansion_borrows_unchanged_text() { + assert!(matches!(expand_tabs("unchanged"), Cow::Borrowed(_))); + } + + #[test] + fn tab_expansion_allocates_changed_text() { + let expanded = expand_tabs(" \tvalue"); + + assert!(matches!(&expanded, Cow::Owned(_))); + assert_eq!(expanded, " value"); + } +} diff --git a/crates/ty_ide/src/docstring.rs b/crates/ty_ide/src/docstring.rs index 0c58bc8171..d97d190741 100644 --- a/crates/ty_ide/src/docstring.rs +++ b/crates/ty_ide/src/docstring.rs @@ -11,7 +11,7 @@ mod markdown; use indexmap::IndexMap; use regex::Regex; -use ruff_python_trivia::{PythonWhitespace, leading_indentation}; +use ruff_python_trivia::{PythonWhitespace, expand_tabs, leading_indentation}; use ruff_source_file::UniversalNewlines; use std::sync::LazyLock; @@ -83,7 +83,7 @@ impl DocstringFragment { /// Normalizes an extracted docstring fragment without removing meaningful relative indentation. fn documentation_fragment_trim(docs: &str) -> String { - let expanded = docs.trim_end().replace('\t', " "); + let expanded = expand_tabs(docs.trim_end()); let mut output = String::with_capacity(expanded.len()); for line in expanded.universal_newlines() { output.push_str(line.as_str().trim_whitespace_end()); @@ -96,13 +96,13 @@ fn documentation_fragment_trim(docs: &str) -> String { /// /// See: fn documentation_trim(docs: &str) -> String { - // First apply tab expansion as we don't want tabs in our output - // (python says tabs are equal to 8 spaces). + // First apply tab expansion as we don't want tabs in our output. Python advances tabs to the + // next eight-column tab stop. // // We also trim off all trailing whitespace here to eliminate trailing newlines so we // don't need to handle trailing blank lines later. We can't trim away leading // whitespace yet, because we need to identify the first line and handle it specially. - let expanded = docs.trim_end().replace('\t', " "); + let expanded = expand_tabs(docs.trim_end()); // Compute the minimum indention of all non-empty non-first lines // and statistics about leading blank lines to help trim them later. @@ -348,6 +348,23 @@ mod tests { settings.bind_to_scope() } + #[test] + fn expands_tabs_to_tab_stops_when_trimming_documentation() { + assert_snapshot!( + documentation_trim( + "\ +Summary. + baseline + \tindented", + ), + @" + Summary. + baseline + indented + " + ); + } + // A nice doctest that is surrounded by prose #[test] fn dunder_escape() { From 2e2d7387f158520ad2a68dca4173090c0d3f195e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 13:01:45 -0400 Subject: [PATCH 020/222] [ty] Allow unsafe equality-based narrowing for builtins (#26414) ## Summary This PR allows `str`, `int`, and `bytes` types to narrow to literal values when an equality check succeeds by default. The behavior is unsound because a subclass could override `__eq__`, but it is convenient for users and matches other type checkers. Users who prefer conservative behavior can enable `strict-literal-narrowing`; the option defaults to `false`. ```toml [tool.ty.analysis] strict-literal-narrowing = true ``` For example, the default behavior allows: ```python from typing import Literal type Choice = Literal["a", "b"] def parse(value: str, choices: list[Choice]) -> Choice | None: if value in choices: reveal_type(value) # Literal["a", "b"] return value return None ``` Closes https://github.com/astral-sh/ty/issues/1566. Closes https://github.com/astral-sh/ty/issues/1659. Closes https://github.com/astral-sh/ty/issues/2178. Closes https://github.com/astral-sh/ty/issues/2211. Closes https://github.com/astral-sh/ty/issues/3233. Closes https://github.com/astral-sh/ty/issues/3852. --- crates/ty/docs/configuration.md | 136 ++++++++++++++++++ crates/ty_ide/src/hover.rs | 8 +- crates/ty_ide/src/inlay_hints.rs | 42 +++++- crates/ty_project/src/metadata/options.rs | 55 +++++++ .../resources/mdtest/conditional/match.md | 29 +++- .../mdtest/narrow/conditionals/elif_else.md | 5 +- .../mdtest/narrow/conditionals/eq.md | 85 +++++++++++ .../mdtest/narrow/conditionals/in.md | 74 ++++++++-- .../resources/mdtest/narrow/match.md | 74 +++++++--- .../type_compendium/integer_literals.md | 9 +- crates/ty_python_semantic/src/lib.rs | 5 + .../ty_python_semantic/src/types/equality.rs | 127 ++++++++++++---- .../src/types/match_pattern.rs | 15 +- crates/ty_python_semantic/src/types/narrow.rs | 55 +++++-- .../e2e__commands__debug_command.snap | 1 + crates/ty_test/src/config.rs | 3 + crates/ty_test/src/db.rs | 4 + ty.schema.json | 7 + 18 files changed, 641 insertions(+), 93 deletions(-) diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index d1a12f159c..b7dffda5da 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -160,6 +160,74 @@ Defaults to `true`. --- +### `strict-literal-narrowing` + +Whether equality-based checks should preserve broad builtin types rather than narrow them to +literal types. + +By default, ty narrows `value` from `str` to `Literal["a"]` in the positive branch of +`value == "a"`. When this option is enabled, `value` remains `str`. This also applies to +membership tests and literal match patterns, which use equality comparisons. + +```python +from typing import Literal + +def parse(value: str) -> Literal["a"] | None: + if value == "a": + return value # Accepted by default; `value` remains `str` in strict mode. + return None +``` + +Broad builtin types include subclasses, but literal types distinguish values by both their +runtime type and value. This makes the narrowing unsound even for subclasses that inherit +builtin equality. For example: + +```python +class StringSubclass(str): ... + +result = parse(StringSubclass("a")) +# Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. +``` + +A subclass can also override `__eq__` to compare equal to a literal with a different value: + +```python +class MisleadingStr(str): + def __eq__(self, other: object) -> bool: + return True + +result = parse(MisleadingStr("b")) +# Statically `Literal["a"] | None`, but `result` contains `"b"` at runtime. +``` + +Enable this option to preserve the broader builtin type instead. + +Defaults to `false`. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.analysis] + # Preserve broad builtin types instead of narrowing them to literals + strict-literal-narrowing = true + ``` + +=== "ty.toml" + + ```toml + [analysis] + # Preserve broad builtin types instead of narrowing them to literals + strict-literal-narrowing = true + ``` + +--- + ## `environment` ### `extra-paths` @@ -654,6 +722,74 @@ Defaults to `true`. --- +#### `strict-literal-narrowing` + +Whether equality-based checks should preserve broad builtin types rather than narrow them to +literal types. + +By default, ty narrows `value` from `str` to `Literal["a"]` in the positive branch of +`value == "a"`. When this option is enabled, `value` remains `str`. This also applies to +membership tests and literal match patterns, which use equality comparisons. + +```python +from typing import Literal + +def parse(value: str) -> Literal["a"] | None: + if value == "a": + return value # Accepted by default; `value` remains `str` in strict mode. + return None +``` + +Broad builtin types include subclasses, but literal types distinguish values by both their +runtime type and value. This makes the narrowing unsound even for subclasses that inherit +builtin equality. For example: + +```python +class StringSubclass(str): ... + +result = parse(StringSubclass("a")) +# Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. +``` + +A subclass can also override `__eq__` to compare equal to a literal with a different value: + +```python +class MisleadingStr(str): + def __eq__(self, other: object) -> bool: + return True + +result = parse(MisleadingStr("b")) +# Statically `Literal["a"] | None`, but `result` contains `"b"` at runtime. +``` + +Enable this option to preserve the broader builtin type instead. + +Defaults to `false`. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.overrides.analysis] + # Preserve broad builtin types instead of narrowing them to literals + strict-literal-narrowing = true + ``` + +=== "ty.toml" + + ```toml + [overrides.analysis] + # Preserve broad builtin types instead of narrowing them to literals + strict-literal-narrowing = true + ``` + +--- + ## `src` ### `exclude` diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index 5019e146ea..ffb103180d 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -3880,11 +3880,11 @@ def function(): "#, ); - assert_snapshot!(test.hover(), @" - str + assert_snapshot!(test.hover(), @r#" + Literal["a", "b"] --------------------------------------------- ```python - str + Literal["a", "b"] ``` --------------------------------------------- info[hover]: Hovered content is @@ -3896,7 +3896,7 @@ def function(): | |Cursor offset | source | - "); + "#); } #[test] diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index f64dd19a57..f0b8f1f7a2 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -2244,9 +2244,22 @@ Source with applied edits: def my_func(command: str): match command.split(): case ["get", ("a" | "b") as ab]: - x[: str] = ab + x[: Literal["a", "b"]] = ab --------------------------------------------- + info[inlay-hint-location]: Inlay Hint Target + --> stdlib/typing.pyi:LL:1 + | + LL | Literal: _SpecialForm + | ^^^^^^^ + | + info: Source + --> main2.py:LL:17 + | + LL | x[: Literal["a", "b"]] = ab + | ^^^^^^^ + | + info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.pyi:LL:7 | @@ -2254,19 +2267,36 @@ Source with applied edits: | ^^^ | info: Source - --> main2.py:LL:17 + --> main2.py:LL:25 | - LL | x[: str] = ab - | ^^^ + LL | x[: Literal["a", "b"]] = ab + | ^^^ + | + + info[inlay-hint-location]: Inlay Hint Target + --> stdlib/builtins.pyi:LL:7 + | + LL | class str(Sequence[str]): + | ^^^ + | + info: Source + --> main2.py:LL:30 + | + LL | x[: Literal["a", "b"]] = ab + | ^^^ | --------------------------------------------- info[inlay-hint-edit]: Inlay hint edits --> main.py:1:1 | - 4 | case ["get", ("a" | "b") as ab]: + 1 + from typing import Literal + 2 | + 3 | def my_func(command: str): + 4 | match command.split(): + 5 | case ["get", ("a" | "b") as ab]: - x = ab - 5 + x: str = ab + 6 + x: Literal["a", "b"] = ab | "#); } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index c23fe72a19..7f88ea4d02 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1465,6 +1465,57 @@ pub struct TerminalOptions { #[serde(rename_all = "kebab-case", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct AnalysisOptions { + /// Whether equality-based checks should preserve broad builtin types rather than narrow them to + /// literal types. + /// + /// By default, ty narrows `value` from `str` to `Literal["a"]` in the positive branch of + /// `value == "a"`. When this option is enabled, `value` remains `str`. This also applies to + /// membership tests and literal match patterns, which use equality comparisons. + /// + /// ```python + /// from typing import Literal + /// + /// def parse(value: str) -> Literal["a"] | None: + /// if value == "a": + /// return value # Accepted by default; `value` remains `str` in strict mode. + /// return None + /// ``` + /// + /// Broad builtin types include subclasses, but literal types distinguish values by both their + /// runtime type and value. This makes the narrowing unsound even for subclasses that inherit + /// builtin equality. For example: + /// + /// ```python + /// class StringSubclass(str): ... + /// + /// result = parse(StringSubclass("a")) + /// # Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. + /// ``` + /// + /// A subclass can also override `__eq__` to compare equal to a literal with a different value: + /// + /// ```python + /// class MisleadingStr(str): + /// def __eq__(self, other: object) -> bool: + /// return True + /// + /// result = parse(MisleadingStr("b")) + /// # Statically `Literal["a"] | None`, but `result` contains `"b"` at runtime. + /// ``` + /// + /// Enable this option to preserve the broader builtin type instead. + /// + /// Defaults to `false`. + #[option( + default = r#"false"#, + value_type = "bool", + example = r#" + # Preserve broad builtin types instead of narrowing them to literals + strict-literal-narrowing = true + "# + )] + pub strict_literal_narrowing: Option, + /// Whether ty should respect `type: ignore` comments. /// /// When set to `false`, `type: ignore` comments are treated like any other normal @@ -1541,12 +1592,14 @@ impl AnalysisOptions { diagnostics: &mut Vec, ) -> AnalysisSettings { let Self { + strict_literal_narrowing, respect_type_ignore_comments, allowed_unresolved_imports, replace_imports_with_any, } = self; let AnalysisSettings { + strict_literal_narrowing: strict_literal_narrowing_default, respect_type_ignore_comments: respect_type_ignore_default, allowed_unresolved_imports: allowed_unresolved_imports_default, replace_imports_with_any: replace_imports_with_any_default, @@ -1575,6 +1628,8 @@ impl AnalysisOptions { }; AnalysisSettings { + strict_literal_narrowing: strict_literal_narrowing + .unwrap_or(strict_literal_narrowing_default), respect_type_ignore_comments: respect_type_ignore_comments .unwrap_or(respect_type_ignore_default), allowed_unresolved_imports, diff --git a/crates/ty_python_semantic/resources/mdtest/conditional/match.md b/crates/ty_python_semantic/resources/mdtest/conditional/match.md index 3687b9721a..3747485417 100644 --- a/crates/ty_python_semantic/resources/mdtest/conditional/match.md +++ b/crates/ty_python_semantic/resources/mdtest/conditional/match.md @@ -554,9 +554,9 @@ the subject after that pattern succeeds. ### Value-pattern aliases Value patterns use `==`, and `as` binds the original subject rather than the value written in the -pattern. An `int` or `str` subclass can define `__eq__` so that it compares equal to `1`, so `x` -remains `int | str` in the first branch. If that branch fails, we can rule out the exact integer -literal `1` and `True`, which compares equal to `1`, but not the rest of either class. +pattern. Broad builtin types are treated as if they use builtin equality, so matching `1` narrows +`x` to the integer and boolean literals that compare equal to it. After that pattern fails, matching +`"foo"` narrows `x` to that string literal. ```py def _(target: int | str): @@ -565,16 +565,35 @@ def _(target: int | str): match target: case 1 as x: y = 2 - reveal_type(x) # revealed: int | str + reveal_type(x) # revealed: Literal[1, True] case "foo" as x: y = 3 - reveal_type(x) # revealed: (int & ~Literal[1] & ~Literal[True]) | str + reveal_type(x) # revealed: Literal["foo"] case _: y = 4 reveal_type(y) # revealed: Literal[2, 3, 4] ``` +### Enabling strict literal narrowing + +With strict literal narrowing enabled, broad builtin types are preserved both in the capture and +when narrowing the subject for later cases: + +```toml +[analysis] +strict-literal-narrowing = true +``` + +```py +def _(target: int | str): + match target: + case 1 as x: + reveal_type(x) # revealed: int | str + case "foo" as x: + reveal_type(x) # revealed: (int & ~Literal[1] & ~Literal[True]) | str +``` + ### Narrowing a value alias When every possible value has known equality behavior, the value pattern can narrow the bound name. diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md index 682daea9e4..24b26d0f25 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/elif_else.md @@ -5,10 +5,9 @@ ```py def _(x: int): if x == 1: - # cannot narrow; could be a subclass of `int` - reveal_type(x) # revealed: int + reveal_type(x) # revealed: Literal[1, True] elif x == 2: - reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[True] + reveal_type(x) # revealed: Literal[2] elif x != 3: reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[True] & ~Literal[2] & ~Literal[3] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index 4b99d37cf8..f4124df95f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -1137,6 +1137,61 @@ def _(value: Right | None): reveal_type(value) # revealed: Right | None ``` +## Narrowing builtin types to literals + +Equality with a literal narrows broad `str`, `int`, and `bytes` types to the values that compare +equal to that literal: + +```py +def narrow_string(value: str): + if value == "a": + reveal_type(value) # revealed: Literal["a"] + else: + reveal_type(value) # revealed: str & ~Literal["a"] + +def narrow_reversed_string(value: str): + if "a" == value: + reveal_type(value) # revealed: Literal["a"] + +def narrow_integer(value: int): + if value == 1: + # `True == 1` at runtime. + reveal_type(value) # revealed: Literal[1, True] + +def narrow_bytes(value: bytes): + if value == b"a": + reveal_type(value) # revealed: Literal[b"a"] + +def narrow_mixed_builtins(value: str | int | bytes): + if value == "a": + reveal_type(value) # revealed: Literal["a"] + +def narrow_inequality_else(value: str): + if value != "a": + reveal_type(value) # revealed: str & ~Literal["a"] + else: + reveal_type(value) # revealed: Literal["a"] +``` + +The narrowing only treats the broad builtin types optimistically. Explicit subclass and custom +comparison arms are preserved: + +```py +class StringSubclass(str): ... + +class AlwaysEqual: + def __eq__(self, other: object) -> bool: + return True + +def preserve_subclass(value: StringSubclass): + if value == "a": + reveal_type(value) # revealed: StringSubclass + +def preserve_custom_comparison(value: str | AlwaysEqual): + if value == "a": + reveal_type(value) # revealed: Literal["a"] | AlwaysEqual +``` + ## `x != y` where `y` is of literal type ```py @@ -1561,3 +1616,33 @@ def _(x: A | B): else: reveal_type(x) # revealed: B ``` + +## Enabling strict literal narrowing + +The `strict-literal-narrowing` option can be enabled to preserve broad builtin types after equality +comparisons. Narrowing types that are already literal unions remains safe and is unaffected. + +```toml +[analysis] +strict-literal-narrowing = true +``` + +```py +from typing import Literal + +def broad(value: str): + if value == "a": + reveal_type(value) # revealed: str + else: + reveal_type(value) # revealed: str & ~Literal["a"] + +def inequality(value: str): + if value != "a": + reveal_type(value) # revealed: str & ~Literal["a"] + else: + reveal_type(value) # revealed: str + +def literal(value: Literal["a", "b"]): + if value == "a": + reveal_type(value) # revealed: Literal["a"] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index 6c553e722a..5c5674db30 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -5,7 +5,7 @@ ```py def _(x: int): if x in (1, 2, 3): - reveal_type(x) # revealed: int + reveal_type(x) # revealed: Literal[1, 2, 3, True] else: reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[True] & ~Literal[2] & ~Literal[3] ``` @@ -13,11 +13,37 @@ def _(x: int): ```py def _(x: str): if x in ("a", "b", "c"): - reveal_type(x) # revealed: str + reveal_type(x) # revealed: Literal["a", "b", "c"] else: reveal_type(x) # revealed: str & ~Literal["a"] & ~Literal["b"] & ~Literal["c"] ``` +```py +def _(x: bytes): + if x in (b"a", b"b"): + reveal_type(x) # revealed: Literal[b"a", b"b"] +``` + +Membership in a container with literal elements narrows a broad builtin type to those literals: + +```py +from typing import Literal + +MyType = Literal["test1", "test2"] + +def from_list(value: str, valid_values: list[MyType]) -> MyType: + if value in valid_values: + reveal_type(value) # revealed: Literal["test1", "test2"] + return value + return "test1" + +def from_dict(value: str, valid_values: dict[MyType, int]) -> int | None: + if value in valid_values: + reveal_type(value) # revealed: Literal["test1", "test2"] + return valid_values[value] + return None +``` + ```py from typing import Literal @@ -86,7 +112,7 @@ type Foo = Literal["a", "b", "c"] | int def _(x: Foo): if x in ("a", "b"): - reveal_type(x) # revealed: Literal["a", "b"] | int + reveal_type(x) # revealed: Literal["a", "b"] else: reveal_type(x) # revealed: Literal["c"] | int @@ -94,7 +120,30 @@ def _(x: Foo): if x not in ("a", "c"): reveal_type(x) # revealed: Literal["b"] | int else: - reveal_type(x) # revealed: Literal["a", "c"] | int + reveal_type(x) # revealed: Literal["a", "c"] +``` + +## Enabling strict literal narrowing for membership + +With strict literal narrowing enabled, a broad union arm is preserved when a membership test +succeeds, while literal arms are still narrowed safely: + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-literal-narrowing = true +``` + +```py +from typing import Literal + +type Foo = Literal["a", "b", "c"] | int + +def _(x: Foo): + if x in ("a", "b"): + reveal_type(x) # revealed: Literal["a", "b"] | int ``` ## `in` for `str` and literal strings @@ -296,9 +345,7 @@ from typing import Literal def test(x: Literal["a", "b", "c"] | None | int = None): if x in ("a", "b"): - # int is included because custom __eq__ methods could make - # an int equal to "a" or "b", so we can't eliminate it - reveal_type(x) # revealed: Literal["a", "b"] | int + reveal_type(x) # revealed: Literal["a", "b"] else: reveal_type(x) # revealed: Literal["c"] | None | int @@ -364,11 +411,11 @@ T = TypeVar("T", Literal[1], Literal[2]) def test(x: Literal["a", "b", "c"] | None | int = None): if x not in ("a", "c"): - # int is included because custom __eq__ methods could make - # an int equal to "a" or "c", so we can't eliminate it + # Negative narrowing remains conservative because an `int` subclass could compare equal + # to a string literal. reveal_type(x) # revealed: Literal["b"] | None | int else: - reveal_type(x) # revealed: Literal["a", "c"] | int + reveal_type(x) # revealed: Literal["a", "c"] def broad_set_element(x: Literal[1, 2], values: set[int]) -> None: if x not in values: @@ -463,7 +510,7 @@ def default_equality(x: Token | Literal[1]): def overlapping_union_member(x: int | Literal["missing"]): if x in ("missing", 1): - reveal_type(x) # revealed: int | Literal["missing"] + reveal_type(x) # revealed: Literal["missing", 1, True] def custom_equality(x: AlwaysEqual | Literal[1]): if x in (1,): @@ -977,8 +1024,7 @@ def _(x: bool): def _(x: bool | str): if x in (False,): - # `str` remains due to possible custom __eq__ methods on a subclass - reveal_type(x) # revealed: Literal[False] | str + reveal_type(x) # revealed: Literal[False] else: reveal_type(x) # revealed: Literal[True] | str ``` @@ -996,7 +1042,7 @@ def _(x: LiteralString): def _(x: LiteralString | int): if x in ("a", "b", "c"): - reveal_type(x) # revealed: Literal["a", "b", "c"] | int + reveal_type(x) # revealed: Literal["a", "b", "c"] else: reveal_type(x) # revealed: (LiteralString & ~Literal["a"] & ~Literal["b"] & ~Literal["c"]) | int ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 9bd9c01779..1c75821f21 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -2765,8 +2765,49 @@ match capture_from_later_global(): ## Value patterns -Value patterns are evaluated by equality, which is overridable. Therefore successfully matching on -one can only give us information where we know how the subject type implements equality. +Value patterns are evaluated by equality, which is overridable. Apart from the optimistic treatment +of broad builtin types described below, successfully matching one only gives us information where we +know how the subject type implements equality. + +Broad builtin types are treated as if they use the builtin equality implementation, so literal +patterns narrow `str`, `int`, and `bytes`: + +```py +def string_pattern(value: str): + match value: + case "a": + reveal_type(value) # revealed: Literal["a"] + +def integer_pattern(value: int): + match value: + case 1: + reveal_type(value) # revealed: Literal[1, True] + +def bytes_pattern(value: bytes): + match value: + case b"a": + reveal_type(value) # revealed: Literal[b"a"] +``` + +Explicit subclass and custom comparison arms are still preserved: + +```py +class StringSubclass(str): ... + +class AlwaysEqual: + def __eq__(self, other: object) -> bool: + return True + +def subclass_pattern(value: StringSubclass): + match value: + case "a": + reveal_type(value) # revealed: StringSubclass + +def custom_comparison_pattern(value: str | AlwaysEqual): + match value: + case "a": + reveal_type(value) # revealed: Literal["a"] | AlwaysEqual +``` Consider the following example. @@ -2776,19 +2817,16 @@ from typing import Literal def _(x: Literal["foo"] | int): match x: case "foo": - reveal_type(x) # revealed: Literal["foo"] | int + reveal_type(x) # revealed: Literal["foo"] match x: case "bar": - reveal_type(x) # revealed: int + reveal_type(x) # revealed: Never ``` -In the first `match`'s `case "foo"` all we know is `x == "foo"`. `x` could be an instance of an -arbitrary `int` subclass with an arbitrary `__eq__`, so we can't actually narrow to -`Literal["foo"]`. - -In the second `match`'s `case "bar"` we know `x == "bar"`. As discussed above, this isn't enough to -rule out `int`, but we know that `"foo" == "bar"` is false so we can eliminate `Literal["foo"]`. +In the first `match`, the broad `int` arm is assumed to use builtin equality and cannot compare +equal to `"foo"`. In the second, neither arm can compare equal to `"bar"`. Enabling +`strict-literal-narrowing` disables this optimistic treatment of broad builtin types. A final subclass with inherited builtin equality can compare equal to a literal despite being disjoint from the literal's type. This applies both to literal patterns and dotted value patterns: @@ -2883,15 +2921,15 @@ class C: def _(x: Literal["foo", "bar", 42, b"foo"] | bool | complex): match x: case "foo": - reveal_type(x) # revealed: Literal["foo"] | int | float | complex + reveal_type(x) # revealed: Literal["foo"] | float | complex case 42: - reveal_type(x) # revealed: int | float | complex + reveal_type(x) # revealed: Literal[42] | float | complex case 6.0: reveal_type(x) # revealed: Literal["bar", b"foo"] | (int & ~Literal[42]) | float | complex case 1j: reveal_type(x) # revealed: Literal["bar", b"foo"] | (int & ~Literal[42]) | float | complex case b"foo": - reveal_type(x) # revealed: (int & ~Literal[42]) | Literal[b"foo"] | float | complex + reveal_type(x) # revealed: Literal[b"foo"] | float | complex case _: reveal_type(x) # revealed: Literal["bar"] | (int & ~Literal[42]) | float | complex ``` @@ -3132,11 +3170,11 @@ class C: def _(x: Literal["foo", b"bar"] | int): match x: - case "foo" if reveal_type(x): # revealed: Literal["foo"] | int + case "foo" if reveal_type(x): # revealed: Literal["foo"] pass - case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int + case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] pass - case 42 if reveal_type(x): # revealed: int + case 42 if reveal_type(x): # revealed: Literal[42] pass ``` @@ -3232,9 +3270,9 @@ from typing import Literal def _(x: Literal["foo", b"bar"] | int): match x: - case "foo" | 42 if reveal_type(x): # revealed: Literal["foo"] | int + case "foo" | 42 if reveal_type(x): # revealed: Literal["foo", 42] pass - case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int + case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] pass case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int pass diff --git a/crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md b/crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md index b004df8610..a13db81b9d 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md +++ b/crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md @@ -65,15 +65,14 @@ static_assert(is_single_valued(Literal[1])) static_assert(is_single_valued(Literal[54165])) ``` -And this can be used for type-narrowing using not-equal comparisons: +And this can be used for type-narrowing using equality comparisons: ```py def f(x: int): if x == 54165: - # The reason that no narrowing occurs here is that there might be subclasses of `int` - # that override `__eq__`. This is not specific to integer literals though, and generally - # applies to `==` comparisons. - reveal_type(x) # revealed: int + # By default, ty assumes that a broad `int` uses builtin equality. The + # Enabling `strict-literal-narrowing` disables this narrowing. + reveal_type(x) # revealed: Literal[54165] if x != 54165: reveal_type(x) # revealed: int & ~Literal[54165] diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 098bfa071a..03ea13be8c 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -91,6 +91,10 @@ pub fn register_lints(registry: &mut LintRegistryBuilder) { #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub struct AnalysisSettings { + /// Whether equality-based checks preserve broad builtin types rather than narrow them to + /// literal types. + pub strict_literal_narrowing: bool, + /// Whether errors can be suppressed with `type: ignore` comments. /// /// If set to false, ty won't: @@ -108,6 +112,7 @@ pub struct AnalysisSettings { impl Default for AnalysisSettings { fn default() -> Self { Self { + strict_literal_narrowing: false, respect_type_ignore_comments: true, allowed_unresolved_imports: ModuleGlobSet::empty(), replace_imports_with_any: ModuleGlobSet::empty(), diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index 2d3ca67378..d22e85a1ae 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -129,6 +129,7 @@ pub(super) fn evaluate_type_equality<'db>( left: Type<'db>, right: Type<'db>, is_positive: bool, + soundness_policy: ComparisonSoundnessPolicy, ) -> Option> { let branch = ComparisonBranch::from(is_positive); let condition_expects_equality = @@ -157,7 +158,7 @@ pub(super) fn evaluate_type_equality<'db>( if comparison_domain(db, left, right, ComparisonOperator::Equality) == ComparisonDomain::Known { - ComparisonEvaluator::new(db) + ComparisonEvaluator::new(db, soundness_policy) .evaluate(left, right, branch, ComparisonOperator::Equality) .constraint(branch) } else { @@ -175,7 +176,7 @@ pub(super) fn equality_exclusion_constraint<'db>( builtin_literal_constraint(db, ty, ty, ComparisonOperator::Equality, false) .or_else(|| ty.is_single_valued(db).then(|| ty.negate(db))) .or_else(|| { - (ComparisonEvaluator::new(db).evaluate( + (ComparisonEvaluator::conservative(db).evaluate( ty, ty, ComparisonBranch::Positive, @@ -210,6 +211,7 @@ pub(super) fn evaluate_type_inequality<'db>( left: Type<'db>, right: Type<'db>, is_positive: bool, + soundness_policy: ComparisonSoundnessPolicy, ) -> Option> { let branch = ComparisonBranch::from(is_positive); let condition_expects_equality = @@ -231,7 +233,7 @@ pub(super) fn evaluate_type_inequality<'db>( ) }) .or_else(|| { - ComparisonEvaluator::new(db) + ComparisonEvaluator::new(db, soundness_policy) .evaluate(left, right, branch, ComparisonOperator::Inequality) .constraint(branch) }) @@ -307,6 +309,22 @@ enum ComparisonGoal { Truthiness, } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(super) enum ComparisonSoundnessPolicy { + Conservative, + UnsafeLiteralNarrowing, +} + +impl ComparisonSoundnessPolicy { + pub(super) fn from_strict_literal_narrowing(enabled: bool) -> Self { + if enabled { + Self::Conservative + } else { + Self::UnsafeLiteralNarrowing + } + } +} + /// Identifies an active comparison evaluation. /// /// Operand order and branch are significant because the left operand is the narrowing target. @@ -323,22 +341,29 @@ struct ComparisonEvaluator<'db> { db: &'db dyn Db, active: FxHashSet>, goal: ComparisonGoal, + soundness_policy: ComparisonSoundnessPolicy, } impl<'db> ComparisonEvaluator<'db> { - fn new(db: &'db dyn Db) -> Self { + fn new(db: &'db dyn Db, soundness_policy: ComparisonSoundnessPolicy) -> Self { Self { db, active: FxHashSet::default(), goal: ComparisonGoal::Constraint, + soundness_policy, } } + fn conservative(db: &'db dyn Db) -> Self { + Self::new(db, ComparisonSoundnessPolicy::Conservative) + } + fn for_truthiness(db: &'db dyn Db) -> Self { Self { db, active: FxHashSet::default(), goal: ComparisonGoal::Truthiness, + soundness_policy: ComparisonSoundnessPolicy::Conservative, } } @@ -512,7 +537,7 @@ fn evaluate_comparison_once<'db>( } (Type::LiteralValue(literal), other) => compare_literal_to_other( - db, + evaluator, Type::LiteralValue(literal), literal.kind(), other, @@ -521,7 +546,7 @@ fn evaluate_comparison_once<'db>( LiteralOperand::Target, ), (other, Type::LiteralValue(literal)) => compare_literal_to_other( - db, + evaluator, Type::LiteralValue(literal), literal.kind(), other, @@ -643,30 +668,19 @@ fn builtin_literal_constraint<'db>( return None; }; - let mut equal_to_right = match right.kind() { - LiteralValueTypeKind::Int(value) => { - let mut builder = UnionBuilder::new(db).add(Type::LiteralValue(right)); - if matches!(value.as_i64(), 0 | 1) { - builder = builder.add(Type::bool_literal(value.as_i64() == 1)); - } - builder - } - LiteralValueTypeKind::Bool(value) => UnionBuilder::new(db) - .add(Type::LiteralValue(right)) - .add(Type::int_literal(i64::from(value))), - LiteralValueTypeKind::String(_) | LiteralValueTypeKind::Bytes(_) => { - UnionBuilder::new(db).add(Type::LiteralValue(right)) - } - LiteralValueTypeKind::LiteralString | LiteralValueTypeKind::Enum(_) => return None, - }; + let equal_to_right = builtin_literals_equal_to(db, Type::LiteralValue(right), right.kind())?; if !condition_expects_equality { - equal_to_right = add_equal_enum_literals(db, left, right.kind(), operator, equal_to_right); + let equal_to_right = add_equal_enum_literals( + db, + left, + right.kind(), + operator, + UnionBuilder::new(db).add(equal_to_right), + ); return Some(equal_to_right.build().negate(db)); } - let equal_to_right = equal_to_right.build(); - match left.resolve_type_alias(db) { Type::Union(union) => union .elements(db) @@ -678,6 +692,31 @@ fn builtin_literal_constraint<'db>( .then_some(equal_to_right) } +/// Return the builtin literal values that compare equal to `literal_type`. +fn builtin_literals_equal_to<'db>( + db: &'db dyn Db, + literal_type: Type<'db>, + literal: LiteralValueTypeKind<'db>, +) -> Option> { + let builder = match literal { + LiteralValueTypeKind::Int(value) => { + let mut builder = UnionBuilder::new(db).add(literal_type); + if matches!(value.as_i64(), 0 | 1) { + builder = builder.add(Type::bool_literal(value.as_i64() == 1)); + } + builder + } + LiteralValueTypeKind::Bool(value) => UnionBuilder::new(db) + .add(literal_type) + .add(Type::int_literal(i64::from(value))), + LiteralValueTypeKind::String(_) | LiteralValueTypeKind::Bytes(_) => { + UnionBuilder::new(db).add(literal_type) + } + LiteralValueTypeKind::LiteralString | LiteralValueTypeKind::Enum(_) => return None, + }; + Some(builder.build()) +} + /// Add finite enum members in `ty` that are known to compare equal to `right`. fn add_equal_enum_literals<'db>( db: &'db dyn Db, @@ -1132,12 +1171,29 @@ fn narrow_literal_string_against_enum<'db>( ComparisonResult::CanNarrow(narrowed) } +/// Return the builtin comparison semantics assumed by unsafe literal narrowing. +fn unsafe_narrowable_builtin_semantics(db: &dyn Db, ty: Type) -> Option { + let Type::NominalInstance(instance) = ty.resolve_type_alias(db) else { + return None; + }; + + if instance.has_known_class(db, KnownClass::Int) { + Some(KnownComparisonSemantics::Int) + } else if instance.has_known_class(db, KnownClass::Str) { + Some(KnownComparisonSemantics::Str) + } else if instance.has_known_class(db, KnownClass::Bytes) { + Some(KnownComparisonSemantics::Bytes) + } else { + None + } +} + /// Compare a literal with a non-literal type using their known runtime comparison semantics. /// /// A literal on the non-target side can constrain the target only when the types overlap; matching /// comparison implementations alone do not establish that the literal inhabits the target type. fn compare_literal_to_other<'db>( - db: &'db dyn Db, + evaluator: &ComparisonEvaluator<'db>, literal_type: Type<'db>, literal: LiteralValueTypeKind<'db>, other: Type<'db>, @@ -1145,6 +1201,8 @@ fn compare_literal_to_other<'db>( operator: ComparisonOperator, literal_operand: LiteralOperand, ) -> ComparisonResult<'db> { + let db = evaluator.db; + if matches!(literal, LiteralValueTypeKind::LiteralString) { return match KnownComparisonSemantics::of_type(db, other, operator) { Some(KnownComparisonSemantics::Str) => ComparisonResult::Ambiguous, @@ -1158,6 +1216,23 @@ fn compare_literal_to_other<'db>( return ComparisonResult::Ambiguous; }; let condition_expects_equality = operator.condition_expects_equality(branch); + + // Treat broad builtin types as if they exclude subclasses with custom equality. This is + // intentionally unsafe: an instance of such a subclass can compare equal to the literal + // without inhabiting its literal type. Explicitly typed subclasses do not take this path. + if evaluator.soundness_policy == ComparisonSoundnessPolicy::UnsafeLiteralNarrowing + && condition_expects_equality + && literal_operand == LiteralOperand::Other + && let Some(equal_to_literal) = builtin_literals_equal_to(db, literal_type, literal) + && let Some(other_semantics) = unsafe_narrowable_builtin_semantics(db, other) + { + return if literal_semantics == other_semantics { + ComparisonResult::CanNarrow(equal_to_literal) + } else { + operator.result_from_equality(false) + }; + } + match KnownComparisonSemantics::of_type(db, other, operator) { Some(other_semantics) if literal_semantics != other_semantics => { ComparisonResult::from_bool(operator == ComparisonOperator::Inequality) diff --git a/crates/ty_python_semantic/src/types/match_pattern.rs b/crates/ty_python_semantic/src/types/match_pattern.rs index 8c2c97229f..1b4e47e2bf 100644 --- a/crates/ty_python_semantic/src/types/match_pattern.rs +++ b/crates/ty_python_semantic/src/types/match_pattern.rs @@ -9,7 +9,9 @@ use ty_python_core::predicate::{ use crate::Db; use crate::place::{DefinedPlace, Place}; use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; -use crate::types::equality::{evaluate_type_equality, is_same_enum_domain}; +use crate::types::equality::{ + ComparisonSoundnessPolicy, evaluate_type_equality, is_same_enum_domain, +}; use crate::types::signatures::CallableSignature; use crate::types::tuple::TupleType; use crate::types::visitor::any_over_type; @@ -709,7 +711,16 @@ pub(crate) fn pattern_fallthrough_type<'db>( .add_negative(value_ty) .build(); } - if let Some(constraint) = evaluate_type_equality(db, subject_ty, value_ty, false) { + if let Some(constraint) = evaluate_type_equality( + db, + subject_ty, + value_ty, + false, + ComparisonSoundnessPolicy::from_strict_literal_narrowing( + db.analysis_settings(value.file(db)) + .strict_literal_narrowing, + ), + ) { return IntersectionBuilder::new(db) .add_positive(subject_ty) .add_positive(constraint) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 2079a62f33..9123914163 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -42,8 +42,8 @@ use super::UnionType; use super::call::CallArguments; use super::constraints::{ConstraintSetBuilder, PathBounds, Solutions}; use super::equality::{ - equality_exclusion_constraint, equality_truthiness, evaluate_type_equality, - evaluate_type_inequality, + ComparisonSoundnessPolicy, equality_exclusion_constraint, equality_truthiness, + evaluate_type_equality, evaluate_type_inequality, }; use super::variance::TypeVarVariance; use itertools::Itertools; @@ -1349,6 +1349,14 @@ impl<'db> PatternSuccessAnalyzer<'db> { Self { db, scope } } + fn comparison_soundness_policy(&self) -> ComparisonSoundnessPolicy { + ComparisonSoundnessPolicy::from_strict_literal_narrowing( + self.db + .analysis_settings(self.scope.file(self.db)) + .strict_literal_narrowing, + ) + } + fn merge_binding( bindings: &mut BTreeMap>, place: ScopedPlaceId, @@ -1542,9 +1550,15 @@ impl<'db> PatternSuccessAnalyzer<'db> { subject_ty: Type<'db>, ) -> Type<'db> { let value_ty = infer_same_file_expression_type(self.db, value, TypeContext::default()); - evaluate_type_equality(self.db, subject_ty, value_ty, true) - .map(|constraint| self.intersect_types(subject_ty, constraint)) - .unwrap_or(subject_ty) + evaluate_type_equality( + self.db, + subject_ty, + value_ty, + true, + self.comparison_soundness_policy(), + ) + .map(|constraint| self.intersect_types(subject_ty, constraint)) + .unwrap_or(subject_ty) } fn analyze_successful_or_pattern( @@ -2629,6 +2643,14 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } } + fn comparison_soundness_policy(&self) -> ComparisonSoundnessPolicy { + ComparisonSoundnessPolicy::from_strict_literal_narrowing( + self.db + .analysis_settings(self.scope().file(self.db)) + .strict_literal_narrowing, + ) + } + #[track_caller] fn expect_place(&self, place_expr: &PlaceExpr) -> ScopedPlaceId { self.places() @@ -2899,6 +2921,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { ) -> Option> { let lhs_ty = lhs_ty.resolve_type_alias(self.db); let rhs_ty = rhs_ty.resolve_type_alias(self.db); + let soundness_policy = self.comparison_soundness_policy(); // Preserve the shared specialization of a constrained TypeVar. Expanding the TypeVar // before comparing it with `lhs_ty` would lose the correlation between this occurrence @@ -2907,7 +2930,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { && let Some(TypeVarBoundOrConstraints::Constraints(constraints)) = typevar.typevar(self.db).bound_or_constraints(self.db) && constraints.elements(self.db).iter().all(|constraint| { - evaluate_type_equality(self.db, lhs_ty, *constraint, true) + evaluate_type_equality(self.db, lhs_ty, *constraint, true, soundness_policy) .is_some_and(|narrowed| narrowed.is_equivalent_to(self.db, *constraint)) }) { @@ -2922,13 +2945,13 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { _ => lhs_ty.is_single_valued(self.db), }; if !has_single_valued_component { - return evaluate_type_equality(self.db, lhs_ty, rhs_ty, true); + return evaluate_type_equality(self.db, lhs_ty, rhs_ty, true, soundness_policy); } let mut builder = UnionBuilder::new(self.db); let add_lhs_element = |builder: UnionBuilder<'db>, element: Type<'db>| { let element = element.resolve_type_alias(self.db); - match evaluate_type_equality(self.db, element, rhs_ty, true) { + match evaluate_type_equality(self.db, element, rhs_ty, true, soundness_policy) { Some(Type::Never) => builder, Some(constraint) => builder.add(constraint), None if !element.is_single_valued(self.db) => builder.add(element), @@ -2979,10 +3002,22 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { is_positive: bool, ) -> Option> { if op == ast::CmpOp::Eq { - return evaluate_type_equality(self.db, lhs_ty, rhs_ty, is_positive); + return evaluate_type_equality( + self.db, + lhs_ty, + rhs_ty, + is_positive, + self.comparison_soundness_policy(), + ); } if op == ast::CmpOp::NotEq { - return evaluate_type_inequality(self.db, lhs_ty, rhs_ty, is_positive); + return evaluate_type_inequality( + self.db, + lhs_ty, + rhs_ty, + is_positive, + self.comparison_soundness_policy(), + ); } let op = if is_positive { op } else { op.negate() }; diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index da77c081a3..8fccb66153 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -163,6 +163,7 @@ Settings: Settings { }, }, analysis: AnalysisSettings { + strict_literal_narrowing: false, respect_type_ignore_comments: true, allowed_unresolved_imports: ModuleGlobSet { regex_set: RegexSet([]), diff --git a/crates/ty_test/src/config.rs b/crates/ty_test/src/config.rs index 554d6eb7fd..1fc94b6136 100644 --- a/crates/ty_test/src/config.rs +++ b/crates/ty_test/src/config.rs @@ -121,6 +121,9 @@ pub(crate) struct Environment { #[derive(Deserialize, Default, Debug, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct Analysis { + /// Whether equality comparisons should only narrow to literals when it is safe to do so. + pub(crate) strict_literal_narrowing: Option, + /// Whether ty should support `type: ignore` comments. pub(crate) respect_type_ignore_comments: Option, diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 56e9151ef0..b15a167e20 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -60,6 +60,7 @@ impl Db { pub(crate) fn update_analysis_options(&mut self, options: Option<&Analysis>) { let analysis = if let Some(options) = options { let AnalysisSettings { + strict_literal_narrowing: strict_literal_narrowing_default, respect_type_ignore_comments: respect_type_ignore_comments_default, allowed_unresolved_imports: allowed_unresolved_imports_default, replace_imports_with_any: replace_imports_with_any_default, @@ -94,6 +95,9 @@ impl Db { }; AnalysisSettings { + strict_literal_narrowing: options + .strict_literal_narrowing + .unwrap_or(strict_literal_narrowing_default), respect_type_ignore_comments: options .respect_type_ignore_comments .unwrap_or(respect_type_ignore_comments_default), diff --git a/ty.schema.json b/ty.schema.json index 8bf98fb204..5740aae740 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -98,6 +98,13 @@ "boolean", "null" ] + }, + "strict-literal-narrowing": { + "description": "Whether equality-based checks should preserve broad builtin types rather than narrow them to\nliteral types.\n\nBy default, ty narrows `value` from `str` to `Literal[\"a\"]` in the positive branch of\n`value == \"a\"`. When this option is enabled, `value` remains `str`. This also applies to\nmembership tests and literal match patterns, which use equality comparisons.\n\n```python\nfrom typing import Literal\n\ndef parse(value: str) -> Literal[\"a\"] | None:\n if value == \"a\":\n return value # Accepted by default; `value` remains `str` in strict mode.\n return None\n```\n\nBroad builtin types include subclasses, but literal types distinguish values by both their\nruntime type and value. This makes the narrowing unsound even for subclasses that inherit\nbuiltin equality. For example:\n\n```python\nclass StringSubclass(str): ...\n\nresult = parse(StringSubclass(\"a\"))\n# Statically `Literal[\"a\"] | None`, but `result` has runtime type `StringSubclass`.\n```\n\nA subclass can also override `__eq__` to compare equal to a literal with a different value:\n\n```python\nclass MisleadingStr(str):\n def __eq__(self, other: object) -> bool:\n return True\n\nresult = parse(MisleadingStr(\"b\"))\n# Statically `Literal[\"a\"] | None`, but `result` contains `\"b\"` at runtime.\n```\n\nEnable this option to preserve the broader builtin type instead.\n\nDefaults to `false`.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false From 945b74d229bbea7855862454910664d8ff2e8a9f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 14:53:51 -0400 Subject: [PATCH 021/222] [ty] Exempt ParamSpec callables from the dunder descriptor heuristic (#26696) ## Summary We generally treat `Callable`-typed dunder class members as function-like descriptors. This is deliberately a name-based heuristic: a `Callable` annotation can describe either a function descriptor or an arbitrary callable object, so the type alone does not tell us whether instance access should bind the first parameter. The heuristic nevertheless supports common patterns where special methods are installed dynamically and explicitly include the instance in their signature: ```python def pow_impl(tensor: Tensor, exponent: int) -> Tensor: ... class Tensor: __pow__: Callable[[Tensor, int], Tensor] = pow_impl Tensor() ** 2 ``` There are two narrow cases where we should not apply this heuristic. A callable with no parameters cannot accept a receiver, and a callable parameterized directly by a `ParamSpec` does not identify a receiver parameter: ```python class Task[**P](Protocol): __call__: Callable[P, int] def check(task: Task[[str]]) -> int: return task("value") ``` Once `P` is specialized to `(str,)`, converting the member to a function-like callable would bind away the `str` parameter and expose `() -> int` instead. We now record that a dunder callable was declared with a bare `ParamSpec` before generic specialization and preserve it as a regular callable during instance and protocol binding. Parameterless callable dunders likewise remain regular. All other concrete and gradual `Callable` dunders retain the existing descriptor heuristic. In particular, we deliberately do not inspect whether the first parameter annotation can accept the owning instance. Closes https://github.com/astral-sh/ty/issues/3957. --- .../mdtest/call/callables_as_descriptors.md | 38 +++++++++++++++++++ .../resources/mdtest/call/dunder.md | 17 +++++++++ .../ty_python_semantic/src/types/callable.rs | 36 ++++++++++++++++++ .../src/types/class/static_literal.rs | 35 ++++++++++++++--- .../src/types/protocol_class.rs | 11 ++---- .../src/types/signatures.rs | 6 +++ 6 files changed, 129 insertions(+), 14 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index cfa9f85088..fd0746200d 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -282,6 +282,44 @@ class Matrix: Matrix() < Matrix() ``` +The dunder-name heuristic does not apply when the callable takes no arguments, because it cannot +accept a receiver: + +```py +class Thunk: + __value_thunk__: Callable[[], int] + + def replace(self, other: "Thunk") -> None: + self.__value_thunk__ = other.__value_thunk__ + +reveal_type(Thunk().__value_thunk__) # revealed: () -> int +``` + +For other concrete signatures, the heuristic does not check whether the first parameter can accept +the instance: + +```py +def descriptor_candidate(value: str) -> int: + return len(value) + +class DescriptorCandidate: + __value__: Callable[[str], int] = descriptor_candidate + +reveal_type(DescriptorCandidate().__value__) # revealed: () -> int +``` + +A gradual callable signature might accept the receiver, so we preserve the function-descriptor +heuristic. This also preserves function attributes on class access: + +```py +from typing import Any + +class Method: + __call__: Callable[..., Any] + +Method.__call__.__code__ +``` + ## `self`-binding behaviour of function-like `Callable`s Binding the `self` parameter of a function-like `Callable` creates a new `Callable` that is also diff --git a/crates/ty_python_semantic/resources/mdtest/call/dunder.md b/crates/ty_python_semantic/resources/mdtest/call/dunder.md index 64efc7b6c6..7346488e91 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/dunder.md +++ b/crates/ty_python_semantic/resources/mdtest/call/dunder.md @@ -138,6 +138,23 @@ C()() _: Callable[..., None] = C() ``` +The dunder-name heuristic also does not apply to a callable parameterized by a `ParamSpec`, even +after the `ParamSpec` is specialized: + +```py +from collections.abc import Callable +from typing import ParamSpec, Protocol + +P = ParamSpec("P") + +class C(Protocol[P]): + __call__: Callable[P, int] + +def check(value: C[[str]]) -> None: + reveal_type(value.__call__) # revealed: (str, /) -> int + reveal_type(value("value")) # revealed: int +``` + And of course the same is true if we have only an implicit assignment inside a method: ```py diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index f74d33064d..cc114e168e 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -318,6 +318,12 @@ pub enum CallableTypeKind { /// instances, i.e. they bind `self`. FunctionLike, + /// Represents a `Callable[P, R]`-typed dunder attribute. + /// + /// This is distinct from [`Self::Regular`] so that the dunder descriptor heuristic does not + /// turn the callable into a function-like object after `P` is specialized. + DunderParamSpec, + /// A callable type that represents a staticmethod. These callables do not bind `self` /// when accessed as attributes on instances - they return the underlying function as-is. StaticMethodLike, @@ -480,6 +486,14 @@ impl<'db> CallableType<'db> { matches!(self.kind(db), CallableTypeKind::FunctionLike) } + pub(crate) fn is_dunder_paramspec(self, db: &'db dyn Db) -> bool { + matches!(self.kind(db), CallableTypeKind::DunderParamSpec) + } + + pub(crate) fn is_regular(self, db: &'db dyn Db) -> bool { + matches!(self.kind(db), CallableTypeKind::Regular) + } + pub(crate) fn is_classmethod_like(self, db: &'db dyn Db) -> bool { matches!(self.kind(db), CallableTypeKind::ClassMethodLike) } @@ -542,6 +556,10 @@ impl<'db> CallableType<'db> { db: &'db dyn Db, self_type: Option>, ) -> CallableType<'db> { + if self.is_dunder_paramspec(db) { + return self.into_regular(db); + } + CallableType::new( db, self.signatures(db).bind_self(db, self_type), @@ -550,6 +568,24 @@ impl<'db> CallableType<'db> { ) } + pub(crate) fn into_function_like(self, db: &'db dyn Db) -> CallableType<'db> { + CallableType::new( + db, + self.signatures(db), + CallableTypeKind::FunctionLike, + self.provenance(db), + ) + } + + pub(crate) fn into_dunder_paramspec(self, db: &'db dyn Db) -> CallableType<'db> { + CallableType::new( + db, + self.signatures(db), + CallableTypeKind::DunderParamSpec, + self.provenance(db), + ) + } + pub(crate) fn apply_self(self, db: &'db dyn Db, self_type: Type<'db>) -> CallableType<'db> { CallableType::new( db, diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 8e66c80b4f..35ecc5173f 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -1117,12 +1117,12 @@ impl<'db> StaticClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { fn into_function_like_callable<'d>(db: &'d dyn Db, ty: Type<'d>) -> Type<'d> { match ty { - Type::Callable(callable_ty) => Type::Callable(CallableType::new( - db, - callable_ty.signatures(db), - CallableTypeKind::FunctionLike, - callable_ty.provenance(db), - )), + Type::Callable(callable_ty) + if callable_ty.is_regular(db) + && callable_ty.signatures(db).has_parameters() => + { + Type::Callable(callable_ty.into_function_like(db)) + } Type::Union(union) => { union.map(db, |element| into_function_like_callable(db, *element)) } @@ -1168,6 +1168,23 @@ impl<'db> StaticClassLiteral<'db> { specialization: Option>, name: &str, ) -> Member<'db> { + fn into_dunder_paramspec_callable<'d>(db: &'d dyn Db, ty: Type<'d>) -> Type<'d> { + match ty { + Type::Callable(callable_ty) + if callable_ty.is_regular(db) + && callable_ty.signatures(db).is_single_paramspec().is_some() => + { + Type::Callable(callable_ty.into_dunder_paramspec(db)) + } + Type::Union(union) => { + union.map(db, |element| into_dunder_paramspec_callable(db, *element)) + } + Type::Intersection(intersection) => intersection + .map_positive(db, |element| into_dunder_paramspec_callable(db, *element)), + _ => ty, + } + } + // Check if this class is dataclass-like (either via @dataclass or via dataclass_transform) if CodeGeneratorKind::from_class(db, self.into()) .is_some_and(CodeGeneratorKind::is_dataclass_like) @@ -1211,6 +1228,12 @@ impl<'db> StaticClassLiteral<'db> { let body_scope = self.body_scope(db); let member = class_member(db, body_scope, name).map_type(|ty| { + let ty = if name.starts_with("__") && name.ends_with("__") { + into_dunder_paramspec_callable(db, ty) + } else { + ty + }; + // The `__new__` and `__init__` members of a non-specialized generic class are handled // specially: they inherit the generic context of their class. That lets us treat them // as generic functions when constructing the class, and infer the specialization of diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 0f3ca723b6..b2d908be20 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -11,7 +11,6 @@ use crate::types::attribute_write::{ FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, attribute_write_requirement, }; use crate::types::call::{CallArguments, CallDunderError}; -use crate::types::callable::CallableTypeKind; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; use crate::types::{TypeContext, UpcastPolicy}; use crate::{ @@ -2119,7 +2118,8 @@ fn proto_interface_cycle_recover<'db>( value.cycle_normalized(db, *previous, cycle) } -/// Bind `self`, and *also* discard the functionlike-ness of the callable. +/// Bind `self` unless this is a `Callable[P, R]` dunder, and *also* discard the functionlike-ness +/// of the callable. /// /// This additional upcasting is required in order for protocols with `__call__` method /// members to be considered assignable to `Callable` types, since the `Callable` supertype @@ -2129,12 +2129,7 @@ fn protocol_bind_self<'db>( callable: CallableType<'db>, self_type: Option>, ) -> CallableType<'db> { - CallableType::new( - db, - callable.signatures(db).bind_self(db, self_type), - CallableTypeKind::Regular, - callable.provenance(db), - ) + callable.bind_self(db, self_type).into_regular(db) } /// Return the possible output type of a callable unless any overload returns `Never`. diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index e6f13473ac..07bee7a3e8 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -398,6 +398,12 @@ impl<'db> CallableSignature<'db> { } } + pub(crate) fn has_parameters(&self) -> bool { + self.overloads + .iter() + .any(|signature| !signature.parameters().as_slice().is_empty()) + } + /// Replaces any occurrences of `typing.Self` in the parameter and return annotations with the /// given type. (Does not bind the `self` parameter; to do that, use /// [`bind_self`][Self::bind_self].) From 035ebc332af34b7e301606dcc74d997092be2316 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 14:54:01 -0400 Subject: [PATCH 022/222] [ty] Document enum subclass literal narrowing (#26709) ## Summary This follows up on #26414 by documenting that the standard library's `StrEnum` and `IntEnum` types are subclasses of `str` and `int`, respectively, and are therefore affected by unsafe builtin literal narrowing. The new `StrEnum` example shows how a call can be statically typed as returning `Literal["a"] | None` while returning a `Choice` enum member at runtime. The generated configuration documentation and schema now include the clarification. --- crates/ty/docs/configuration.md | 26 +++++++++++++++++++++++ crates/ty_project/src/metadata/options.rs | 13 ++++++++++++ ty.schema.json | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index b7dffda5da..9d020cc0ed 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -189,6 +189,19 @@ result = parse(StringSubclass("a")) # Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. ``` +The standard library's `StrEnum` and `IntEnum` types are also subclasses of `str` and `int`, +respectively. This means enum members can encounter the same unsoundness: + +```python +from enum import StrEnum + +class Choice(StrEnum): + A = "a" + +result = parse(Choice.A) +# Statically `Literal["a"] | None`, but `result` has runtime type `Choice`. +``` + A subclass can also override `__eq__` to compare equal to a literal with a different value: ```python @@ -751,6 +764,19 @@ result = parse(StringSubclass("a")) # Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. ``` +The standard library's `StrEnum` and `IntEnum` types are also subclasses of `str` and `int`, +respectively. This means enum members can encounter the same unsoundness: + +```python +from enum import StrEnum + +class Choice(StrEnum): + A = "a" + +result = parse(Choice.A) +# Statically `Literal["a"] | None`, but `result` has runtime type `Choice`. +``` + A subclass can also override `__eq__` to compare equal to a literal with a different value: ```python diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 7f88ea4d02..e13f2d7826 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1492,6 +1492,19 @@ pub struct AnalysisOptions { /// # Statically `Literal["a"] | None`, but `result` has runtime type `StringSubclass`. /// ``` /// + /// The standard library's `StrEnum` and `IntEnum` types are also subclasses of `str` and `int`, + /// respectively. This means enum members can encounter the same unsoundness: + /// + /// ```python + /// from enum import StrEnum + /// + /// class Choice(StrEnum): + /// A = "a" + /// + /// result = parse(Choice.A) + /// # Statically `Literal["a"] | None`, but `result` has runtime type `Choice`. + /// ``` + /// /// A subclass can also override `__eq__` to compare equal to a literal with a different value: /// /// ```python diff --git a/ty.schema.json b/ty.schema.json index 5740aae740..d05b26ccd3 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -100,7 +100,7 @@ ] }, "strict-literal-narrowing": { - "description": "Whether equality-based checks should preserve broad builtin types rather than narrow them to\nliteral types.\n\nBy default, ty narrows `value` from `str` to `Literal[\"a\"]` in the positive branch of\n`value == \"a\"`. When this option is enabled, `value` remains `str`. This also applies to\nmembership tests and literal match patterns, which use equality comparisons.\n\n```python\nfrom typing import Literal\n\ndef parse(value: str) -> Literal[\"a\"] | None:\n if value == \"a\":\n return value # Accepted by default; `value` remains `str` in strict mode.\n return None\n```\n\nBroad builtin types include subclasses, but literal types distinguish values by both their\nruntime type and value. This makes the narrowing unsound even for subclasses that inherit\nbuiltin equality. For example:\n\n```python\nclass StringSubclass(str): ...\n\nresult = parse(StringSubclass(\"a\"))\n# Statically `Literal[\"a\"] | None`, but `result` has runtime type `StringSubclass`.\n```\n\nA subclass can also override `__eq__` to compare equal to a literal with a different value:\n\n```python\nclass MisleadingStr(str):\n def __eq__(self, other: object) -> bool:\n return True\n\nresult = parse(MisleadingStr(\"b\"))\n# Statically `Literal[\"a\"] | None`, but `result` contains `\"b\"` at runtime.\n```\n\nEnable this option to preserve the broader builtin type instead.\n\nDefaults to `false`.", + "description": "Whether equality-based checks should preserve broad builtin types rather than narrow them to\nliteral types.\n\nBy default, ty narrows `value` from `str` to `Literal[\"a\"]` in the positive branch of\n`value == \"a\"`. When this option is enabled, `value` remains `str`. This also applies to\nmembership tests and literal match patterns, which use equality comparisons.\n\n```python\nfrom typing import Literal\n\ndef parse(value: str) -> Literal[\"a\"] | None:\n if value == \"a\":\n return value # Accepted by default; `value` remains `str` in strict mode.\n return None\n```\n\nBroad builtin types include subclasses, but literal types distinguish values by both their\nruntime type and value. This makes the narrowing unsound even for subclasses that inherit\nbuiltin equality. For example:\n\n```python\nclass StringSubclass(str): ...\n\nresult = parse(StringSubclass(\"a\"))\n# Statically `Literal[\"a\"] | None`, but `result` has runtime type `StringSubclass`.\n```\n\nThe standard library's `StrEnum` and `IntEnum` types are also subclasses of `str` and `int`,\nrespectively. This means enum members can encounter the same unsoundness:\n\n```python\nfrom enum import StrEnum\n\nclass Choice(StrEnum):\n A = \"a\"\n\nresult = parse(Choice.A)\n# Statically `Literal[\"a\"] | None`, but `result` has runtime type `Choice`.\n```\n\nA subclass can also override `__eq__` to compare equal to a literal with a different value:\n\n```python\nclass MisleadingStr(str):\n def __eq__(self, other: object) -> bool:\n return True\n\nresult = parse(MisleadingStr(\"b\"))\n# Statically `Literal[\"a\"] | None`, but `result` contains `\"b\"` at runtime.\n```\n\nEnable this option to preserve the broader builtin type instead.\n\nDefaults to `false`.", "type": [ "boolean", "null" From 9c2b709a299b58ce471b55ea370350ebe3b621e1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 16:01:57 -0400 Subject: [PATCH 023/222] [ty] Bind Self in implicit dunder calls (#26711) ## Summary In #26696, we stopped treating `Callable[P, R]`-typed dunder members as function-like descriptors because binding the first parameter after specializing `P` would incorrectly remove a real argument. However, implicit dunder lookup uses a no-instance-fallback path that returned before the normal eager `Self` binding step. As a result, a ParamSpec dunder that returned `Self` preserved the declaring class's `Self` type instead of resolving it to the concrete receiver: ```python class Base[**P]: __getitem__: Callable[P, Self] class Child(Base[[int]]): ... def check(value: Child) -> None: reveal_type(value[0]) # Self@Base, expected Child ``` We now apply the existing receiver-aware `Self` mapping to the result of no-instance-fallback descriptor lookup. Function-like callables continue to defer `Self` binding to their signature-binding path, while regular ParamSpec callables resolve `Self` without dropping any parameters. The regression coverage verifies that explicit `value.__getitem__(0)` and implicit `value[0]` access both return `Child`. --- .../resources/mdtest/call/dunder.md | 15 +++++++++++++- crates/ty_python_semantic/src/types.rs | 20 +++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/dunder.md b/crates/ty_python_semantic/resources/mdtest/call/dunder.md index 7346488e91..c080e30fa7 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/dunder.md +++ b/crates/ty_python_semantic/resources/mdtest/call/dunder.md @@ -143,7 +143,8 @@ after the `ParamSpec` is specialized: ```py from collections.abc import Callable -from typing import ParamSpec, Protocol +from typing import Generic, ParamSpec, Protocol +from typing_extensions import Self P = ParamSpec("P") @@ -153,6 +154,18 @@ class C(Protocol[P]): def check(value: C[[str]]) -> None: reveal_type(value.__call__) # revealed: (str, /) -> int reveal_type(value("value")) # revealed: int + +class Base(Generic[P]): + __getitem__: Callable[P, Self] + +class Child(Base[[int]]): + pass + +def check_self(value: Child) -> None: + reveal_type(value.__getitem__(0)) # revealed: Child + reveal_type(value[0]) # revealed: Child + + result: Child = value[0] ``` And of course the same is true if we have only an implicit assignment inside a method: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 0e9b068f58..94265cafa4 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4131,14 +4131,18 @@ impl<'db> Type<'db> { .value_type(db) .member_lookup_with_policy_and_receiver(db, name, policy, receiver), - _ if policy.no_instance_fallback() => this.invoke_descriptor_protocol( - db, - receiver.unwrap_or(this), - name_str, - Place::Undefined.into(), - InstanceFallbackShadowsNonDataDescriptor::No, - policy, - ), + _ if policy.no_instance_fallback() => { + let receiver = receiver.unwrap_or(this); + this.invoke_descriptor_protocol( + db, + receiver, + name_str, + Place::Undefined.into(), + InstanceFallbackShadowsNonDataDescriptor::No, + policy, + ) + .map_type(|ty| ty.bind_self_typevars(db, receiver)) + } Type::LiteralValue(literal) if matches!(name_str, "name" | "_name_" | "value" | "_value_") From 815468ba20c29260a8c3b5b7b951bda914c1bfbf Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:34:30 -0400 Subject: [PATCH 024/222] [`ruff`] Add rule to use human-readable names in `ruff:ignore` comments (`RUF106`) (#26682) Summary -- This is the second of our migration rules for moving from `noqa` comments to `ruff:ignore` comments with human-readable names. `RUF106` performs the second part of this transformation, converting `ruff:ignore` comments that use rule codes to `ruff:ignore` comments with human-readable names. The rule skips unknown codes, external and otherwise. Unlike `RUF105` I don't think there's anything sensible we could do with those here. Test Plan -- New mdtests and a CLI test for the combination of `RUF105` and `RUF106`. I did a local grep of our ecosystem projects and didn't find any `ruff:ignore`-style suppressions, so I don't expect any ecosystem hits for this. --- crates/ruff/tests/cli/lint.rs | 42 +++ .../rule-codes-in-suppression-comments.md | 308 ++++++++++++++++++ .../resources/mdtest/suppression/ignore.md | 18 +- crates/ruff_linter/src/checkers/noqa.rs | 4 + crates/ruff_linter/src/codes.rs | 1 + crates/ruff_linter/src/registry.rs | 8 +- .../ruff_linter/src/rules/ruff/rules/mod.rs | 2 + .../rule_codes_in_suppression_comments.rs | 40 +++ crates/ruff_linter/src/suppression.rs | 46 ++- ruff.schema.json | 2 + 10 files changed, 458 insertions(+), 13 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-suppression-comments.md create mode 100644 crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs diff --git a/crates/ruff/tests/cli/lint.rs b/crates/ruff/tests/cli/lint.rs index 8e4b23aec6..bacd40530c 100644 --- a/crates/ruff/tests/cli/lint.rs +++ b/crates/ruff/tests/cli/lint.rs @@ -3720,6 +3720,48 @@ def func(t: _T) -> _T: ); } +/// Test that `noqa` comments with rule codes +/// 1. Get replaced with Ruff-specific suppression comments (RUF105) +/// 2. Use human-readable rule names instead of codes (RUF106) +#[test] +fn noqa_comments_to_human_readable_ruff_ignores() -> Result<()> { + let fixture = CliTest::new()?; + let source = "# ruff: noqa: F401 +import os + +def foo(): + value = 1 # noqa: F841 +"; + + assert_cmd_snapshot!( + fixture + .check_command() + .args([ + "--select=F401,F841,RUF105,RUF106", + "--stdin-filename=test.py", + "--fix", + "--preview", + "-", + ]) + .pass_stdin(source), + @" + success: true + exit_code: 0 + ----- stdout ----- + # ruff:file-ignore[unused-import] + import os + + def foo(): + value = 1 # ruff:ignore[unused-variable] + + ----- stderr ----- + Found 4 errors (4 fixed, 0 remaining). + ", + ); + + Ok(()) +} + /// Test that we do not rename two different type parameters to the same name /// in one execution of Ruff (autofixing this to `class Foo[T, T]: ...` would /// introduce invalid syntax) diff --git a/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-suppression-comments.md b/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-suppression-comments.md new file mode 100644 index 0000000000..e202300431 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-suppression-comments.md @@ -0,0 +1,308 @@ +# `rule-codes-in-suppression-comments` (`RUF106`) + +```toml +[lint] +preview = true +select = ["RUF106"] +external = ["EXT"] +``` + +## `ruff:ignore` + +Each Ruff rule code receives a separate diagnostic. Rule names and external or unknown codes are +preserved: + +```py +# snapshot: rule-codes-in-suppression-comments +# snapshot: rule-codes-in-suppression-comments +# ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, F841] +value = 1 +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:15 + | +3 | # ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, F841] + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, F841] +3 + # ruff:ignore[unused-import, undefined-name, EXT001, UNKNOWN, F841] +4 | value = 1 + | + + +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:54 + | +3 | # ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, F841] + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, F841] +3 + # ruff:ignore[F401, undefined-name, EXT001, UNKNOWN, unused-variable] +4 | value = 1 + | +``` + +Valid human-readable names are unaffected: + +```py +# snapshot: rule-codes-in-suppression-comments +# snapshot: rule-codes-in-suppression-comments +# ruff:ignore[F401, undefined-name, F841] +value = 1 +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:7:15 + | +7 | # ruff:ignore[F401, undefined-name, F841] + | ^^^^ + | +help: Replace rule code with name + | +6 | # snapshot: rule-codes-in-suppression-comments + - # ruff:ignore[F401, undefined-name, F841] +7 + # ruff:ignore[unused-import, undefined-name, F841] +8 | value = 1 + | + + +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:7:37 + | +7 | # ruff:ignore[F401, undefined-name, F841] + | ^^^^ + | +help: Replace rule code with name + | +6 | # snapshot: rule-codes-in-suppression-comments + - # ruff:ignore[F401, undefined-name, F841] +7 + # ruff:ignore[F401, undefined-name, unused-variable] +8 | value = 1 + | +``` + +## `ruff:file-ignore` + +```py +# snapshot: rule-codes-in-suppression-comments +# snapshot: rule-codes-in-suppression-comments +# ruff:file-ignore[F401, F841] +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:20 + | +3 | # ruff:file-ignore[F401, F841] + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:file-ignore[F401, F841] +3 + # ruff:file-ignore[unused-import, F841] + | + + +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:26 + | +3 | # ruff:file-ignore[F401, F841] + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:file-ignore[F401, F841] +3 + # ruff:file-ignore[F401, unused-variable] + | +``` + +## Matched `ruff:disable` and `ruff:enable` + +Matching comments are reported and fixed together: + +```py +# snapshot: rule-codes-in-suppression-comments +# snapshot: rule-codes-in-suppression-comments +# ruff:disable[F401, undefined-name, F841] +value = 1 +# ruff:enable[F401, undefined-name, F841] +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:16 + | +3 | # ruff:disable[F401, undefined-name, F841] + | ^^^^ +4 | value = 1 +5 | # ruff:enable[F401, undefined-name, F841] + | ---- + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:disable[F401, undefined-name, F841] +3 + # ruff:disable[unused-import, undefined-name, F841] +4 | value = 1 + - # ruff:enable[F401, undefined-name, F841] +5 + # ruff:enable[unused-import, undefined-name, F841] + | + + +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:38 + | +3 | # ruff:disable[F401, undefined-name, F841] + | ^^^^ +4 | value = 1 +5 | # ruff:enable[F401, undefined-name, F841] + | ---- + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - # ruff:disable[F401, undefined-name, F841] +3 + # ruff:disable[F401, undefined-name, unused-variable] +4 | value = 1 + - # ruff:enable[F401, undefined-name, F841] +5 + # ruff:enable[F401, undefined-name, unused-variable] + | +``` + +## Unmatched `ruff:disable` + +An unmatched disable comment is still an effective suppression through the end of its indentation +level: + +```py +# snapshot: rule-codes-in-suppression-comments +# ruff:disable[F401] +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:2:16 + | +2 | # ruff:disable[F401] + | ^^^^ + | +help: Replace rule code with name + | +1 | # snapshot: rule-codes-in-suppression-comments + - # ruff:disable[F401] +2 + # ruff:disable[unused-import] + | +``` + +## Unmatched `ruff:enable` + +An unmatched enable comment is invalid and is left to `invalid-suppression-comment`: + +```py +# ruff:enable[F401] +``` + +## Redirected codes + +Redirected codes are replaced with the name of their canonical rule: + +```py +# snapshot: rule-codes-in-suppression-comments +# ruff:ignore[PGH001] +value = 1 +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:2:15 + | +2 | # ruff:ignore[PGH001] + | ^^^^^^ + | +help: Replace rule code with name + | +1 | # snapshot: rule-codes-in-suppression-comments + - # ruff:ignore[PGH001] +2 + # ruff:ignore[suspicious-eval-usage] +3 | value = 1 + | +``` + +## Nested suppression comments + +Only the rule codes within a nested suppression comment are replaced: + +```py +# snapshot: rule-codes-in-suppression-comments +# snapshot: rule-codes-in-suppression-comments +value = 1 # explanation # ruff:ignore[F401, F841] reason # another +``` + +```snapshot +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:40 + | +3 | value = 1 # explanation # ruff:ignore[F401, F841] reason # another + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - value = 1 # explanation # ruff:ignore[F401, F841] reason # another +3 + value = 1 # explanation # ruff:ignore[unused-import, F841] reason # another + | + + +error[RUF106]: Rule code used instead of name in suppression comment + --> src/mdtest_snippet.py:3:46 + | +3 | value = 1 # explanation # ruff:ignore[F401, F841] reason # another + | ^^^^ + | +help: Replace rule code with name + | +2 | # snapshot: rule-codes-in-suppression-comments + - value = 1 # explanation # ruff:ignore[F401, F841] reason # another +3 + value = 1 # explanation # ruff:ignore[F401, unused-variable] reason # another + | +``` + +## Comments without Ruff rule codes + +Comments containing only names and external or unknown codes are unchanged: + +```py +# ruff:ignore[unused-import, EXT001, UNKNOWN] +value = 1 +``` + +## Self-suppression + +The rule can be suppressed by its code or name: + +```py +# ruff:ignore[F401, RUF106] +value = 1 +``` + +```py +# ruff:ignore[F401, rule-codes-in-suppression-comments] +value = 1 +``` + +The diagnostic can also be suppressed with a `noqa` comment: + +```py +value = 1 # ruff:ignore[F401] # noqa: RUF106 +``` diff --git a/crates/ruff_linter/resources/mdtest/suppression/ignore.md b/crates/ruff_linter/resources/mdtest/suppression/ignore.md index 9eed7d3559..752f1dd757 100644 --- a/crates/ruff_linter/resources/mdtest/suppression/ignore.md +++ b/crates/ruff_linter/resources/mdtest/suppression/ignore.md @@ -226,7 +226,7 @@ values = [ ```toml [lint] preview = true -select = ["E501", "F401", "RUF10"] +select = ["E501", "F401", "RUF100", "RUF103", "RUF104"] ``` An intervening `ruff:ignore` directive shouldn't cause a `disable`/`enable` pair to be reported as @@ -304,7 +304,7 @@ def f(): ```toml [lint] preview = true -select = ["F401", "RUF10"] +select = ["F401", "RUF100", "RUF104"] ``` A `file-ignore` within a range suppression takes precedence and marks the `disable` as unused: @@ -568,7 +568,7 @@ help: Remove unused suppression ```toml [lint] preview = true -select = ["F401", "RUF10"] +select = ["F401", "RUF103", "RUF104"] ``` `ruff:ignore` comments nested within other comments should still work: @@ -618,7 +618,7 @@ import foo ```toml [lint] preview = true -select = ["F401", "RUF10"] +select = ["F401", "RUF103", "RUF104"] ``` Nested `disable` and `file-ignore` comments are also invalid and don't suppress diagnostics on the @@ -700,7 +700,7 @@ a = 10 ```toml [lint] preview = true -select = ["E501", "F821", "RUF10"] +select = ["E501", "F821", "RUF100", "RUF103"] ``` `RUF100` should have an unsafe fix when deleting a leading suppression would change the placement @@ -774,7 +774,7 @@ note: This is an unsafe fix and may change runtime behavior ```toml [lint] preview = true -select = ["E501", "RUF10", "FIX002"] +select = ["E501", "RUF100", "FIX002"] ``` Deleting either half of a `disable`/`enable` pair should make the fix unsafe if in a nested context: @@ -812,7 +812,7 @@ note: This is an unsafe fix and may change runtime behavior ```toml [lint] preview = true -select = ["E501", "F401", "F821", "RUF10"] +select = ["E501", "F401", "F821", "RUF100", "RUF103"] ``` Removing a code from a multi-code suppression doesn't promote the later suppression, so the fix is @@ -846,7 +846,7 @@ help: Remove unused suppression ```toml [lint] preview = true -select = ["F821", "RUF10"] +select = ["F821", "RUF102", "RUF103"] ``` The `RUF102` fix should also be unsafe when it would promote a later suppression: @@ -882,7 +882,7 @@ note: This is an unsafe fix and may change runtime behavior ```toml [lint] preview = true -select = ["F401", "F821", "RUF10"] +select = ["F401", "F821", "RUF100", "RUF103"] ``` The same applies to fixes for invalid suppression placement: diff --git a/crates/ruff_linter/src/checkers/noqa.rs b/crates/ruff_linter/src/checkers/noqa.rs index afd190c66c..8de18382a7 100644 --- a/crates/ruff_linter/src/checkers/noqa.rs +++ b/crates/ruff_linter/src/checkers/noqa.rs @@ -48,6 +48,10 @@ pub(crate) fn check_noqa( let exemption = FileExemption::from(&file_noqa_directives); + // Generate diagnostics for suppression comments before applying suppressions so that the + // diagnostics can themselves be suppressed. + suppressions.check_rule_codes(context, locator); + // Indices of diagnostics that were ignored by a `noqa` directive. let mut ignored_diagnostics = vec![]; diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index a51f84a442..a554f9fee2 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1090,6 +1090,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "103") => rules::ruff::rules::InvalidSuppressionComment, (Ruff, "104") => rules::ruff::rules::UnmatchedSuppressionComment, (Ruff, "105") => rules::ruff::rules::NoqaComments, + (Ruff, "106") => rules::ruff::rules::RuleCodesInSuppressionComments, (Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml, #[cfg(any(feature = "test-rules", test))] diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 5afccdda5a..990352abfb 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -250,9 +250,11 @@ impl Rule { pub const fn lint_source(&self) -> LintSource { match self { Rule::InvalidPyprojectToml => LintSource::PyprojectToml, - Rule::BlanketNOQA | Rule::NoqaComments | Rule::RedirectedNOQA | Rule::UnusedNOQA => { - LintSource::Noqa - } + Rule::BlanketNOQA + | Rule::NoqaComments + | Rule::RedirectedNOQA + | Rule::RuleCodesInSuppressionComments + | Rule::UnusedNOQA => LintSource::Noqa, Rule::BidirectionalUnicode | Rule::BlankLineWithWhitespace | Rule::DocLineTooLong diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index e470647b0f..71cd885d0f 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -50,6 +50,7 @@ pub(crate) use pytest_raises_ambiguous_pattern::*; pub(crate) use quadratic_list_summation::*; pub(crate) use redirected_noqa::*; pub(crate) use redundant_bool_literal::*; +pub(crate) use rule_codes_in_suppression_comments::*; pub(crate) use sort_dunder_all::*; pub(crate) use sort_dunder_slots::*; pub(crate) use starmap_zip::*; @@ -129,6 +130,7 @@ mod pytest_raises_ambiguous_pattern; mod quadratic_list_summation; mod redirected_noqa; mod redundant_bool_literal; +mod rule_codes_in_suppression_comments; mod sequence_sorting; mod sort_dunder_all; mod sort_dunder_slots; diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs new file mode 100644 index 0000000000..0ee0ff0742 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs @@ -0,0 +1,40 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::AlwaysFixableViolation; + +/// ## What it does +/// +/// Checks for rule codes in Ruff-specific suppression comments. +/// +/// ## Why is this bad? +/// +/// Human-readable rule names are easier to understand than rule codes. Using names also avoids +/// requiring readers to look up the meaning of each code. +/// +/// This rule applies to `ruff:ignore`, `ruff:file-ignore`, `ruff:disable`, and `ruff:enable` +/// comments. +/// +/// ## Example +/// +/// ```python +/// import os # ruff:ignore[F401] +/// ``` +/// +/// Use instead: +/// ```python +/// import os # ruff:ignore[unused-import] +/// ``` +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +pub(crate) struct RuleCodesInSuppressionComments; + +impl AlwaysFixableViolation for RuleCodesInSuppressionComments { + #[derive_message_formats] + fn message(&self) -> String { + "Rule code used instead of name in suppression comment".to_string() + } + + fn fix_title(&self) -> String { + "Replace rule code with name".to_string() + } +} diff --git a/crates/ruff_linter/src/suppression.rs b/crates/ruff_linter/src/suppression.rs index 85c2d7b0f1..01fe547eb9 100644 --- a/crates/ruff_linter/src/suppression.rs +++ b/crates/ruff_linter/src/suppression.rs @@ -23,7 +23,8 @@ use crate::preview::{is_human_readable_names_enabled, is_ruff_ignore_enabled}; use crate::rule_redirects::get_redirect_target; use crate::rules::ruff::rules::{ InvalidRuleCode, InvalidRuleCodeKind, InvalidSuppressionComment, InvalidSuppressionCommentKind, - UnmatchedSuppressionComment, UnusedCodes, UnusedNOQA, UnusedNOQAKind, code_is_valid, + RuleCodesInSuppressionComments, UnmatchedSuppressionComment, UnusedCodes, UnusedNOQA, + UnusedNOQAKind, code_is_valid, }; use crate::settings::LinterSettings; use crate::settings::types::PreviewMode; @@ -395,6 +396,49 @@ impl Suppressions { false } + /// Check for rule codes in valid suppression comments. + pub(crate) fn check_rule_codes(&self, context: &LintContext, locator: &Locator) { + if !context.is_rule_enabled(Rule::RuleCodesInSuppressionComments) { + return; + } + + // Each comment or matched pair produces one valid suppression per code, all sharing the + // same first comment range. + let mut seen_comments = FxHashSet::default(); + + for suppression in &self.valid { + let first_comment = suppression.comments.first(); + if !seen_comments.insert(first_comment.range) { + continue; + } + + let second_comment = suppression.comments.second(); + for (index, range) in first_comment.codes.iter().enumerate() { + let original = locator.slice(range); + let code = get_redirect_target(original).unwrap_or(original); + let Ok(rule) = Rule::from_code(code) else { + continue; + }; + + let mut diagnostic = + context.report_diagnostic(RuleCodesInSuppressionComments, *range); + let name = rule.name().to_string(); + let fix = if let Some(second_range) = + second_comment.and_then(|comment| comment.codes.get(index)) + { + diagnostic.secondary_annotation_without_message(*second_range); + Fix::safe_edits( + Edit::range_replacement(name.clone(), *range), + [Edit::range_replacement(name, *second_range)], + ) + } else { + Fix::safe_edit(Edit::range_replacement(name, *range)) + }; + diagnostic.set_fix(fix); + } + } + } + pub(crate) fn check_suppressions(&self, context: &LintContext, locator: &Locator) { fn process_pending_diagnostics( key: Option, diff --git a/ruff.schema.json b/ruff.schema.json index 2507c70894..f846497844 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4266,6 +4266,7 @@ "RUF103", "RUF104", "RUF105", + "RUF106", "RUF2", "RUF20", "RUF200", @@ -5208,6 +5209,7 @@ "return-outside-function", "reuse-of-groupby-generator", "root-logger-call", + "rule-codes-in-suppression-comments", "run-process-in-async-function", "runtime-cast-value", "runtime-import-in-type-checking-block", From 742a28917e39213bdc4ea45422990f67ed59b0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Fri, 10 Jul 2026 15:13:28 -0700 Subject: [PATCH 025/222] [ty] Resolve ambiguity in Google-style docstring parsing in favour of observations from popular projects (#26673) ## Summary This changes how we resolve an ambiguity in extracting parameter documentation from Google-style docstrings that being with a reStructuredText literal block. Here is an [example of such a docstring that occurs in the wild](https://github.com/pytorch/pytorch/blob/e3f5bf0b18585511e6cd7d7a574ebf82f465e5ae/torch/_native/instrumentation.py#L360-L384) (paraphrased below): ```py def instrument_triton_kernel(op: str): """Instrument a Triton kernel, with the instrumentation decorator above the jit:: @instrument_triton_kernel("aten::bmm") @triton.jit def kernel(...): ... The kernel compiles lazily and caches variants on the kernel object. Args: op: Operator symbol being compiled. """ ``` Previously, the parser used the raw indentation of the docstring and incorrectly treated the entire remainder of the docstring as part of the literal block. As a result, we did not recognize any parameter documentation from the above block. After the fix, we instead parse the PEP-257-normalized docstring, and thus correct recognize the documentation for `op`. The tradeoff is that if the entire remainder of a docstring beginning with a literal block were intended as literal content, and that content looked like a Google-style param section, we would instead interpret it as real parameter documentation: ```py def example(): """Example:: Args: value: Literal text, not parameter documentation. """ ``` Whereas the former example was actually observed in the wild in quick survey of popular Python repos, the latter was not. As an added benefit, this simplifies our Google-style docstring parsing and aligns it with our reStructuredText parsing, thereby streamlining follow-up [document-model](https://github.com/astral-sh/ruff/pull/26670) and [Markdown-rendering](https://github.com/astral-sh/ruff/pull/26599) changes. ## Test Plan Please see included tests. --- crates/ty_ide/src/docstring.rs | 3 +- crates/ty_ide/src/docstring/document.rs | 9 ++- .../ty_ide/src/docstring/document/google.rs | 78 ++++++++++++++----- crates/ty_ide/src/docstring/document/rst.rs | 11 ++- .../ty_ide/src/docstring/document/syntax.rs | 36 ++------- 5 files changed, 81 insertions(+), 56 deletions(-) diff --git a/crates/ty_ide/src/docstring.rs b/crates/ty_ide/src/docstring.rs index d97d190741..697f03ae02 100644 --- a/crates/ty_ide/src/docstring.rs +++ b/crates/ty_ide/src/docstring.rs @@ -58,7 +58,8 @@ impl Docstring { /// Extract parameter documentation from popular docstring formats. /// Returns a map of parameter names to their documentation. pub fn parameter_documentation(&self) -> IndexMap { - document::parameter_documentation(&self.0, extract_numpy_style_params(&self.0)) + let normalized_source = documentation_trim(&self.0); + document::parameter_documentation(&normalized_source, extract_numpy_style_params(&self.0)) } } diff --git a/crates/ty_ide/src/docstring/document.rs b/crates/ty_ide/src/docstring/document.rs index cef072c39b..fdae0721e0 100644 --- a/crates/ty_ide/src/docstring/document.rs +++ b/crates/ty_ide/src/docstring/document.rs @@ -9,13 +9,16 @@ pub(super) mod rst; pub(in crate::docstring) mod syntax; /// Returns docs for all parameters recognized in the given docstring. +/// +/// `normalized_source` must have already undergone PEP-257 trimming and universal newline +/// normalization. pub(super) fn parameter_documentation( - raw: &str, + normalized_source: &str, numpy_parameters: IndexMap, ) -> IndexMap { - let mut parameters = google::parameter_documentation(raw); + let mut parameters = google::parameter_documentation(normalized_source); parameters.extend(numpy_parameters); - parameters.extend(rst::parameter_documentation(raw)); + parameters.extend(rst::parameter_documentation(normalized_source)); parameters } diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 389c005292..8c55f918d2 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -42,8 +42,11 @@ use super::syntax::{ }; /// Returns parameter documentation from recognized Google-style parameter sections. -pub(super) fn parameter_documentation(raw: &str) -> IndexMap { - let lines = parsed_lines(raw); +/// +/// `normalized_source` must have already undergone PEP-257 trimming and universal newline +/// normalization. +pub(super) fn parameter_documentation(normalized_source: &str) -> IndexMap { + let lines = parsed_lines(normalized_source); let mut parameters = Parameters::default(); for section in sections(&lines) { if matches!( @@ -57,6 +60,9 @@ pub(super) fn parameter_documentation(raw: &str) -> IndexMap { } /// Returns recognized Google-style sections in source order. +/// +/// `lines` must come from source that has already undergone PEP-257 trimming and universal +/// newline normalization (typically via `docstring::documentation_trim`). pub(in crate::docstring) fn sections<'a>( lines: &'a [ParsedLine<'a>], ) -> impl Iterator> + 'a { @@ -121,13 +127,13 @@ fn extend_parameter_documentation(parameters: &mut Parameters, lines: &[ParsedLi // The first recognized item establishes the sibling indentation. // Each item at that indentation starts a new sibling and completes its predecessor. - if item_indent.is_none_or(|indent| line.raw_indent == indent) + if item_indent.is_none_or(|indent| line.indent == indent) && let Some((names, description)) = parse_parameter(trimmed) { parameters.insert_documentation( current.replace((names.to_string(), description.to_string())), ); - item_indent = Some(line.raw_indent); + item_indent = Some(line.indent); continue; } @@ -201,8 +207,7 @@ fn parse_section_header(lines: &[ParsedLine<'_>], index: usize) -> Option( } if leading_blank_lines > 0 - && next_line.structural_indent <= header.structural_indent + && next_line.indent <= header.indent && (parse_section_header(lines, leading_blank_lines).is_some() || is_inline_section_header(next_line.text)) { @@ -278,7 +283,7 @@ fn section_body_continuation<'a>( // Returns and yields have no item syntax that distinguishes an aligned body from prose // following an empty section. if leading_blank_lines > 0 - && next_line.raw_indent <= header.indent + && next_line.indent <= header.indent && item_indent.is_none() && matches!( header.kind, @@ -299,7 +304,7 @@ fn section_body_continuation<'a>( | SectionKind::OtherParameters ) ) - && item_indent == Some(next_line.raw_indent) + && item_indent == Some(next_line.indent) && section_item_indent(header, *next_line).is_none() { return None; @@ -314,12 +319,11 @@ fn section_header_ends_body(lines: &[ParsedLine<'_>], index: usize, header: Sect let Some(line) = lines.get(index) else { return false; }; - if line.structural_indent <= header.structural_indent && is_inline_section_header(line.text) { + if line.indent <= header.indent && is_inline_section_header(line.text) { return true; } - parse_section_header(lines, index) - .is_some_and(|next| next.structural_indent <= header.structural_indent) + parse_section_header(lines, index).is_some_and(|next| next.indent <= header.indent) } /// Returns whether `line` belongs to `header` under Google-style indentation rules. @@ -328,12 +332,11 @@ fn line_belongs_to_body( line: ParsedLine<'_>, item_indent: Option, ) -> bool { - match line.raw_indent.cmp(&header.indent) { + match line.indent.cmp(&header.indent) { Ordering::Less => false, Ordering::Greater => true, Ordering::Equal => { - let item_indent_matches_line = - item_indent.is_none_or(|indent| indent == line.raw_indent); + let item_indent_matches_line = item_indent.is_none_or(|indent| indent == line.indent); let is_parameter_section = matches!( header.kind, HeaderKind::Structured( @@ -368,7 +371,7 @@ fn section_item_indent(header: SectionHeader, line: ParsedLine<'_>) -> Option !trimmed.is_empty(), HeaderKind::Opaque => false, }; - is_item.then_some(line.raw_indent) + is_item.then_some(line.indent) } /// Returns whether `line` is a recognized section header followed by inline content. @@ -394,7 +397,6 @@ fn is_inline_section_header(line: &str) -> bool { struct SectionHeader { kind: HeaderKind, indent: TextSize, - structural_indent: TextSize, body_start_line_index: usize, range: TextRange, } @@ -738,6 +740,28 @@ This line starts at column zero. ); } + #[test] + fn finds_parameter_section_after_first_line_literal_block() { + // Regression for https://github.com/pytorch/pytorch/blob/e3f5bf0b18585511e6cd7d7a574ebf82f465e5ae/torch/_native/instrumentation.py#L365-L383 + assert_parameter_documentation( + "\ +Instrument a single ``@triton.jit`` kernel, stacked above the jit:: + + @instrument_triton_kernel(\"aten::bmm\") + @triton.jit + def _bmm_kernel(...): ... + + A Triton kernel compiles lazily and caches variants on the kernel object. + + Args: + op: Operator symbol being compiled for, e.g. ``\"aten::bmm\"``.", + &[( + "op", + "Operator symbol being compiled for, e.g. ``\"aten::bmm\"``.", + )], + ); + } + #[test] fn keeps_colon_prose_in_parameter_documentation() { assert_parameter_documentation( @@ -807,6 +831,8 @@ Args: fn ignores_parameter_section_in_rest_directive() { assert_parameter_documentation( "\ +Summary. + .. note:: Args: nested: Not parameter documentation.", @@ -818,6 +844,8 @@ Args: fn ignores_parameter_section_after_blank_line_in_rest_directive() { assert_parameter_documentation( "\ +Summary. + .. note:: Keyword Args: @@ -830,6 +858,8 @@ Args: fn ignores_parameter_section_in_unordered_markdown_list_item() { assert_parameter_documentation( "\ +Summary. + - Example: Args: nested: Not parameter documentation.", @@ -841,6 +871,8 @@ Args: fn ignores_parameter_section_after_blank_line_in_markdown_list_item() { assert_parameter_documentation( "\ +Summary. + - Example: Args: @@ -853,6 +885,8 @@ Args: fn ignores_parameter_section_in_ordered_markdown_list_item() { assert_parameter_documentation( "\ +Summary. + 1. Example: Args: nested: Not parameter documentation.", @@ -864,6 +898,8 @@ Args: fn ignores_parameter_section_in_rest_field_list() { assert_parameter_documentation( "\ +Summary. + :param value: Example input. Args: nested: Not parameter documentation.", @@ -875,6 +911,8 @@ Args: fn ignores_parameter_section_in_rest_literal_block() { assert_parameter_documentation( "\ +Summary. + Example:: Args: @@ -1054,7 +1092,8 @@ Returns: } fn display_parameters(raw: &str) -> String { - parameter_documentation(raw) + let normalized_source = crate::docstring::documentation_trim(raw); + parameter_documentation(&normalized_source) .into_iter() .map(|(name, documentation)| { let documentation = documentation @@ -1071,7 +1110,8 @@ Returns: #[track_caller] fn assert_parameter_documentation(raw: &str, expected: &[(&str, &str)]) { - let parameters = parameter_documentation(raw); + let normalized_source = crate::docstring::documentation_trim(raw); + let parameters = parameter_documentation(&normalized_source); assert_eq!(parameters.len(), expected.len(), "{raw}"); for &(name, documentation) in expected { diff --git a/crates/ty_ide/src/docstring/document/rst.rs b/crates/ty_ide/src/docstring/document/rst.rs index 102648f617..8ccd608d3e 100644 --- a/crates/ty_ide/src/docstring/document/rst.rs +++ b/crates/ty_ide/src/docstring/document/rst.rs @@ -32,11 +32,13 @@ pub(in crate::docstring) fn top_level_field_lists( } /// Returns the parameter documentation recognized in a reST docstring. -pub(super) fn parameter_documentation(raw: &str) -> IndexMap { +/// +/// `normalized_source` must have already undergone PEP-257 trimming and universal newline +/// normalization. +pub(super) fn parameter_documentation(normalized_source: &str) -> IndexMap { let mut parameters = IndexMap::new(); - let source = crate::docstring::documentation_trim(raw); - for field_list in top_level_field_lists(&source) { + for field_list in top_level_field_lists(normalized_source) { for field in field_list.fields { let Field::Parameter { lookup_name, @@ -1147,7 +1149,8 @@ Section:: } fn parameter_documentation(docstring: &str) -> String { - let parameters = super::parameter_documentation(docstring); + let normalized_source = crate::docstring::documentation_trim(docstring); + let parameters = super::parameter_documentation(&normalized_source); let mut rendered = String::new(); for (name, description) in parameters { diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index 210f79181e..22128f0d7c 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -8,33 +8,15 @@ use super::rst::is_field_list_marker; /// source ranges. /// /// For example, `first\r\nsecond` yields `first` at offset 0 and `second` at offset 7. -pub(super) fn parsed_lines(raw: &str) -> Vec> { - let mut lines = raw +pub(super) fn parsed_lines(source: &str) -> Vec> { + source .universal_newlines() .map(|line| ParsedLine { text: line.as_str(), range: line.range(), - raw_indent: indentation(line.as_str()), - structural_indent: TextSize::new(0), + indent: indentation(line.as_str()), }) - .collect::>(); - - // PEP 257 ignores indentation on the first physical line and removes the common margin from - // all later lines. Keep the raw indentation as well because item and block indentation can - // still disambiguate content within a section. - let continuation_margin = lines - .iter() - .skip(1) - .filter(|line| !line.text.trim().is_empty()) - .map(|line| line.raw_indent) - .min() - .unwrap_or(TextSize::new(0)); - - for line in lines.iter_mut().skip(1) { - line.structural_indent = line.raw_indent.saturating_sub(continuation_margin); - } - - lines + .collect() } /// A docstring line and its source range, excluding the newline terminator. @@ -44,10 +26,8 @@ pub(super) struct ParsedLine<'a> { pub(super) text: &'a str, /// The byte range of `text` within the source document. pub(super) range: TextRange, - /// The indentation in the raw docstring text. - pub(super) raw_indent: TextSize, - /// The indentation after removing the PEP 257 continuation margin. - pub(super) structural_indent: TextSize, + /// The indentation in the source document. + pub(super) indent: TextSize, } /// Returns whether `line` starts with a `CommonMark` list-item marker. @@ -80,13 +60,11 @@ pub(super) fn container_block_end(lines: &[ParsedLine<'_>], index: usize) -> Opt return None; } - // Container membership is determined before PEP 257 normalization. This keeps raw-indented - // section-like text inside lists, directives, and field-list entries. Some( (index + 1..lines.len()) .find(|&end| { let line = lines[end]; - !line.text.trim().is_empty() && line.raw_indent <= marker.raw_indent + !line.text.trim().is_empty() && line.indent <= marker.indent }) .unwrap_or(lines.len()), ) From 00b431e5a369751a085f75b44236685378714827 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Fri, 10 Jul 2026 19:45:36 -0400 Subject: [PATCH 026/222] [ty] Remove transitive typevar artifacts during collection inference (#26714) Use the `remove_inferable_typevar_artifacts_from_solution` helper from https://github.com/astral-sh/ruff/pull/26099, instead of the current naive union filtering during collection inference, to eliminate unnecessary inferable type variables that prevent the constraint solver from determining bidirectional inference type mappings. I also reorganized the relevant tests slightly. Resolves https://github.com/astral-sh/ty/issues/3956#issuecomment-4940028081. Note that the diagnostics there were a little misleading because the callable types and concrete function types are rendered identically if they have the same signature, despite being distinct types. --- .../resources/mdtest/bidirectional.md | 88 ++++++++++--------- .../src/types/infer/builder.rs | 16 ++-- 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index ed170f00bc..8dd89f190e 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -221,28 +221,6 @@ x4: dict[str, int] = reveal_type({**dynamic_mapping()}) # revealed: dict[str | x5: dict[str, int] = {**42} ``` -### Aliases and protocols - -```py -from collections.abc import Iterable - -type IntDict = dict[str, int] - -x1: IntDict = {"a": 1} -reveal_type(x1) # revealed: dict[str, int] - -# A protocol context is not an exact nominal collection context and must use the general path. -x2: Iterable[int] = [1] -reveal_type(x2) # revealed: list[int] -``` - -### Invalid elements - -```py -x1: dict[str, int] = {"a": "bad"} # error: [invalid-assignment] -x2: list[list[list[str]]] = [[[1]]] # error: [invalid-assignment] -``` - ### Collection unions ```py @@ -560,6 +538,7 @@ def dict_with_numeric_promotion( ```py from collections.abc import Callable, Hashable +from typing import Any # The `dict(...)` variant is not technically allowed by the typeshed overloads, which require # string keys for keyword arguments. We special-case it to match the literal form. @@ -861,8 +840,8 @@ reveal_type(x5) # revealed: ((Any, /) -> bool) | None ## Declared type preference sees through subtyping -Similarly, if the inferred type is a subtype of the declared type, we prefer declared type -assignments that are in non-covariant position: +Additionally, if the inferred type is a subtype of the declared type, we prefer declared type +assignments that are in non-covariant position. This behavior applies to collection literals: ```py import builtins @@ -905,7 +884,11 @@ reveal_type(x11) # revealed: list[Iterable[Any]] x12: Iterable[list[Any]] = [[i] for i in [1, 2, 3]] reveal_type(x12) # revealed: list[list[Any]] +``` +As well as generic calls, and constructors of generic classes: + +```py class X[T]: value: T @@ -939,25 +922,50 @@ reveal_type(x18) # revealed: list[list[Any]] x19: dict[int, dict[str, int]] = defaultdict(dict) reveal_type(x19) # revealed: defaultdict[int, dict[str, int]] +``` + +Complex subtyping relationships are solved correctly: + +```py +from typing import Hashable + +def variadic(*args: Any, **kwargs: Any) -> Any: ... + +x20: Mapping[Hashable, list[Callable[..., Any]]] = {"x": [variadic]} +reveal_type(x20) # revealed: dict[Hashable, list[(...) -> Any]] + +x21: Mapping[Hashable, list[Callable[..., Any]]] = dict(x=[variadic]) +reveal_type(x21) # revealed: dict[Hashable, list[(...) -> Any]] +``` + +## Implicit generic class specialization + +Callable type context is also used to inform the implicit specialization of a generic class: + +```py +import builtins +from collections import defaultdict +from collections.abc import Mapping +from typing import Any, Callable, overload -x20: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[str, list[str]] -x20["key"].append(1) # error: [invalid-argument-type] +x1: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[str, list[str]] +x1["key"].append(1) # error: [invalid-argument-type] -factory: Callable[[], list[str]] = reveal_type(list) # revealed: -reveal_type(factory()) # revealed: list[str] +x2: Callable[[], list[str]] = reveal_type(list) # revealed: +reveal_type(x2()) # revealed: list[str] -optional_factory: Callable[[], list[str]] | None = reveal_type(list) # revealed: +x3: Callable[[], list[str]] | None = reveal_type(list) # revealed: -qualified_factory: Callable[[], list[str]] = reveal_type(builtins.list) # revealed: -reveal_type(qualified_factory()) # revealed: list[str] +x4: Callable[[], list[str]] = reveal_type(builtins.list) # revealed: +reveal_type(x4()) # revealed: list[str] type ListFactory = Callable[[], list[str]] -alias_factory: ListFactory = reveal_type(list) # revealed: -reveal_type(alias_factory()) # revealed: list[str] +x5: ListFactory = reveal_type(list) # revealed: +reveal_type(x5()) # revealed: list[str] -gradual_factory: Callable[..., Any] = reveal_type(list) # revealed: -dynamic_factory: Callable[[Any], Any] = reveal_type(list) # revealed: +x6: Callable[..., Any] = reveal_type(list) # revealed: +x7: Callable[[Any], Any] = reveal_type(list) # revealed: class Wrapped[T]: value: T @@ -965,8 +973,8 @@ class Wrapped[T]: def __new__(cls, value: T) -> "Wrapped[tuple[T]]": raise NotImplementedError -wrapped_factory: Callable[[str], Wrapped[tuple[str]]] = reveal_type(Wrapped) # revealed: -reveal_type(wrapped_factory("x")) # revealed: Wrapped[tuple[str]] +x8: Callable[[str], Wrapped[tuple[str]]] = reveal_type(Wrapped) # revealed: +reveal_type(x8("x")) # revealed: Wrapped[tuple[str]] class M[T]: value: T @@ -974,8 +982,8 @@ class M[T]: def __new__[S](cls, value: S) -> "M[tuple[S]]": raise NotImplementedError -m_factory: Callable[[str], M[tuple[str]]] = reveal_type(M) # revealed: -reveal_type(m_factory("x")) # revealed: M[tuple[str]] +x9: Callable[[str], M[tuple[str]]] = reveal_type(M) # revealed: +reveal_type(x9("x")) # revealed: M[tuple[str]] class MultiPath[T]: value: T @@ -987,7 +995,7 @@ class MultiPath[T]: def __init__(self, value: object) -> None: ... # fmt: off -multi_path_factory: Callable[[list[int]], MultiPath[int] | MultiPath[list[int]]] = reveal_type(MultiPath) # revealed: +x10: Callable[[list[int]], MultiPath[int] | MultiPath[list[int]]] = reveal_type(MultiPath) # revealed: # fmt: on ``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index bb660a9589..b31187c9ec 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -6322,6 +6322,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let constraints = ConstraintSetBuilder::new(); let inferable = generic_context.inferable_typevars(self.db()); let identity_instance = Type::instance(self.db(), ClassType::Generic(collection_alias)); + let mut builder = SpecializationBuilder::new(self.db(), &constraints, inferable); // Remove any union elements of that are unrelated to the collection type. // @@ -6407,13 +6408,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // The SequentMap's transitivity reasoning can inject // cross-typevar references into the solution bounds. // For example, `_KT ≤ str ∧ str ≤ _VT` derives `_KT ≤ _VT`, - // which adds `_KT` to `_VT`'s lower bound. Filter out any - // inferable typevars from the solution, since they represent + // which adds `_KT` to `_VT`'s lower bound. Remove inferable + // typevars from the same generic context, since they represent // cross-typevar relationships that are resolved independently. - let inferred_ty = binding.solution.filter_union(db, |ty| { - !ty.as_typevar() - .is_some_and(|tv| tv.is_inferable(db, inferable)) - }); + let inferred_ty = builder + .remove_inferable_typevar_artifacts_from_solution( + binding.bound_typevar, + binding.solution, + ); // Avoid inferring a preferred type based on partially specialized // type context from an outer generic call. If the type context is @@ -6526,8 +6528,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Create a set of constraints to infer a precise type for `T`. - let mut builder = SpecializationBuilder::new(self.db(), &constraints, inferable); - let mut tuple_size_promotion_constraints = TupleSizePromotionConstraints::default(); for elt_ty in elt_tys.clone() { From 70e39e08eae5f25ed84ec4630736bcc416d4026d Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 10 Jul 2026 16:59:40 -0700 Subject: [PATCH 027/222] [ty] Avoid duplicate diagnostics for overloaded TypeIs (#26716) ## Summary Check only the current function definition when validating `TypeIs` and `TypeGuard` definitions. `check_type_guard_definition` runs for every `FunctionDef`, which includes each individual overload, so it shouldn't iterate previous overloads each time also. Fixes astral-sh/ty#3968 ## Validation Added mdtests. --- .../resources/mdtest/narrow/type_guards.md | 24 ++++++ .../infer/builder/post_inference/typeguard.rs | 81 +++++++++---------- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md index cde08f4f90..0b28b4d785 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md @@ -108,6 +108,30 @@ def _(a: int) -> TypeIs[str]: ... def _(a: bool | str) -> TypeIs[int]: ... ``` +## Overloaded definitions + +Each overload is checked exactly once, and distinct invalid overloads each report their own +diagnostic. + +```pyi +from typing import overload +from typing_extensions import Never, TypeIs + +@overload +# error: [invalid-type-guard-definition] "Narrowed type `bool` is not assignable to the declared parameter type `Never`" +def one_invalid(value: Never) -> TypeIs[bool]: ... +@overload +def one_invalid(value: object) -> TypeIs[str]: ... + +# Two distinct invalid overloads should each report their own diagnostic. +@overload +# error: [invalid-type-guard-definition] "Narrowed type `bool` is not assignable to the declared parameter type `Never`" +def two_invalid(value: Never) -> TypeIs[bool]: ... +@overload +# error: [invalid-type-guard-definition] "Narrowed type `str` is not assignable to the declared parameter type `int`" +def two_invalid(value: int) -> TypeIs[str]: ... +``` + ## Methods Methods narrow the first positional argument after `self` or `cls` diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/typeguard.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/typeguard.rs index f92dacb190..374cecad8b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/typeguard.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/typeguard.rs @@ -16,54 +16,51 @@ pub(crate) fn check_type_guard_definition<'db>( let db = context.db(); - for overload in function.iter_overloads_and_implementation(db) { - let signature = overload.signature(db); - let return_ty = signature.return_ty; + let overload = function.literal(db).last_definition; + let signature = overload.signature(db); + let return_ty = signature.return_ty; - // Check if this is a `TypeIs` or `TypeGuard` return type. - let (type_guard_form_name, narrowed_type) = match return_ty { - Type::TypeIs(type_is) => ("TypeIs", Some(type_is.return_type(db))), - Type::TypeGuard(_) => ("TypeGuard", None), - _ => continue, - }; + // Check if this is a `TypeIs` or `TypeGuard` return type. + let (type_guard_form_name, narrowed_type) = match return_ty { + Type::TypeIs(type_is) => ("TypeIs", Some(type_is.return_type(db))), + Type::TypeGuard(_) => ("TypeGuard", None), + _ => return, + }; - // The return type annotation must exist since we matched `TypeIs`/`TypeGuard`. - let Some(returns_expr) = node.returns.as_deref() else { - continue; - }; + // The return type annotation must exist since we matched `TypeIs`/`TypeGuard`. + let Some(returns_expr) = node.returns.as_deref() else { + return; + }; - // Check if this is a non-static method (first parameter is implicit `self`/`cls`). - let has_implicit_receiver = overload.has_implicit_receiver(db); + // Check if this is a non-static method (first parameter is implicit `self`/`cls`). + let has_implicit_receiver = overload.has_implicit_receiver(db); - // Find the first positional parameter to narrow (skip implicit `self`/`cls`). - let positional_params: Vec<_> = signature.parameters().positional().collect(); - let first_narrowed_param_index = usize::from(has_implicit_receiver); - let first_narrowed_param = positional_params.get(first_narrowed_param_index); + // Find the first positional parameter to narrow (skip implicit `self`/`cls`). + let positional_params: Vec<_> = signature.parameters().positional().collect(); + let first_narrowed_param_index = usize::from(has_implicit_receiver); + let first_narrowed_param = positional_params.get(first_narrowed_param_index); - let Some(first_narrowed_param) = first_narrowed_param else { - if let Some(builder) = context.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr) - { - builder.into_diagnostic(format_args!( - "`{type_guard_form_name}` function must have a parameter to narrow" - )); - } - continue; - }; + let Some(first_narrowed_param) = first_narrowed_param else { + if let Some(builder) = context.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr) { + builder.into_diagnostic(format_args!( + "`{type_guard_form_name}` function must have a parameter to narrow" + )); + } + return; + }; - // For `TypeIs`, check that the narrowed type is assignable to the parameter type. - if let Some(narrowed_ty) = narrowed_type { - let param_ty = first_narrowed_param.annotated_type(); - if !narrowed_ty.is_assignable_to(db, param_ty) - && let Some(builder) = - context.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr) - { - builder.into_diagnostic(format_args!( - "Narrowed type `{narrowed}` is not assignable \ - to the declared parameter type `{param}`", - narrowed = narrowed_ty.display(db), - param = param_ty.display(db) - )); - } + // For `TypeIs`, check that the narrowed type is assignable to the parameter type. + if let Some(narrowed_ty) = narrowed_type { + let param_ty = first_narrowed_param.annotated_type(); + if !narrowed_ty.is_assignable_to(db, param_ty) + && let Some(builder) = context.report_lint(&INVALID_TYPE_GUARD_DEFINITION, returns_expr) + { + builder.into_diagnostic(format_args!( + "Narrowed type `{narrowed}` is not assignable \ + to the declared parameter type `{param}`", + narrowed = narrowed_ty.display(db), + param = param_ty.display(db) + )); } } } From c588a3f7f57461692652d339936222b4496c5953 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 10 Jul 2026 21:28:47 -0400 Subject: [PATCH 028/222] [ty] Correct protocol method receiver binding (#26701) ## Summary Prior to this change, we could incorrectly reject a call to a stored bound method obtained from a class-based protocol. Member lookup has already specialized the method for its receiver, but calling through a callable union reconstructed the original signature and prepended the receiver as a synthetic argument. Generic call inference then structurally checked the inferred `self` annotation again: ```python from collections.abc import Iterator from typing import Any class PeekIterator(Iterator[Any]): def __init__(self, iterator: Iterator[Any]) -> None: self._next = iterator.__next__ def __next__(self) -> Any: # Before: Expected `Self@__next__`, found `Iterator[Any]` return self._next() def use_fallback(self) -> None: self._next = lambda: None ``` The receiver is already bound, so `Iterator.__next__` genuinely takes no call-site arguments and should not be checked against `Self` again. We now consume an implicit positional receiver before call inference, apply the `typing.Self` substitution to the remaining signature, and refresh its return type. This removes the false positive and avoids re-entering recursive protocol interfaces during binding, which could otherwise cause overloaded recursive members to grow the deferred constraint graph combinatorially. This also corrects constructor diagnostics for class-based protocols. Previously we counted the implicit receiver in both the expected and supplied arity: ```python from collections.abc import Hashable def construct(value: Hashable) -> None: # Before: expected 1, got 4 # After: expected 0, got 3 type(value)(1970, 1, 1) ``` Because binding removes the leading parameter, we preserve its source-parameter offset so invalid-argument and missing-argument diagnostics still point at the correct declaration. Explicit, variadic, keyword-only, and other nonstandard receivers retain the existing call-inference path; the protocol relation itself is unchanged. The independent recursive traversal in `redundant-cast` checks is handled separately in #26708. Related: --- .../resources/mdtest/call/constructor.md | 13 ++++ .../resources/mdtest/call/methods.md | 33 +++++++++++ .../diagnostics/invalid_argument_type.md | 59 +++++++++++++++++++ .../mdtest/diagnostics/missing_argument.md | 22 +++++++ crates/ty_python_semantic/src/types.rs | 25 +++++++- .../ty_python_semantic/src/types/call/bind.rs | 37 +++++++++--- .../src/types/signatures.rs | 6 ++ 7 files changed, 185 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/constructor.md b/crates/ty_python_semantic/resources/mdtest/call/constructor.md index 144904282d..9cba576b04 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/constructor.md +++ b/crates/ty_python_semantic/resources/mdtest/call/constructor.md @@ -46,6 +46,19 @@ reveal_type(Foo()) # revealed: Foo reveal_type(Foo(1)) # revealed: Foo ``` +## Class-based protocol constructors + +Constructing a class-based protocol through `type` must not count the implicit receiver in an arity +diagnostic. + +```py +from collections.abc import Hashable + +def construct(value: Hashable) -> None: + # error: [too-many-positional-arguments] "Too many positional arguments to `object.__init__`: expected 0, got 3" + type(value)(1970, 1, 1) +``` + ## `__new__` present on the class itself ```py diff --git a/crates/ty_python_semantic/resources/mdtest/call/methods.md b/crates/ty_python_semantic/resources/mdtest/call/methods.md index 9c3b414ac3..803a591488 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/methods.md +++ b/crates/ty_python_semantic/resources/mdtest/call/methods.md @@ -191,6 +191,39 @@ def f(a_or_b: A | B, any_or_a: Any | A): reveal_type(any_or_a.f()) # revealed: Any | int ``` +## Stored protocol-bound methods + +A protocol-bound method stored alongside another callable must not re-check its already-bound +receiver when the callable union is invoked. + +```py +from collections.abc import Iterator +from typing import Any + +class PeekIterator(Iterator[Any]): + def __init__(self, iterator: Iterator[Any]) -> None: + self._next = iterator.__next__ + + def __next__(self) -> Any: + return self._next() + + def use_fallback(self) -> None: + self._next = lambda: None +``` + +Only a genuine implicit positional receiver can be consumed before call inference. Other parameter +shapes must continue through the ordinary bound-method call path. + +```py +from typing import Protocol + +class Variadic(Protocol): + def method(*args: int) -> int: ... + +def check_variadic(value: Variadic) -> None: + value.method() # error: [invalid-argument-type] +``` + ## Method calls on `KnownInstance` types ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_argument_type.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_argument_type.md index afcb7e8cef..beb1e9d0b9 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_argument_type.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_argument_type.md @@ -452,6 +452,65 @@ info: Method defined here | ``` +## Calls to protocol methods + +```py +from typing import Protocol + +class P(Protocol): + def method(self, value: int) -> None: ... + +def f(p: P) -> None: + p.method("bad") # snapshot: invalid-argument-type +``` + +```snapshot +error[invalid-argument-type]: Argument to bound method `P.method` is incorrect + --> src/mdtest_snippet.py:7:14 + | +7 | p.method("bad") # snapshot: invalid-argument-type + | ^^^^^ Expected `int`, found `Literal["bad"]` + | +info: Method defined here + --> src/mdtest_snippet.py:4:9 + | +4 | def method(self, value: int) -> None: ... + | ^^^^^^ ---------- Parameter declared here + | +``` + +## Calls to overloaded protocol methods + +```py +from typing import Protocol, overload + +class P(Protocol): + @overload + def method(self, value: int) -> None: ... + @overload + def method(self, value: int, extra: int) -> None: ... + +def f(p: P) -> None: + p.method("bad") # snapshot: invalid-argument-type +``` + +```snapshot +error[invalid-argument-type]: Argument to bound method `P.method` is incorrect + --> src/mdtest_snippet.py:10:14 + | +10 | p.method("bad") # snapshot: invalid-argument-type + | ^^^^^ Expected `int`, found `Literal["bad"]` + | +info: Matching overload defined here + --> src/mdtest_snippet.py:5:9 + | +5 | def method(self, value: int) -> None: ... + | ^^^^^^ ---------- Parameter declared here + | +info: Non-matching overloads for bound method `method`: +info: (self, /, value: int, extra: int) -> None +``` + ## Types with the same name but from different files `module.py`: diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/missing_argument.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/missing_argument.md index d349f3ca30..484ca4ca20 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/missing_argument.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/missing_argument.md @@ -32,6 +32,14 @@ h = f if coinflip() else g h(b=56) Foo().method() # snapshot: missing-argument + +from typing import Protocol + +class P(Protocol): + def method(self, value: int) -> None: ... + +def check_protocol(p: P) -> None: + p.method() # snapshot: missing-argument ``` ```snapshot @@ -81,4 +89,18 @@ info: Parameter declared here 5 | def method(self, a): ... | ^ | + + +error[missing-argument]: No argument provided for required parameter `value` of bound method `P.method` + --> src/main.py:22:5 + | +22 | p.method() # snapshot: missing-argument + | ^^^^^^^^^^ + | +info: Parameter declared here + --> src/main.py:19:22 + | +19 | def method(self, value: int) -> None: ... + | ^^^^^^^^^^ + | ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 94265cafa4..4755e83167 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4555,9 +4555,28 @@ impl<'db> Type<'db> { Type::BoundMethod(bound_method) => { let signature = bound_method.function(db).signature(db); - CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) - .with_bound_type(bound_method.self_instance(db)) - .into() + let self_instance = bound_method.self_instance(db); + // Class-based protocol member lookup has already specialized the method for this + // receiver. Bake an implicit positional receiver into the signature instead of + // checking it structurally again during call inference. + if self_instance + .as_protocol_instance() + .is_some_and(|protocol| protocol.to_nominal_instance().is_some()) + && signature + .overloads + .iter() + .all(Signature::has_implicit_positional_receiver_annotation) + { + let mut binding = + CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) + .with_bound_type(bound_method.typing_self_type(db)); + binding.bake_bound_type_into_overloads(db); + binding.into() + } else { + CallableBinding::from_overloads(self, signature.overloads.iter().cloned()) + .with_bound_type(self_instance) + .into() + } } Type::KnownBoundMethod(method) => { diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 7877bd6271..028909546f 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2942,13 +2942,20 @@ impl<'db> CallableBinding<'db> { } /// Rewrites overload signatures as if an implicit bound receiver argument had already been - /// consumed. + /// consumed, preserving the corresponding source-parameter offset for diagnostics. pub(crate) fn bake_bound_type_into_overloads(&mut self, db: &'db dyn Db) { let Some(bound_self) = self.bound_type.take() else { return; }; for overload in &mut self.overloads { + let removed_receiver = overload + .signature + .parameters() + .get(0) + .is_some_and(Parameter::is_positional); overload.signature = overload.signature.bind_self(db, Some(bound_self)); + overload.return_ty = overload.initial_return_type(db); + overload.source_parameter_index_offset += usize::from(removed_receiver); } } @@ -5759,6 +5766,12 @@ pub(crate) struct Binding<'db> { /// and `2` here so diagnostics can point at the source declarations. source_overload_index: usize, + /// The number of leading source parameters consumed while constructing this binding. + /// + /// Parameter matching uses indexes into the rewritten signature, while diagnostic spans use + /// this offset to recover indexes into the original function definition. + source_parameter_index_offset: usize, + /// The type that is (hopefully) callable. pub(crate) callable_type: Type<'db>, @@ -5801,6 +5814,7 @@ impl<'db> Binding<'db> { Binding { signature, source_overload_index: 0, + source_parameter_index_offset: 0, callable_type: signature_type, signature_type, return_ty, @@ -6539,6 +6553,7 @@ impl<'db> Binding<'db> { callable_description, compound_diag, matching_overload, + self.source_parameter_index_offset, ); } } @@ -7113,6 +7128,7 @@ impl<'db> BindingError<'db> { } } + #[expect(clippy::too_many_arguments)] fn report_diagnostic( &self, context: &InferContext<'db, '_>, @@ -7121,6 +7137,7 @@ impl<'db> BindingError<'db> { callable_description: Option<&CallableDescription>, compound_diag: Option<&dyn CompoundDiagnostic>, matching_overload: Option<&MatchingOverloadLiteral<'_>>, + source_parameter_index_offset: usize, ) { let callable_kind = match callable_ty { Type::FunctionLiteral(_) => "Function", @@ -7209,8 +7226,10 @@ impl<'db> BindingError<'db> { SubDiagnosticSeverity::Info, "Matching overload defined here", ); - let (name_span, parameter_span) = - overload_literal.parameter_span(context.db(), Some(parameter.index)); + let (name_span, parameter_span) = overload_literal.parameter_span( + context.db(), + Some(parameter.index + source_parameter_index_offset), + ); sub.annotate(Annotation::primary(name_span)); sub.annotate( Annotation::secondary(parameter_span) @@ -7241,9 +7260,10 @@ impl<'db> BindingError<'db> { )); } } - } else if let Some((name_span, parameter_span)) = - callable_ty.parameter_span(context.db(), Some(parameter.index)) - { + } else if let Some((name_span, parameter_span)) = callable_ty.parameter_span( + context.db(), + Some(parameter.index + source_parameter_index_offset), + ) { let mut sub = SubDiagnostic::new( SubDiagnosticSeverity::Info, format_args!("{callable_kind} defined here"), @@ -7342,7 +7362,8 @@ impl<'db> BindingError<'db> { } else { let span = callable_ty.parameter_span( context.db(), - (parameters.0.len() == 1).then(|| parameters.0[0].index), + (parameters.0.len() == 1) + .then(|| parameters.0[0].index + source_parameter_index_offset), ); if let Some((_, parameter_span)) = span { let mut sub = SubDiagnostic::new( @@ -7541,6 +7562,7 @@ impl<'db> BindingError<'db> { callable_description, compound_diag, matching_overload, + source_parameter_index_offset, ); } @@ -7552,6 +7574,7 @@ impl<'db> BindingError<'db> { callable_description, compound_diag, matching_overload, + source_parameter_index_offset, ); } diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 07bee7a3e8..4c8675f639 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -1052,6 +1052,12 @@ impl<'db> Signature<'db> { .is_some_and(|parameter| parameter.is_positional() && !parameter.inferred_annotation) } + pub(crate) fn has_implicit_positional_receiver_annotation(&self) -> bool { + self.parameters + .get(0) + .is_some_and(|parameter| parameter.is_positional() && parameter.inferred_annotation) + } + pub(crate) fn apply_self(&self, db: &'db dyn Db, self_type: Type<'db>) -> Self { if !self.needs_self_mapping(db, false) { return self.clone(); From 2022d536f73955ced7550b04c31ffe3964e5719c Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 12 Jul 2026 13:34:47 +0200 Subject: [PATCH 029/222] [ty] Update Salsa to 0.0.28 (#26663) --- Cargo.lock | 27 ++++++----- Cargo.toml | 2 +- crates/ruff_db/src/files.rs | 3 ++ crates/ruff_db/src/files/file_root.rs | 1 + crates/ruff_db/src/panic.rs | 3 +- crates/ruff_db/src/source.rs | 4 +- crates/ruff_db/src/testing.rs | 12 +++-- crates/ruff_index/src/frozen.rs | 12 +---- crates/ruff_index/src/vec.rs | 12 +---- crates/ruff_python_ast/src/name.rs | 2 +- crates/ruff_python_formatter/src/lib.rs | 2 +- crates/ty_module_resolver/src/list.rs | 2 + crates/ty_module_resolver/src/module.rs | 5 ++- crates/ty_module_resolver/src/resolve.rs | 10 +++-- crates/ty_project/src/lib.rs | 3 ++ crates/ty_project/src/metadata/settings.rs | 2 +- crates/ty_python_core/src/ast_ids.rs | 13 +++++- crates/ty_python_core/src/ast_node_ref.rs | 5 +-- crates/ty_python_core/src/definition.rs | 24 +++++----- crates/ty_python_core/src/expression.rs | 3 ++ crates/ty_python_core/src/frozen.rs | 8 ++-- crates/ty_python_core/src/lib.rs | 15 +++---- crates/ty_python_core/src/member.rs | 2 +- .../src/narrowing_constraints.rs | 4 +- crates/ty_python_core/src/node_key.rs | 4 +- crates/ty_python_core/src/place.rs | 4 +- crates/ty_python_core/src/predicate.rs | 29 +++++++----- crates/ty_python_core/src/rank.rs | 3 +- .../src/reachability_constraints.rs | 4 +- crates/ty_python_core/src/scope.rs | 8 ++-- crates/ty_python_core/src/statement.rs | 6 ++- crates/ty_python_core/src/symbol.rs | 2 +- crates/ty_python_core/src/unpack.rs | 10 +++-- crates/ty_python_core/src/use_def.rs | 34 +++++++------- .../ty_python_core/src/use_def/place_state.rs | 16 +++---- crates/ty_python_semantic/src/place.rs | 16 ++++--- crates/ty_python_semantic/src/reachability.rs | 4 ++ crates/ty_python_semantic/src/types.rs | 44 ++++++++++++------ .../src/types/bound_super.rs | 8 ++-- crates/ty_python_semantic/src/types/call.rs | 2 +- .../ty_python_semantic/src/types/callable.rs | 4 +- crates/ty_python_semantic/src/types/class.rs | 34 ++++++++------ .../src/types/class/dynamic_literal.rs | 4 +- .../src/types/class/enum_literal.rs | 5 ++- .../src/types/class/known.rs | 5 ++- .../src/types/class/named_tuple.rs | 6 ++- .../src/types/class/static_literal.rs | 28 +++++++++--- .../src/types/class/typed_dict.rs | 3 +- .../src/types/class_base.rs | 2 +- .../src/types/constraints.rs | 33 +++++++------- .../src/types/dedicated/pydantic.rs | 10 +++-- crates/ty_python_semantic/src/types/enums.rs | 18 +++++--- .../ty_python_semantic/src/types/equality.rs | 2 +- .../src/types/equality/enums.rs | 8 ++-- .../ty_python_semantic/src/types/function.rs | 19 +++++--- .../ty_python_semantic/src/types/generics.rs | 11 +++-- crates/ty_python_semantic/src/types/infer.rs | 45 +++++++++++-------- .../ty_python_semantic/src/types/instance.rs | 15 ++++--- .../src/types/known_instance.rs | 14 +++++- .../ty_python_semantic/src/types/literal.rs | 11 ++--- crates/ty_python_semantic/src/types/member.rs | 2 +- crates/ty_python_semantic/src/types/method.rs | 7 ++- crates/ty_python_semantic/src/types/mro.rs | 12 ++--- crates/ty_python_semantic/src/types/narrow.rs | 8 ++-- .../ty_python_semantic/src/types/newtype.rs | 5 ++- .../ty_python_semantic/src/types/overrides.rs | 6 +-- .../types/property_tests/type_generation.rs | 2 +- .../src/types/protocol_class.rs | 11 ++--- .../ty_python_semantic/src/types/relation.rs | 2 +- .../src/types/set_theoretic.rs | 5 ++- .../src/types/signatures.rs | 20 +++++---- .../src/types/subclass_of.rs | 4 +- crates/ty_python_semantic/src/types/tests.rs | 1 + crates/ty_python_semantic/src/types/tuple.rs | 8 ++-- .../src/types/type_alias.rs | 10 ++++- .../ty_python_semantic/src/types/type_form.rs | 1 + .../src/types/typed_dict.rs | 20 ++++++--- .../ty_python_semantic/src/types/typevar.rs | 25 ++++++++--- .../ty_python_semantic/src/types/unpacker.rs | 2 +- .../ty_python_semantic/src/types/variance.rs | 2 +- crates/ty_test/src/db.rs | 1 + fuzz/Cargo.toml | 2 +- 82 files changed, 482 insertions(+), 321 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9967cfb0..9d53a24ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,7 +553,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -672,7 +672,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -1024,7 +1024,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -1104,7 +1104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -3842,7 +3842,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -3859,9 +3859,9 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "salsa" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbaab832e2ea754afda4a738f987dd1e8bd30c9e5d8c981ee6a3934386095e2" +checksum = "63ad5919b18c2deaf18921fffd5c84d6e41416d1ef80b6b541d629aa30ce8556" dependencies = [ "boxcar", "compact_str", @@ -3886,20 +3886,19 @@ dependencies = [ [[package]] name = "salsa-macro-rules" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6872462ac73d39969a836273c24163e6a26a4e08f5114fcd80e25af30ea9c6" +checksum = "d50068b1f8b1ac7567a4a70eb6cc15daa9dc997b73a6b5aa3248dd960755cddb" [[package]] name = "salsa-macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc78ffaf65b1a9175818592c5130aa10b1bb245a905722fd4db87cea8a8457" +checksum = "8b26cb1c61fc424f7ff36e0606491429f58d1738bb0917cd999d680baae0c52c" dependencies = [ "proc-macro2", "quote", "syn", - "synstructure", ] [[package]] @@ -4242,7 +4241,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -5447,7 +5446,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c6675c2eda..eae906f542 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,7 +156,7 @@ regex-syntax = { version = "0.8.8" } rustc-hash = { version = "2.0.0" } rustc-stable-hash = { version = "0.1.2" } # When updating salsa, make sure to also update the version in `fuzz/Cargo.toml` -salsa = { version = "0.27.2", default-features = false, features = [ +salsa = { version = "0.28.0", default-features = false, features = [ "compact_str", "macros", "salsa_unstable", diff --git a/crates/ruff_db/src/files.rs b/crates/ruff_db/src/files.rs index 39baaea520..7661807d59 100644 --- a/crates/ruff_db/src/files.rs +++ b/crates/ruff_db/src/files.rs @@ -344,10 +344,12 @@ pub struct File { /// The unix permissions of the file. Only supported on unix systems. Always `None` on Windows /// or when the file has been deleted. #[default] + #[returns(copy)] pub permissions: Option, /// The path revision. A file or directory has changed if the revisions don't compare equal. #[default] + #[returns(copy)] pub revision: FileRevision, /// The status of the file. @@ -355,6 +357,7 @@ pub struct File { /// Salsa doesn't support deleting inputs. The only way to signal dependent queries that /// the file has been deleted is to change the status to `Deleted`. #[default] + #[returns(copy)] pub status: FileStatus, /// Overrides the result of [`source_text`](crate::source::source_text). diff --git a/crates/ruff_db/src/files/file_root.rs b/crates/ruff_db/src/files/file_root.rs index 919bf152f8..db54fde524 100644 --- a/crates/ruff_db/src/files/file_root.rs +++ b/crates/ruff_db/src/files/file_root.rs @@ -18,6 +18,7 @@ pub struct FileRoot { pub path: Box, /// The kind of the root at the time of its creation. + #[returns(copy)] pub kind_at_time_of_creation: FileRootKind, } diff --git a/crates/ruff_db/src/panic.rs b/crates/ruff_db/src/panic.rs index 61faf91875..6d12e5bd0f 100644 --- a/crates/ruff_db/src/panic.rs +++ b/crates/ruff_db/src/panic.rs @@ -183,10 +183,11 @@ mod tests { fn no_backtrace_for_salsa_cancelled() { #[salsa::input] struct Input { + #[returns(copy)] value: u32, } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn test_query(db: &dyn Database, input: Input) -> u32 { loop { // This should throw a cancelled error diff --git a/crates/ruff_db/src/source.rs b/crates/ruff_db/src/source.rs index 5a2d1b29f2..511a7e7fa6 100644 --- a/crates/ruff_db/src/source.rs +++ b/crates/ruff_db/src/source.rs @@ -12,7 +12,7 @@ use crate::files::{File, FilePath}; use crate::system::System; /// Reads the source text of a python text file (must be valid UTF8) or notebook. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(clone), heap_size=ruff_memory_usage::heap_size)] pub fn source_text(db: &dyn Db, file: File) -> SourceText { let path = file.path(db); let _span = tracing::trace_span!("source_text", file = %path).entered(); @@ -202,7 +202,7 @@ pub enum SourceTextError { } /// Computes the [`LineIndex`] for `file`. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(clone), heap_size=ruff_memory_usage::heap_size)] pub fn line_index(db: &dyn Db, file: File) -> LineIndex { let _span = tracing::trace_span!("line_index", ?file).entered(); diff --git a/crates/ruff_db/src/testing.rs b/crates/ruff_db/src/testing.rs index c1096c45aa..e74d8a5bc0 100644 --- a/crates/ruff_db/src/testing.rs +++ b/crates/ruff_db/src/testing.rs @@ -227,10 +227,11 @@ fn query_was_not_run() { #[salsa::input(debug)] struct Input { + #[returns(clone)] text: String, } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn len(db: &dyn salsa::Database, input: Input) -> usize { input.text(db).len() } @@ -262,10 +263,11 @@ fn query_was_not_run_fails_if_query_was_run() { #[salsa::input(debug)] struct Input { + #[returns(clone)] text: String, } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn len(db: &dyn salsa::Database, input: Input) -> usize { input.text(db).len() } @@ -294,10 +296,11 @@ fn const_query_was_not_run_fails_if_query_was_run() { #[salsa::input] struct Input { + #[returns(clone)] text: String, } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn len(db: &dyn salsa::Database) -> usize { db.report_untracked_read(); 5 @@ -325,10 +328,11 @@ fn query_was_run_fails_if_query_was_not_run() { #[salsa::input(debug)] struct Input { + #[returns(clone)] text: String, } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn len(db: &dyn salsa::Database, input: Input) -> usize { input.text(db).len() } diff --git a/crates/ruff_index/src/frozen.rs b/crates/ruff_index/src/frozen.rs index f7ccf87f34..1a88b472a6 100644 --- a/crates/ruff_index/src/frozen.rs +++ b/crates/ruff_index/src/frozen.rs @@ -74,15 +74,7 @@ impl FromIterator for FrozenIndexVec { #[expect(unsafe_code)] unsafe impl Send for FrozenIndexVec where T: Send {} +// SAFETY: `FrozenIndexVec` owns its elements; `I` is only a marker. #[expect(unsafe_code)] #[cfg(feature = "salsa")] -unsafe impl salsa::Update for FrozenIndexVec -where - T: salsa::Update, -{ - #[expect(unsafe_code)] - unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool { - let old_box: &mut FrozenIndexVec = unsafe { &mut *old_pointer }; - unsafe { salsa::Update::maybe_update(&raw mut old_box.raw, new_value.raw) } - } -} +unsafe impl salsa::SalsaValue for FrozenIndexVec {} diff --git a/crates/ruff_index/src/vec.rs b/crates/ruff_index/src/vec.rs index 648f9cb132..44c9734ffb 100644 --- a/crates/ruff_index/src/vec.rs +++ b/crates/ruff_index/src/vec.rs @@ -182,15 +182,7 @@ impl From<[T; N]> for IndexVec { #[expect(unsafe_code)] unsafe impl Send for IndexVec where T: Send {} +// SAFETY: `IndexVec` owns its elements; `I` is only a marker. #[expect(unsafe_code)] #[cfg(feature = "salsa")] -unsafe impl salsa::Update for IndexVec -where - T: salsa::Update, -{ - #[expect(unsafe_code)] - unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool { - let old_vec: &mut IndexVec = unsafe { &mut *old_pointer }; - unsafe { salsa::Update::maybe_update(&raw mut old_vec.raw, new_value.raw) } - } -} +unsafe impl salsa::SalsaValue for IndexVec {} diff --git a/crates/ruff_python_ast/src/name.rs b/crates/ruff_python_ast/src/name.rs index da9730dab2..8c045ddcf0 100644 --- a/crates/ruff_python_ast/src/name.rs +++ b/crates/ruff_python_ast/src/name.rs @@ -9,9 +9,9 @@ use crate::Expr; use crate::generated::ExprName; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[cfg_attr(feature = "salsa", derive(salsa::SalsaValue))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "cache", derive(ruff_macros::CacheKey))] -#[cfg_attr(feature = "salsa", derive(salsa::Update))] #[cfg_attr(feature = "get-size", derive(get_size2::GetSize))] #[cfg_attr( feature = "schemars", diff --git a/crates/ruff_python_formatter/src/lib.rs b/crates/ruff_python_formatter/src/lib.rs index d3b52ab513..79e8a72552 100644 --- a/crates/ruff_python_formatter/src/lib.rs +++ b/crates/ruff_python_formatter/src/lib.rs @@ -95,7 +95,7 @@ where fn fmt_fields(&self, item: &N, f: &mut PyFormatter) -> FormatResult<()>; } -#[derive(Error, Debug, salsa::Update, PartialEq, Eq)] +#[derive(Error, Debug, PartialEq, Eq)] pub enum FormatModuleError { #[error(transparent)] ParseError(#[from] ParseError), diff --git a/crates/ty_module_resolver/src/list.rs b/crates/ty_module_resolver/src/list.rs index f330133054..7451c6e56a 100644 --- a/crates/ty_module_resolver/src/list.rs +++ b/crates/ty_module_resolver/src/list.rs @@ -98,7 +98,9 @@ fn list_modules_in<'db>( /// A module paired with whether it came from a stub package. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] struct ListedModule<'db> { + #[returns(copy)] module: Module<'db>, + #[returns(copy)] is_stub_package: bool, } diff --git a/crates/ty_module_resolver/src/module.rs b/crates/ty_module_resolver/src/module.rs index d4aac67efd..be5d8bd80d 100644 --- a/crates/ty_module_resolver/src/module.rs +++ b/crates/ty_module_resolver/src/module.rs @@ -12,7 +12,7 @@ use crate::module_name::ModuleName; use crate::path::{SearchPath, SystemOrVendoredPathRef}; /// Representation of a Python module. -#[derive(Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update)] +#[derive(Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype, salsa::SalsaValue)] pub enum Module<'db> { File(FileModule<'db>), Namespace(NamespacePackage<'db>), @@ -256,10 +256,13 @@ fn all_submodule_names_for_package<'db>( pub struct FileModule<'db> { #[returns(ref)] pub(super) name: ModuleName, + #[returns(copy)] pub(super) kind: ModuleKind, #[returns(ref)] pub(super) search_path: SearchPath, + #[returns(copy)] pub(super) file: File, + #[returns(copy)] pub(super) known: Option, } diff --git a/crates/ty_module_resolver/src/resolve.rs b/crates/ty_module_resolver/src/resolve.rs index e923f61aca..a4589a44c1 100644 --- a/crates/ty_module_resolver/src/resolve.rs +++ b/crates/ty_module_resolver/src/resolve.rs @@ -158,6 +158,7 @@ pub enum ModuleResolveMode { #[salsa::interned(heap_size=ruff_memory_usage::heap_size)] #[derive(Debug)] pub(crate) struct ModuleResolveModeIngredient<'db> { + #[returns(copy)] mode: ModuleResolveMode, } @@ -204,7 +205,7 @@ impl ModuleResolveMode { /// /// This query should not be called directly. Instead, use [`resolve_module`]. It only exists /// because Salsa requires the module name to be an ingredient. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn resolve_module_query<'db>( db: &'db dyn Db, module_name: ModuleNameIngredient<'db>, @@ -236,7 +237,7 @@ fn resolve_module_query<'db>( /// /// Cache desperate resolution because repeated unresolved imports in a project can otherwise /// re-walk the same importing-file-relative search paths many times. -#[salsa::tracked] +#[salsa::tracked(returns(copy))] fn desperately_resolve_module<'db>( db: &'db dyn Db, importing_file: File, @@ -289,7 +290,7 @@ pub(crate) fn path_to_module<'db>(db: &'db dyn Db, path: &FilePath) -> Option` to derive the module name of `.`. /// This intuition is particularly useful for understanding why it's correct that we pass /// the file itself as `importing_file` to various subroutines. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] pub fn file_to_module(db: &dyn Db, file: File) -> Option> { let _span = tracing::trace_span!("file_to_module", ?file).entered(); @@ -526,7 +527,7 @@ fn absolute_desperate_search_paths(db: &dyn Db, importing_file: File) -> Option< /// Being so strict minimizes concerns about this going off a lot and doing random /// chaotic things. In particular, all files under a given pyproject.toml will currently /// agree on this being their desperate search-path, which is really nice. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(clone), heap_size=ruff_memory_usage::heap_size)] fn relative_desperate_search_paths(db: &dyn Db, importing_file: File) -> Option { let system = db.system(); let importing_path = importing_file.path(db).as_system_path()?; @@ -1053,6 +1054,7 @@ impl FusedIterator for SearchPathIterator<'_> {} struct ModuleNameIngredient<'db> { #[returns(ref)] pub(super) name: ModuleName, + #[returns(copy)] pub(super) mode: ModuleResolveMode, } diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index 14950e043d..f1760c4e38 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -110,13 +110,16 @@ pub struct Project { /// This changes the behavior of `check` to either check only the open files or all files in /// the project including the virtual files that might exists in the editor. #[default] + #[returns(copy)] check_mode: CheckMode, #[default] + #[returns(copy)] verbose_flag: bool, /// Whether to enforce exclusion rules even to files explicitly passed to ty on the command line. #[default] + #[returns(copy)] force_exclude_flag: bool, } diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index 28a35021da..5caa8749ac 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -179,7 +179,7 @@ pub(crate) fn file_settings(db: &dyn Db, file: File) -> FileSettings { /// This is to make Salsa happy because it requires that queries with only a single argument /// take a salsa-struct as argument, which isn't the case here. The `()` enables salsa's /// automatic interning for the arguments. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(clone), heap_size=ruff_memory_usage::heap_size)] fn merge_overrides(db: &dyn Db, overrides: Vec>, _: ()) -> FileSettings { let mut overrides = overrides.into_iter().rev(); let mut merged = (*overrides.next().unwrap()).clone(); diff --git a/crates/ty_python_core/src/ast_ids.rs b/crates/ty_python_core/src/ast_ids.rs index 3343434274..cfa3d25460 100644 --- a/crates/ty_python_core/src/ast_ids.rs +++ b/crates/ty_python_core/src/ast_ids.rs @@ -26,7 +26,7 @@ pub use node_key::ExpressionNodeKey; /// /// x = foo() /// ``` -#[derive(Debug, salsa::Update, get_size2::GetSize)] +#[derive(Debug, get_size2::GetSize)] pub(crate) struct AstIds { /// Maps expressions which "use" a place (that is, [`ast::ExprName`], [`ast::ExprAttribute`] or [`ast::ExprSubscript`]) to a use id. uses_map: FrozenMap, @@ -136,7 +136,16 @@ pub(crate) mod node_key { use crate::{ast_node_ref::AstNodeRef, node_key::NodeKey}; #[derive( - Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, salsa::Update, get_size2::GetSize, + Copy, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + get_size2::GetSize, + salsa::SalsaValue, )] pub struct ExpressionNodeKey(NodeKey); diff --git a/crates/ty_python_core/src/ast_node_ref.rs b/crates/ty_python_core/src/ast_node_ref.rs index 9f48a810a6..1e81ac4b59 100644 --- a/crates/ty_python_core/src/ast_node_ref.rs +++ b/crates/ty_python_core/src/ast_node_ref.rs @@ -32,7 +32,7 @@ use ruff_text_size::Ranged; /// This means that changes to expressions in other scopes don't invalidate the expression's id, giving /// us some form of scope-stable identity for expressions. Only queries accessing the node field /// run on every AST change. All other queries only run when the expression's identity changes. -#[derive(Clone, salsa::Update)] +#[derive(Clone)] pub struct AstNodeRef { /// The index of the node in the AST. index: NodeIndex, @@ -48,9 +48,6 @@ pub struct AstNodeRef { #[cfg(debug_assertions)] file: File, - // Always consider the node changed because its identity also depends on the module address, - // which is omitted in release builds. Customizing this field avoids requiring `T: Update`. - #[update(unsafe(with(|_, _| true)))] _node: PhantomData, } diff --git a/crates/ty_python_core/src/definition.rs b/crates/ty_python_core/src/definition.rs index 205dade1b1..5e5f4a8936 100644 --- a/crates/ty_python_core/src/definition.rs +++ b/crates/ty_python_core/src/definition.rs @@ -40,9 +40,11 @@ pub struct Definition<'db> { /// /// Storing the interned scope avoids retaining the file and file-local scope separately, at /// the cost of database lookups when either of those values is needed. + #[returns(copy)] pub scope_id: ScopeId<'db>, /// The place ID and re-export state of the definition. + #[returns(copy)] place_info: DefinitionPlace, /// WARNING: Only access this field when doing type inference for the same @@ -174,7 +176,7 @@ impl<'db> Definition<'db> { /// Keeping the re-export state in the enum lets it share the place ID's otherwise-unused /// representation space. Storing it as a separate field on [`Definition`] would add padding to /// every tracked definition. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] pub enum DefinitionPlace { Symbol { id: ScopedSymbolId, @@ -262,7 +264,7 @@ fn attribute_docstring<'a>( } /// One or more [`Definition`]s. -#[derive(Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Default, PartialEq, Eq, get_size2::GetSize)] pub struct Definitions<'db> { definitions: smallvec::SmallVec<[Definition<'db>; 1]>, } @@ -300,7 +302,7 @@ impl<'a, 'db> IntoIterator for &'a Definitions<'db> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize)] pub enum DefinitionState<'db> { Defined(Definition<'db>), /// Represents the implicit "unbound"/"undeclared" definition of every place. @@ -910,7 +912,7 @@ impl DefinitionCategory { /// [`DefinitionKind`] fields in salsa tracked structs should be tracked (attributed with `#[tracked]`) /// because the kind is a thin wrapper around [`AstNodeRef`]. See the [`AstNodeRef`] documentation /// for an in-depth explanation of why this is necessary. -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub enum DefinitionKind<'db> { Import(ImportDefinitionKind), ImportFrom(ImportFromDefinitionKind), @@ -1214,7 +1216,7 @@ impl StarImportDefinitionKind { } } -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct MatchPatternDefinitionKind<'db> { pattern: AstNodeRef, identifier: AstNodeRef, @@ -1236,7 +1238,7 @@ impl<'db> MatchPatternDefinitionKind<'db> { /// But if the target is an attribute or subscript, its definition is not in the comprehension's scope; /// it is in the scope in which the root variable is bound. /// TODO: currently we don't model this correctly and simply assume that it is in a scope outside the comprehension. -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct ComprehensionDefinitionKind<'db> { unpack: Option>, node: AstNodeRef, @@ -1412,7 +1414,7 @@ impl ImportFromSubmoduleDefinitionKind { } } -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct AssignmentDefinitionKind<'db> { unpack: Option>, value: AstNodeRef, @@ -1456,7 +1458,7 @@ impl AnnotatedAssignmentDefinitionKind { } } -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct DictKeyAssignmentKind<'db> { pub(crate) key: AstNodeRef, pub(crate) value: AstNodeRef, @@ -1477,7 +1479,7 @@ impl<'db> DictKeyAssignmentKind<'db> { } } -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct WithItemDefinitionKind<'db> { unpack: Option>, item: AstNodeRef, @@ -1504,7 +1506,7 @@ impl<'db> WithItemDefinitionKind<'db> { } } -#[derive(Clone, Debug, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct ForStmtDefinitionKind<'db> { unpack: Option>, node: AstNodeRef, @@ -1599,7 +1601,7 @@ pub struct NestedBindingsDefinitionKind { } #[derive( - Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, salsa::Update, get_size2::GetSize, + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, get_size2::GetSize, salsa::SalsaValue, )] pub struct DefinitionNodeKey(NodeKey); diff --git a/crates/ty_python_core/src/expression.rs b/crates/ty_python_core/src/expression.rs index 9329948b5e..dec30584ac 100644 --- a/crates/ty_python_core/src/expression.rs +++ b/crates/ty_python_core/src/expression.rs @@ -36,6 +36,7 @@ pub struct Expression<'db> { /// /// Storing the interned scope avoids retaining the file and file-local scope separately, at /// the cost of database lookups when either of those values is needed. + #[returns(copy)] pub scope_id: ScopeId<'db>, /// The expression node. @@ -53,9 +54,11 @@ pub struct Expression<'db> { /// to the target, and so have `None` for this field.) #[no_eq] #[tracked] + #[returns(clone)] pub assigned_to: Option>, /// Should this expression be inferred as a normal expression or a type expression? + #[returns(copy)] pub kind: ExpressionKind, } diff --git a/crates/ty_python_core/src/frozen.rs b/crates/ty_python_core/src/frozen.rs index a94fa3ed45..6df174dd19 100644 --- a/crates/ty_python_core/src/frozen.rs +++ b/crates/ty_python_core/src/frozen.rs @@ -7,7 +7,7 @@ use rustc_hash::FxHashMap; /// /// Analysis builds these tables with hash maps, but after construction they only need keyed /// lookup. A sorted slice avoids retaining hash-table capacity for every indexed file. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct FrozenMap(Box<[(K, V)]>); impl FrozenMap { @@ -115,7 +115,7 @@ impl<'a, K, V> IntoIterator for &'a mut FrozenMap { } #[newtype_index] -#[derive(get_size2::GetSize, salsa::Update)] +#[derive(get_size2::GetSize, salsa::SalsaValue)] struct FrozenValueIndex; /// Sorts entries by key and removes duplicate keys, retaining the last value for each key. @@ -159,7 +159,7 @@ where } /// Compact immutable key-value entries that deduplicate repeated values. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct FrozenValueMap { entries: FrozenMap, values: FrozenIndexVec, @@ -244,7 +244,7 @@ impl Default for FrozenValueMap { /// /// Analysis builds these sets with hash sets, but after construction they only need membership /// tests and iteration. A sorted slice avoids retaining hash-table capacity. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize)] pub struct FrozenSet(Box<[K]>); impl From> for FrozenSet { diff --git a/crates/ty_python_core/src/lib.rs b/crates/ty_python_core/src/lib.rs index b332363790..2308bed39e 100644 --- a/crates/ty_python_core/src/lib.rs +++ b/crates/ty_python_core/src/lib.rs @@ -13,7 +13,6 @@ use ruff_python_ast::NodeIndex; use ruff_python_parser::semantic_errors::SemanticSyntaxError; use ruff_text_size::TextRange; use rustc_hash::{FxHashMap, FxHashSet}; -use salsa::Update; use salsa::plumbing::AsId; use smallvec::SmallVec; use ty_module_resolver::ModuleName; @@ -136,7 +135,7 @@ pub fn use_def_map<'db>(db: &'db dyn Db, scope: ScopeId<'db>) -> Arc>, } @@ -221,7 +220,7 @@ pub fn attribute_scopes<'db>( } /// Returns the module global scope of `file`. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] pub fn global_scope(db: &dyn Db, file: File) -> ScopeId<'_> { let _span = tracing::trace_span!("global_scope", ?file).entered(); @@ -235,7 +234,7 @@ pub enum EnclosingSnapshotResult<'map, 'db> { NoLongerInEagerContext, } -#[derive(Debug, PartialEq, Eq, Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct DefinitionsByNode<'db> { single: FrozenMap>, non_single: FrozenMap]>>, @@ -277,7 +276,7 @@ impl<'db> DefinitionsByNode<'db> { } /// The place tables and use-def maps for all scopes in a file. -#[derive(Debug, Update, get_size2::GetSize)] +#[derive(Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct SemanticIndex<'db> { /// List of all place tables in this file, indexed by scope. place_tables: FrozenIndexVec>, @@ -348,7 +347,7 @@ pub struct SemanticIndex<'db> { narrowing_alias_predicates: FrozenMap>, } -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct NarrowingAliasPredicate<'db> { /// Aliased expression, e.g., `x is None` in `is_none = x is None`. pub expression: Expression<'db>, @@ -1008,7 +1007,7 @@ impl From for Truthiness { } } -#[derive(Clone, Copy, Debug, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, get_size2::GetSize)] pub enum EvaluationMode { Sync, Async, @@ -1029,7 +1028,7 @@ impl EvaluationMode { } /// Specifies how the boundness of a place should be determined. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum BoundnessAnalysis { /// The place is always considered bound. AssumeBound, diff --git a/crates/ty_python_core/src/member.rs b/crates/ty_python_core/src/member.rs index 5e42e89da9..9d5face2d3 100644 --- a/crates/ty_python_core/src/member.rs +++ b/crates/ty_python_core/src/member.rs @@ -421,7 +421,7 @@ impl Hash for MemberExprRef<'_> { /// Uniquely identifies a member in a scope. #[newtype_index] -#[derive(Ord, PartialOrd, get_size2::GetSize, salsa::Update)] +#[derive(Ord, PartialOrd, get_size2::GetSize)] pub struct ScopedMemberId; /// Map from member path to its ID. diff --git a/crates/ty_python_core/src/narrowing_constraints.rs b/crates/ty_python_core/src/narrowing_constraints.rs index 9f3ae55eaa..5740ec075d 100644 --- a/crates/ty_python_core/src/narrowing_constraints.rs +++ b/crates/ty_python_core/src/narrowing_constraints.rs @@ -46,7 +46,7 @@ use crate::scope::FileScopeId; /// /// `ALWAYS_TRUE` means that no narrowing applies. `ALWAYS_FALSE` means that the path is /// impossible. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] pub struct ScopedNarrowingConstraint(u32); impl ScopedNarrowingConstraint { @@ -90,7 +90,7 @@ pub struct InteriorNode { pub if_false: ScopedNarrowingConstraint, } -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] pub struct NarrowingConstraints { used_interiors: Box<[InteriorNode]>, used_indices: Option, diff --git a/crates/ty_python_core/src/node_key.rs b/crates/ty_python_core/src/node_key.rs index e1d82a7317..1165b24424 100644 --- a/crates/ty_python_core/src/node_key.rs +++ b/crates/ty_python_core/src/node_key.rs @@ -3,9 +3,7 @@ use ruff_python_ast::{HasNodeIndex, NodeIndex}; use crate::ast_node_ref::AstNodeRef; /// Compact key for a node for use in a hash map. -#[derive( - Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, salsa::Update, get_size2::GetSize, -)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, get_size2::GetSize)] pub struct NodeKey(NodeIndex); impl NodeKey { diff --git a/crates/ty_python_core/src/place.rs b/crates/ty_python_core/src/place.rs index 1505525cba..409f1ae221 100644 --- a/crates/ty_python_core/src/place.rs +++ b/crates/ty_python_core/src/place.rs @@ -177,14 +177,14 @@ impl std::fmt::Display for PlaceExprRef<'_> { /// ID that uniquely identifies a place inside a [`Scope`](super::FileScopeId). #[derive( - Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, get_size2::GetSize, salsa::Update, + Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, get_size2::GetSize, salsa::SalsaValue, )] pub enum ScopedPlaceId { Symbol(ScopedSymbolId), Member(ScopedMemberId), } -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize)] pub struct PlaceTable { symbols: SymbolTable, members: MemberTable, diff --git a/crates/ty_python_core/src/predicate.rs b/crates/ty_python_core/src/predicate.rs index 8700c87cbc..1f259d8584 100644 --- a/crates/ty_python_core/src/predicate.rs +++ b/crates/ty_python_core/src/predicate.rs @@ -72,13 +72,13 @@ impl<'db> PredicatesBuilder<'db> { } } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct Predicate<'db> { pub node: PredicateNode<'db>, pub is_positive: bool, } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize)] pub(crate) enum PredicateOrLiteral<'db> { Literal(bool), Predicate(Predicate<'db>), @@ -98,7 +98,7 @@ impl PredicateOrLiteral<'_> { } } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct CallableAndCallExpr<'db> { pub callable: Expression<'db>, pub call_expr: Expression<'db>, @@ -108,7 +108,7 @@ pub struct CallableAndCallExpr<'db> { pub is_await: bool, } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum PredicateNode<'db> { Expression(Expression<'db>), /// These predicates are recorded for statements with call expressions. As part of @@ -143,14 +143,14 @@ pub enum PredicateNode<'db> { /// /// The full pattern determines the predicate's truth value, while `target` selects the subject /// occurrence whose aligned pattern constraint should be applied to a binding. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct SubjectElementPatternPredicate<'db> { pub pattern: PatternPredicate<'db>, pub target: ExpressionNodeKey, } /// Structural details for sequence patterns that affect narrowing and reachability. -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct SequencePatternPredicateKind<'db> { pub patterns: Box<[PatternPredicateKind<'db>]>, } @@ -176,7 +176,7 @@ impl<'db> SequencePatternPredicateKind<'db> { } /// Structural details for a class pattern. -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct ClassPatternPredicateKind<'db> { pub class: Expression<'db>, pub positional: Box<[PatternPredicateKind<'db>]>, @@ -189,14 +189,14 @@ impl ClassPatternPredicateKind<'_> { } } -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct ClassPatternKeywordPredicateKind<'db> { pub attr: Name, pub pattern: PatternPredicateKind<'db>, } /// Structural details for a mapping pattern. -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct MappingPatternPredicateKind<'db> { pub entries: Box<[MappingPatternEntryPredicateKind<'db>]>, pub rest: Option, @@ -208,7 +208,7 @@ impl MappingPatternPredicateKind<'_> { } } -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct MappingPatternEntryPredicateKind<'db> { pub key: Expression<'db>, pub pattern: PatternPredicateKind<'db>, @@ -216,7 +216,7 @@ pub struct MappingPatternEntryPredicateKind<'db> { /// Pattern structure used for type narrowing, static reachability, and inferring the types of /// names bound by a successful match. -#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum PatternPredicateKind<'db> { Singleton(Singleton), Value(Expression<'db>), @@ -230,15 +230,19 @@ pub enum PatternPredicateKind<'db> { #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] pub struct PatternPredicate<'db> { + #[returns(copy)] pub file: File, + #[returns(copy)] pub file_scope: FileScopeId, + #[returns(copy)] pub subject: Expression<'db>, #[returns(ref)] pub kind: PatternPredicateKind<'db>, + #[returns(copy)] pub guard: Option>, /// A reference to the pattern of the previous match case @@ -297,6 +301,7 @@ impl<'db> PatternPredicate<'db> { /// [Truthiness]: [crate::types::Truthiness] #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] pub struct StarImportPlaceholderPredicate<'db> { + #[returns(copy)] pub importing_file: File, /// Each symbol imported by a `*` import has a separate predicate associated with it: @@ -308,8 +313,10 @@ pub struct StarImportPlaceholderPredicate<'db> { /// for valid `*`-import definitions, and valid `*`-import definitions can only ever /// exist in the global scope; thus, we know that the `symbol_id` here will be relative /// to the global scope of the importing file. + #[returns(copy)] pub symbol_id: ScopedSymbolId, + #[returns(copy)] pub referenced_file: File, } diff --git a/crates/ty_python_core/src/rank.rs b/crates/ty_python_core/src/rank.rs index 69c02e4d8c..2105477304 100644 --- a/crates/ty_python_core/src/rank.rs +++ b/crates/ty_python_core/src/rank.rs @@ -23,10 +23,9 @@ use get_size2::GetSize; /// /// This trick adds O(1.5) bits of overhead per large vector element on 64-bit platforms, and O(2) /// bits of overhead on 32-bit platforms. -#[derive(Clone, Debug, Eq, Hash, PartialEq, GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, GetSize)] pub struct RankBitBox { #[get_size(size_fn = bit_box_size)] - #[update(fallback)] bits: RankBitBoxStorage, chunk_ranks: Box<[u32]>, } diff --git a/crates/ty_python_core/src/reachability_constraints.rs b/crates/ty_python_core/src/reachability_constraints.rs index e6cdf03ea1..04625ecbe2 100644 --- a/crates/ty_python_core/src/reachability_constraints.rs +++ b/crates/ty_python_core/src/reachability_constraints.rs @@ -29,7 +29,7 @@ use crate::rank::{RankBitBox, RankBitBoxVec}; /// /// reachability constraints are normalized, so equivalent constraints are guaranteed to have equal /// IDs. -#[derive(Clone, Copy, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Eq, Hash, PartialEq, get_size2::GetSize)] pub struct ScopedReachabilityConstraintId(u32); impl std::fmt::Debug for ScopedReachabilityConstraintId { @@ -137,7 +137,7 @@ const SMALLEST_TERMINAL: ScopedReachabilityConstraintId = ALWAYS_FALSE; const MAX_INTERIOR_NODES: usize = 512 * 1024; /// A collection of reachability constraints for a given scope. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] pub struct ReachabilityConstraints { /// The interior TDD nodes that were marked as used when being built. used_interiors: Box<[InteriorNode]>, diff --git a/crates/ty_python_core/src/scope.rs b/crates/ty_python_core/src/scope.rs index 9a77f1652b..72abec9090 100644 --- a/crates/ty_python_core/src/scope.rs +++ b/crates/ty_python_core/src/scope.rs @@ -12,8 +12,10 @@ use crate::{ /// A cross-module identifier of a scope that can be used as a salsa query parameter. #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] pub struct ScopeId<'db> { + #[returns(copy)] pub file: File, + #[returns(copy)] pub file_scope_id: FileScopeId, } @@ -82,7 +84,7 @@ impl<'db> ScopeId<'db> { /// ID that uniquely identifies a scope inside of a module. #[newtype_index] -#[derive(Ord, PartialOrd, salsa::Update, get_size2::GetSize)] +#[derive(Ord, PartialOrd, get_size2::GetSize)] pub struct FileScopeId; impl FileScopeId { @@ -109,7 +111,7 @@ impl FileScopeId { } } -#[derive(Debug, salsa::Update, get_size2::GetSize)] +#[derive(Debug, get_size2::GetSize)] pub struct Scope { /// The parent scope, if any. parent: Option, @@ -365,7 +367,7 @@ impl NodeWithScopeRef<'_> { } /// Node that introduces a new scope. -#[derive(Clone, Debug, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, get_size2::GetSize)] pub enum NodeWithScopeKind { Module, Class(AstNodeRef), diff --git a/crates/ty_python_core/src/statement.rs b/crates/ty_python_core/src/statement.rs index 33bef81125..9f431884f3 100644 --- a/crates/ty_python_core/src/statement.rs +++ b/crates/ty_python_core/src/statement.rs @@ -13,7 +13,7 @@ use salsa; /// Many statements can be treated directly as definitions or expressions, /// and so do not require a separate Salsa allocation. #[derive( - Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update, get_size2::GetSize, + Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, get_size2::GetSize, salsa::SalsaValue, )] pub enum Statement<'db> { Expression(Expression<'db>), @@ -37,9 +37,11 @@ pub enum Statement<'db> { #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] pub struct StatementInner<'db> { /// The file in which the statement occurs. + #[returns(copy)] pub file: File, /// The scope in which the statement occurs. + #[returns(copy)] pub file_scope: FileScopeId, /// The statement node. @@ -58,7 +60,7 @@ impl<'db> StatementInner<'db> { } } -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, get_size2::GetSize, salsa::SalsaValue)] pub struct StatementNodeKey(NodeKey); impl From<&ast::Stmt> for StatementNodeKey { diff --git a/crates/ty_python_core/src/symbol.rs b/crates/ty_python_core/src/symbol.rs index 24a0503c8a..0c6be89726 100644 --- a/crates/ty_python_core/src/symbol.rs +++ b/crates/ty_python_core/src/symbol.rs @@ -17,7 +17,7 @@ const LINEAR_SEARCH_THRESHOLD: usize = 16; pub struct ScopedSymbolId; /// A symbol in a given scope. -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub struct Symbol { name: Name, flags: SymbolFlags, diff --git a/crates/ty_python_core/src/unpack.rs b/crates/ty_python_core/src/unpack.rs index 4714e1df0a..2037dede53 100644 --- a/crates/ty_python_core/src/unpack.rs +++ b/crates/ty_python_core/src/unpack.rs @@ -29,10 +29,13 @@ use crate::scope::{FileScopeId, ScopeId}; /// * an argument of a cross-module query #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] pub struct Unpack<'db> { + #[returns(copy)] pub file: File, + #[returns(copy)] pub(crate) value_file_scope: FileScopeId, + #[returns(copy)] pub(crate) target_file_scope: FileScopeId, /// The target expression that is being unpacked. For example, in `(a, b) = (1, 2)`, the target @@ -44,6 +47,7 @@ pub struct Unpack<'db> { /// The ingredient representing the value expression of the unpacking. For example, in /// `(a, b) = (1, 2)`, the value expression is `(1, 2)`. + #[returns(copy)] pub value: UnpackValue<'db>, } @@ -67,7 +71,7 @@ impl<'db> Unpack<'db> { } /// The expression that is being unpacked. -#[derive(Clone, Copy, Debug, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct UnpackValue<'db> { /// The kind of unpack expression kind: UnpackKind, @@ -99,7 +103,7 @@ impl<'db> UnpackValue<'db> { } } -#[derive(Clone, Copy, Debug, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, get_size2::GetSize)] pub enum UnpackKind { /// An iterable expression like the one in a `for` loop or a comprehension. Iterable { mode: EvaluationMode }, @@ -110,7 +114,7 @@ pub enum UnpackKind { } /// The position of the target element in an unpacking. -#[derive(Clone, Copy, Debug, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, get_size2::GetSize)] pub enum UnpackPosition { /// The target element is in the first position of the unpacking. First, diff --git a/crates/ty_python_core/src/use_def.rs b/crates/ty_python_core/src/use_def.rs index 2acbec6c0a..9babfd5b09 100644 --- a/crates/ty_python_core/src/use_def.rs +++ b/crates/ty_python_core/src/use_def.rs @@ -280,20 +280,20 @@ pub(super) use place_state::{FutureDefinitions, PreviousDefinitions}; /// Identifies a [`LoopHeader`] within a single scope's [`UseDefMap`]. #[newtype_index] -#[derive(salsa::Update, get_size2::GetSize)] +#[derive(get_size2::GetSize)] pub struct LoopHeaderId; /// Uniquely identifies an interned [`Bindings`] entry in [`UseDefMap::interned_bindings`]. #[newtype_index] -#[derive(salsa::Update, get_size2::GetSize)] +#[derive(get_size2::GetSize, salsa::SalsaValue)] struct InternedBindingsId; /// Uniquely identifies an interned [`Declarations`] entry in [`UseDefMap::interned_declarations`]. #[newtype_index] -#[derive(salsa::Update, get_size2::GetSize)] +#[derive(get_size2::GetSize, salsa::SalsaValue)] struct InternedDeclarationsId; -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, get_size2::GetSize)] struct InternedPlaceStateId(InternedBindingsId, InternedDeclarationsId); impl InternedPlaceStateId { @@ -443,7 +443,7 @@ impl PlaceStateInterner { /// The builder needs a `SmallVec` and an optional unbound constraint while constructing each /// binding state. Neither is needed after the semantic index is built, so the retained map stores /// cumulative end offsets into one contiguous array instead. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] struct RetainedBindings { ends: FrozenIndexVec, live_bindings: Box<[LiveBinding]>, @@ -512,7 +512,7 @@ impl Index for RetainedBindings { } /// Compact, retained representation of the interned declaration vectors for a scope. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] struct RetainedDeclarations { /// The exclusive end of each state in `live_declarations`; its start is the previous end. ends: FrozenIndexVec, @@ -567,26 +567,26 @@ impl Index for RetainedDeclarations { } } -#[derive(Clone, Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize)] struct RetainedPlaceStates { end_of_scope: T, reachable: T, } -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct DefinitionsAtDefinition { bindings: B, declarations: Option, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] enum InternedEnclosingSnapshotId { Constraint(ScopedNarrowingConstraint), Bindings(InternedBindingsId), } /// Lookup tables needed to evaluate reachability and narrowing constraints. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct ConstraintTables<'db> { predicates: Predicates<'db>, reachability_constraints: ReachabilityConstraints, @@ -597,7 +597,7 @@ struct ConstraintTables<'db> { /// /// These fields share an allocation to avoid storing five collection headers in every /// [`UseDefMap`]. They are not otherwise semantically related. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] struct UseDefMapExtra { /// [`Bindings`] reaching a [`ScopedUseId`]. bindings_by_use: FrozenIndexVec, @@ -631,7 +631,7 @@ static ALWAYS_UNBOUND_BINDINGS: LazyLock = static ALWAYS_UNDECLARED_DECLARATIONS: LazyLock = LazyLock::new(|| Declarations::undeclared(ScopedReachabilityConstraintId::ALWAYS_TRUE)); -#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] enum RetainedDefinitionState<'db> { Unused(Definition<'db>), Used(Definition<'db>), @@ -673,7 +673,7 @@ impl<'db> RetainedDefinitionState<'db> { static_assertions::assert_eq_size!(RetainedDefinitionState<'static>, DefinitionState<'static>); /// Retained definition states, excluding the implicit unbound definition at index zero. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct RetainedDefinitions<'db> { states: Box<[RetainedDefinitionState<'db>]>, } @@ -727,7 +727,7 @@ impl<'db> RetainedDefinitions<'db> { } /// Applicable definitions and constraints for every use of a name. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct UseDefMap<'db> { /// Definition states in this scope, plus an implicit "unbound"/"undeclared" definition at /// index zero. @@ -796,7 +796,7 @@ pub struct UseDefMap<'db> { } /// Information about a given range of source code. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] struct RangeInfo { reachability: ScopedReachabilityConstraintId, in_type_checking_block: bool, @@ -811,7 +811,7 @@ impl Default for RangeInfo { } } -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize)] struct MultiBindingsByUse(ThinVec<(ScopedUseId, Box<[Bindings]>)>); impl MultiBindingsByUse { @@ -1354,7 +1354,7 @@ impl<'db> Iterator for DeclarationsIterator<'_, 'db> { impl std::iter::FusedIterator for DeclarationsIterator<'_, '_> {} -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] struct ReachableDefinitions { bindings: Bindings, declarations: Declarations, diff --git a/crates/ty_python_core/src/use_def/place_state.rs b/crates/ty_python_core/src/use_def/place_state.rs index 13e910f3ae..4467d91958 100644 --- a/crates/ty_python_core/src/use_def/place_state.rs +++ b/crates/ty_python_core/src/use_def/place_state.rs @@ -52,7 +52,7 @@ use crate::reachability_constraints::ScopedReachabilityConstraintId; /// A newtype-index for a definition in a particular scope. #[newtype_index] -#[derive(Ord, PartialOrd, salsa::Update, get_size2::GetSize)] +#[derive(Ord, PartialOrd, get_size2::GetSize)] pub struct ScopedDefinitionId; impl ScopedDefinitionId { @@ -70,14 +70,14 @@ impl ScopedDefinitionId { /// Live declarations for a single place at some point in control flow, with their /// corresponding reachability constraints. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) struct Declarations { /// A list of live declarations for this place, sorted by their `ScopedDefinitionId` live_declarations: SmallVec<[LiveDeclaration; 2]>, } /// One of the live declarations for a single place at some point in control flow. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) struct LiveDeclaration { pub(super) declaration: ScopedDefinitionId, pub(super) reachability_constraint: ScopedReachabilityConstraintId, @@ -98,7 +98,7 @@ pub(crate) enum PreviousDefinitions { /// `ShadowThisOne` is how normal assignments behave, and it's also how some "synthetic" bindings /// behave (loop headers), but there are other synthetic bindings (nested `nonlocal` writes) that /// cannot be shadowed. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] pub(crate) enum FutureDefinitions { ShadowThisOne, DontShadowThisOne, @@ -212,7 +212,7 @@ impl Declarations { /// Even if it's a class scope (class variables are not visible to nested scopes) or there are no /// bindings, the current narrowing constraint is necessary for narrowing, so it's stored in /// `Constraint`. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) enum EnclosingSnapshot { Constraint(ScopedNarrowingConstraint), Bindings(Bindings), @@ -220,7 +220,7 @@ pub(super) enum EnclosingSnapshot { /// Live bindings for a single place at some point in control flow. Each live binding comes /// with a set of narrowing constraints and a reachability constraint. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) struct Bindings { /// The narrowing constraint applicable to the "unbound" binding, if we need access to it even /// when it's not visible. This happens in class scopes, where local name bindings are not visible @@ -262,14 +262,14 @@ impl Bindings { } /// One of the live bindings for a single place at some point in control flow. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] pub struct LiveBinding { binding: PackedDefinitionId, narrowing_constraint: ScopedNarrowingConstraint, reachability_constraint: ScopedReachabilityConstraintId, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] struct PackedDefinitionId(u32); impl PackedDefinitionId { diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 9adb618e2a..e2285e54d5 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -97,7 +97,7 @@ impl PublicTypePolicy { } /// The source definition provenance for a place. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum Provenance<'db> { /// No source definition is known. #[default] @@ -136,7 +136,7 @@ impl<'db> Provenance<'db> { } /// A defined place with its raw type, origin, definedness, public-type policy, and provenance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct DefinedPlace<'db> { pub(crate) ty: Type<'db>, pub(crate) origin: TypeOrigin, @@ -215,7 +215,7 @@ impl<'db> DefinedPlace<'db> { /// bound_or_declared: Place::Defined(DefinedPlace { ty: Literal[1], origin: TypeOrigin::Inferred, definedness: Definedness::PossiblyUndefined, .. }), /// non_existent: Place::Undefined, /// ``` -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum Place<'db> { Defined(DefinedPlace<'db>), #[default] @@ -776,7 +776,7 @@ impl<'db> PlaceFromDeclarationsResult<'db> { /// that this comes with a [`CLASS_VAR`] type qualifier. /// /// [`CLASS_VAR`]: crate::types::TypeQualifiers::CLASS_VAR -#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct PlaceAndQualifiers<'db> { pub(crate) place: Place<'db>, pub(crate) qualifiers: TypeQualifiers, @@ -982,6 +982,7 @@ impl<'db> From> for PlaceAndQualifiers<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _, _, _| Place::bound(Type::divergent(id)).into(), cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, place: PlaceAndQualifiers<'db>, _, _, _, _| { place.cycle_normalized(db, *previous, cycle) @@ -1332,6 +1333,7 @@ fn symbol_impl<'db>( /// Pre-computed reachability analysis for loop-back bindings in a loop header. #[salsa::tracked( + returns(clone), cycle_initial=|db, _, definition| loop_header_reachability_impl(db, definition, true), cycle_fn=loop_header_reachability_cycle_recover, heap_size = ruff_memory_usage::heap_size, @@ -1424,7 +1426,7 @@ fn loop_header_reachability_impl<'db>( } /// Result of [`loop_header_reachability`]: pre-computed reachability info for loop-back bindings. -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct LoopHeaderReachability<'db> { pub(crate) deleted_reachability: Truthiness, /// Reachable loop-back bindings that are not `del`s. @@ -1454,7 +1456,7 @@ impl<'db> LoopHeaderReachability<'db> { } /// A single reachable loop-back binding with its narrowing constraint. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct ReachableLoopBinding<'db> { pub(crate) definition: Definition<'db>, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, @@ -2277,7 +2279,7 @@ impl RequiresExplicitReExport { /// if flag(): /// x = 3 /// ``` -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub(crate) enum ConsideredDefinitions { /// Consider only the definitions that are "live" at the end of the scope, i.e. those /// that have not been shadowed or deleted. diff --git a/crates/ty_python_semantic/src/reachability.rs b/crates/ty_python_semantic/src/reachability.rs index 35755c6660..0f2a50446c 100644 --- a/crates/ty_python_semantic/src/reachability.rs +++ b/crates/ty_python_semantic/src/reachability.rs @@ -236,6 +236,7 @@ use ty_python_core::{ /// rebuilding it from the union of all preceding patterns, which can repeatedly distribute the /// same intersections. #[salsa::tracked( + returns(copy), cycle_initial = |_, id, _, _| Type::divergent(id), cycle_fn = |db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { result.cycle_normalized(db, *previous, cycle) @@ -265,6 +266,7 @@ pub(crate) fn type_narrowed_by_previous_patterns<'db>( /// /// This result is also the preceding-pattern prefix for the next unguarded case. #[salsa::tracked( + returns(copy), cycle_initial = |_, id, _, _| Type::divergent(id), cycle_fn = |db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { result.cycle_normalized(db, *previous, cycle) @@ -461,6 +463,7 @@ fn analyze_enum_literal_union_pattern_predicate<'db>( /// re-analyze each pattern O(N) times (once per reference), leading to O(N²) total work. /// With memoization, each pattern is analyzed exactly once. #[salsa::tracked( + returns(copy), cycle_initial = |_, _, _| Truthiness::Ambiguous, heap_size = get_size2::GetSize::get_heap_size )] @@ -1250,6 +1253,7 @@ fn analyze_single_pattern_predicate_kind<'db>( /// Cycle recovery conservatively treats the call as returning so that a cyclic type inference /// dependency cannot make subsequent code unreachable. #[salsa::tracked( + returns(copy), cycle_initial = |_, _, _, _, _| Truthiness::AlwaysTrue, heap_size = get_size2::GetSize::get_heap_size )] diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 4755e83167..a6964654ce 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -398,7 +398,7 @@ pub(crate) struct VisitSpecialization; /// Similarly, there is `Bottom[list[Any]]`. /// This type is harder to make sense of in a set-theoretic framework, but /// it is a subtype of all materializations of `list[Any]`. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum MaterializationKind { Top, Bottom, @@ -419,7 +419,7 @@ impl MaterializationKind { /// define a `__get__` method, while data descriptors additionally define a `__set__` /// method or a `__delete__` method. This enum is used to categorize attributes into two /// groups: (1) data descriptors and (2) normal attributes or non-data descriptors. -#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum AttributeKind { DataDescriptor, NormalOrNonDataDescriptor, @@ -618,9 +618,13 @@ pub enum PropertyAccessorRole { /// Represents an instance of `builtins.property` or `enum.property`. #[salsa::interned(debug, constructor=new_internal, heap_size=ruff_memory_usage::heap_size)] pub struct PropertyInstanceType<'db> { + #[returns(copy)] pub getter: Option>, + #[returns(copy)] pub setter: Option>, + #[returns(copy)] pub deleter: Option>, + #[returns(copy)] instance_class: KnownClass, } @@ -849,6 +853,7 @@ impl From for DataclassFlags { /// dataclass-transformer decorator calls. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct DataclassParams<'db> { + #[returns(copy)] flags: DataclassFlags, #[returns(deref)] @@ -900,7 +905,7 @@ impl<'db> DataclassParams<'db> { /// Representation of a type: a set of possible values at runtime. /// -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum Type<'db> { /// The dynamic type: a statically unknown set of values Dynamic(DynamicType<'db>), @@ -1456,6 +1461,7 @@ impl<'db> Type<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, materialization_kind| { Type::Divergent(DivergentType::new(id).materialized(materialization_kind)) }, @@ -2694,7 +2700,7 @@ impl<'db> Type<'db> { } fn lookup_dunder_new(self, db: &'db dyn Db) -> Option> { - #[salsa::tracked(cycle_initial=|_, _, _, ()| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, ()| None, heap_size=ruff_memory_usage::heap_size)] fn lookup_dunder_new_inner<'db>( db: &'db dyn Db, ty: Type<'db>, @@ -2720,6 +2726,7 @@ impl<'db> Type<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _, _| Place::bound(Type::divergent(id)).into(), cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _, _, _| { member.cycle_normalized(db, *previous, cycle) @@ -3214,7 +3221,7 @@ impl<'db> Type<'db> { instance: Option>, owner: Type<'db>, ) -> Option<(Type<'db>, AttributeKind)> { - #[salsa::tracked(cycle_initial=|_, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] fn try_call_dunder_get_inner<'db>( db: &'db dyn Db, ty: Type<'db>, @@ -3528,6 +3535,7 @@ impl<'db> Type<'db> { // Recursive aliases use `true`, the identity for the all-of classifications above. #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _, ()| true, heap_size=ruff_memory_usage::heap_size )] @@ -3551,6 +3559,7 @@ impl<'db> Type<'db> { // Definite data descriptors use an all-of union fold; possible data descriptors use any-of. // Seed recursive aliases with the corresponding identity value. #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _, any_of_union: bool| !any_of_union, heap_size=ruff_memory_usage::heap_size )] @@ -3769,6 +3778,7 @@ impl<'db> Type<'db> { receiver: Option>, ) -> PlaceAndQualifiers<'db> { #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _, _, _| Place::bound(Type::divergent(id)).into(), cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _, _, _, _| { member.cycle_normalized(db, *previous, cycle) @@ -6377,6 +6387,7 @@ impl<'db> Type<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _, _| { value.cycle_normalized(db, *previous, cycle) @@ -7041,6 +7052,7 @@ impl<'db> Type<'db> { #[allow(clippy::used_underscore_binding)] #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, ()| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _, ()| { value.cycle_normalized(db, *previous, cycle) @@ -7875,7 +7887,7 @@ impl<'db> TypeMapping<'_, 'db> { /// (e.g. `Divergent` is assignable to `@Todo`, but `@Todo | Divergent` must not be reduced to `@Todo`). /// Otherwise, type inference cannot converge properly. /// For detailed properties of this type, see the unit test at the end of the file. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DivergentType { /// The query ID that caused the cycle. id: salsa::Id, @@ -7911,7 +7923,7 @@ impl DivergentType { } } -#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum DynamicType<'db> { /// An explicitly annotated `typing.Any` Any, @@ -7988,7 +8000,7 @@ impl std::fmt::Display for DynamicType<'_> { bitflags! { /// Type qualifiers that appear in an annotation expression. - #[derive(Copy, Clone, Debug, Eq, PartialEq, Default, salsa::Update, Hash)] + #[derive(Copy, Clone, Debug, Eq, PartialEq, Default, Hash)] pub struct TypeQualifiers: u8 { /// `typing.ClassVar` const CLASS_VAR = 1 << 0; @@ -8054,7 +8066,7 @@ impl TypeQualifiers { /// /// Example: `Annotated[ClassVar[tuple[int]], "metadata"]` would have type `tuple[int]` and the /// qualifier `ClassVar`. -#[derive(Clone, Debug, Copy, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Copy, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct TypeAndQualifiers<'db> { inner: Type<'db>, origin: TypeOrigin, @@ -8126,7 +8138,7 @@ impl<'db> TypeAndQualifiers<'db> { /// Error struct providing information on type(s) that were deemed to be invalid /// in a type expression context, and the type we should therefore fallback to /// for the problematic type expression. -#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct InvalidTypeExpressionError<'db> { fallback_type: Type<'db>, invalid_expressions: smallvec::SmallVec<[InvalidTypeExpression<'db>; 1]>, @@ -8155,7 +8167,7 @@ impl<'db> InvalidTypeExpressionError<'db> { } /// Enumeration of various types that are invalid in type-expression contexts -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum InvalidTypeExpression<'db> { /// Some types always require exactly one argument when used in a type expression RequiresOneArgument(SpecialFormType), @@ -8509,6 +8521,7 @@ impl<'db> AwaitError<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct ModuleLiteralType<'db> { /// The imported module. + #[returns(copy)] pub module: Module<'db>, /// The file in which this module was imported. @@ -8521,6 +8534,7 @@ pub struct ModuleLiteralType<'db> { /// single-file modules), and ensures that two module-literal types that both refer to /// the same underlying single-file module are understood by ty as being equivalent types /// in all situations. + #[returns(copy)] _importing_file: Option, } @@ -8687,14 +8701,14 @@ impl<'db> ModuleLiteralType<'db> { } /// Either the explicit `metaclass=` keyword of the class, or the inferred metaclass of one of its base classes. -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct MetaclassCandidate<'db> { metaclass: ClassType<'db>, explicit_metaclass_of: StaticClassLiteral<'db>, } /// Information about a `@dataclass_transform`-decorated metaclass. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct MetaclassTransformInfo<'db> { pub(super) params: DataclassTransformerParams<'db>, @@ -8705,9 +8719,11 @@ pub(super) struct MetaclassTransformInfo<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct TypeIsType<'db> { + #[returns(copy)] type_argument: Type<'db>, /// The ID of the scope to which the place belongs /// and the ID of the place itself within that scope. + #[returns(copy)] place_info: Option<(ScopeId<'db>, ScopedPlaceId)>, } @@ -8786,9 +8802,11 @@ impl<'db> VarianceInferable<'db> for TypeIsType<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct TypeGuardType<'db> { + #[returns(copy)] return_type: Type<'db>, /// The ID of the scope to which the place belongs /// and the ID of the place itself within that scope. + #[returns(copy)] place_info: Option<(ScopeId<'db>, ScopedPlaceId)>, } diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs index 5be4c6c4c9..43eed5e91c 100644 --- a/crates/ty_python_semantic/src/types/bound_super.rs +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -222,7 +222,7 @@ impl<'db> BoundSuperError<'db> { } } -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] enum DescriptorReceiverKind { /// Bind descriptors as if `super()` were owned by a class object, i.e. via /// `__get__(None, owner)`. @@ -232,7 +232,7 @@ enum DescriptorReceiverKind { Instance, } -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub struct ResolvedSuperOwner<'db> { /// The resolved second `super()` argument, used when binding descriptors after /// attribute lookup. If `receiver` is [`DescriptorReceiverKind::Instance`], this @@ -286,7 +286,7 @@ impl<'db> ResolvedSuperOwner<'db> { } } -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum SuperOwnerKind<'db> { Dynamic(DynamicType<'db>), Divergent(DivergentType), @@ -345,7 +345,9 @@ impl<'db> SuperOwnerKind<'db> { /// Represent a bound super object like `super(PivotClass, owner)` #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct BoundSuperType<'db> { + #[returns(copy)] pub pivot_class: ClassBase<'db>, + #[returns(copy)] pub owner: SuperOwnerKind<'db>, } diff --git a/crates/ty_python_semantic/src/types/call.rs b/crates/ty_python_semantic/src/types/call.rs index de63c508bc..1e0d14e04b 100644 --- a/crates/ty_python_semantic/src/types/call.rs +++ b/crates/ty_python_semantic/src/types/call.rs @@ -97,7 +97,7 @@ impl<'db> Type<'db> { op: ast::Operator, right_ty: Type<'db>, ) -> Option> { - #[salsa::tracked(cycle_initial=|_, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] fn try_call_bin_op_return_type_impl<'db>( db: &'db dyn Db, left_ty: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index cc114e168e..5e0d3107c5 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -416,6 +416,7 @@ pub struct CallableType<'db> { #[returns(ref)] pub(crate) signatures: CallableSignature<'db>, + #[returns(copy)] pub(super) kind: CallableTypeKind, /// Source-function return-annotation provenance retained by this callable. @@ -430,6 +431,7 @@ pub struct CallableType<'db> { /// ```python /// def decorator_factory() -> Callable[[type[object]], object]: ... /// ``` + #[returns(copy)] pub(crate) provenance: CallableFunctionProvenance, } @@ -662,7 +664,7 @@ impl<'db> CallableType<'db> { /// /// Note that this type is guaranteed to contain at least one callable. If you need to support "no /// callables" as a possibility, use `Option`. -#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct CallableTypes<'db>(SmallVec<[CallableType<'db>; 1]>); impl<'db> CallableTypes<'db> { diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 2c4dd6547f..68c394a13e 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -71,7 +71,7 @@ bitflags::bitflags! { /// /// This combines properties derived from the MRO into the existing class-classification /// query, avoiding a separate cached query for each property. - #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash, salsa::Update)] + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] pub(super) struct ClassInstanceFlags: u8 { /// The class is, or inherits from, a `TypedDict` specification. const TYPED_DICT = 1 << 0; @@ -83,7 +83,7 @@ bitflags::bitflags! { impl get_size2::GetSize for ClassInstanceFlags {} /// A category of classes with code generation capabilities (with synthesized methods). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum CodeGeneratorKind<'db> { /// Classes decorated with `@dataclass` or similar dataclass-like decorators DataclassLike(Option>), @@ -120,7 +120,9 @@ impl<'db> CodeGeneratorKind<'db> { return None; } - #[salsa::tracked(cycle_initial=|_, _, _| None, + #[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size )] fn code_generator_of_static_class<'db>( @@ -197,7 +199,9 @@ impl<'db> CodeGeneratorKind<'db> { } fn from_dynamic_class(db: &'db dyn Db, class: DynamicClassLiteral<'db>) -> Option { - #[salsa::tracked(cycle_initial=|_, _, _| None, + #[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size )] fn code_generator_of_dynamic_class<'db>( @@ -309,7 +313,9 @@ impl<'db> CodeGeneratorKind<'db> { /// A specialization of a generic class with a particular assignment of types to typevars. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct GenericAlias<'db> { + #[returns(copy)] pub(crate) origin: StaticClassLiteral<'db>, + #[returns(copy)] pub(crate) specialization: Specialization<'db>, } @@ -409,6 +415,7 @@ impl<'db> From> for Type<'db> { #[salsa::tracked] impl<'db> VarianceInferable<'db> for GenericAlias<'db> { #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size )] @@ -450,7 +457,7 @@ impl<'db> VarianceInferable<'db> for GenericAlias<'db> { /// A class literal, either defined via a `class` statement or a `type` function call. #[derive( - Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update, get_size2::GetSize, + Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, get_size2::GetSize, salsa::SalsaValue, )] pub enum ClassLiteral<'db> { /// A class defined via a `class` statement. @@ -993,7 +1000,7 @@ impl<'db> From> for ClassLiteral<'db> { /// Represents a class type, which might be a non-generic class, or a specialization of a generic /// class. #[derive( - Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update, get_size2::GetSize, + Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Supertype, get_size2::GetSize, salsa::SalsaValue, )] pub enum ClassType<'db> { // `NonGeneric` is intended to mean that the `ClassLiteral` has no type parameters. There are @@ -1392,6 +1399,7 @@ impl<'db> ClassType<'db> { /// /// Returns `None` if this class does not have any disjoint bases in its MRO. #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size )] @@ -2372,7 +2380,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct AbstractMethod<'db> { pub(super) defining_class: ClassType<'db>, pub(super) definition: Definition<'db>, @@ -2413,7 +2421,7 @@ impl MethodDecorator { } /// Kind-specific metadata for different types of fields -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum FieldKind<'db> { /// `NamedTuple` field metadata NamedTuple { default_ty: Option> }, @@ -2456,7 +2464,7 @@ pub(crate) enum FieldKind<'db> { } /// Metadata regarding a dataclass field/attribute or a `TypedDict` "item" / key-value pair. -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct Field<'db> { /// The declared type of the field pub(crate) declared_ty: Type<'db>, @@ -2838,7 +2846,7 @@ impl std::fmt::Display for QualifiedClassName<'_> { /// `TypeError`s resulting from class definitions. /// /// [PEP 800]: https://peps.python.org/pep-0800/ -#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, get_size2::GetSize, salsa::Update)] +#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct DisjointBase<'db> { pub(super) class: ClassLiteral<'db>, pub(super) kind: DisjointBaseKind, @@ -2879,7 +2887,7 @@ impl<'db> DisjointBase<'db> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) enum DisjointBaseKind { /// We know the class is a disjoint base because it's either hardcoded in ty /// or has the `@disjoint_base` decorator. @@ -2888,7 +2896,7 @@ pub(super) enum DisjointBaseKind { DefinesSlots, } -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct MetaclassError<'db> { kind: MetaclassErrorKind<'db>, } @@ -2900,7 +2908,7 @@ impl<'db> MetaclassError<'db> { } } -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) enum MetaclassErrorKind<'db> { /// The class has incompatible metaclasses in its inheritance hierarchy. /// diff --git a/crates/ty_python_semantic/src/types/class/dynamic_literal.rs b/crates/ty_python_semantic/src/types/class/dynamic_literal.rs index 51e8e4e9c2..809ceec851 100644 --- a/crates/ty_python_semantic/src/types/class/dynamic_literal.rs +++ b/crates/ty_python_semantic/src/types/class/dynamic_literal.rs @@ -72,10 +72,12 @@ pub struct DynamicClassLiteral<'db> { /// Whether the namespace is dynamic (not a literal dict, or contains /// non-string-literal keys). When true, attribute lookups on this class /// and its instances return `Unknown` instead of failing. + #[returns(copy)] pub has_dynamic_namespace: bool, /// Dataclass parameters if this class has been wrapped with `@dataclass` decorator /// or passed to `dataclass()` as a function. + #[returns(copy)] pub dataclass_params: Option>, } @@ -84,7 +86,7 @@ pub struct DynamicClassLiteral<'db> { /// This enum provides stable identity for `DynamicClassLiteral`: /// - For assigned calls, the `Definition` uniquely identifies the class. /// - For dangling calls, a relative offset provides stable identity. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum DynamicClassAnchor<'db> { /// The call is assigned to a variable. /// diff --git a/crates/ty_python_semantic/src/types/class/enum_literal.rs b/crates/ty_python_semantic/src/types/class/enum_literal.rs index 7016238918..663dd7ef62 100644 --- a/crates/ty_python_semantic/src/types/class/enum_literal.rs +++ b/crates/ty_python_semantic/src/types/class/enum_literal.rs @@ -20,6 +20,7 @@ use ty_python_core::scope::ScopeId; pub struct EnumSpec<'db> { #[returns(deref)] pub(crate) members: Box<[(Name, Type<'db>)]>, + #[returns(copy)] pub(crate) has_known_members: bool, } @@ -51,7 +52,7 @@ impl get_size2::GetSize for EnumSpec<'_> {} /// This mirrors the dynamic `TypedDict` / `NamedTuple` pattern: /// - assigned calls use the `Definition` as stable identity; /// - dangling calls use a relative offset within the enclosing scope. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum DynamicEnumAnchor<'db> { Definition { definition: Definition<'db>, @@ -96,7 +97,9 @@ pub struct DynamicEnumLiteral<'db> { pub name: Name, #[returns(ref)] pub anchor: DynamicEnumAnchor<'db>, + #[returns(copy)] pub base_class: KnownClass, + #[returns(copy)] pub mixin_type: Option>, } diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 406c3793d7..fbdc1b2038 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -1127,10 +1127,11 @@ impl KnownClass { ) -> Result>, KnownClassLookupError<'_>> { #[salsa::interned(heap_size=ruff_memory_usage::heap_size)] struct KnownClassArgument { + #[returns(copy)] class: KnownClass, } - #[salsa::tracked(cycle_initial=|_, _, _| Ok(None), heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| Ok(None), heap_size=ruff_memory_usage::heap_size)] fn known_class_to_class_literal<'db>( db: &'db dyn Db, class: KnownClassArgument<'db>, @@ -1974,7 +1975,7 @@ impl KnownClass { } /// Enumeration of ways in which looking up a [`KnownClass`] in its canonical module could fail. -#[derive(Debug, Clone, Copy, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum KnownClassLookupError<'db> { /// There is no symbol by that name in the expected module. ClassNotFound { third_party: bool }, diff --git a/crates/ty_python_semantic/src/types/class/named_tuple.rs b/crates/ty_python_semantic/src/types/class/named_tuple.rs index 86de4714bb..44a840fce7 100644 --- a/crates/ty_python_semantic/src/types/class/named_tuple.rs +++ b/crates/ty_python_semantic/src/types/class/named_tuple.rs @@ -121,7 +121,7 @@ pub(super) fn synthesize_namedtuple_class_member<'db>( } } -#[derive(Debug, salsa::Update, get_size2::GetSize, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, get_size2::GetSize, Clone, PartialEq, Eq, Hash, salsa::SalsaValue)] pub struct NamedTupleField<'db> { pub(crate) name: Name, pub(crate) ty: Type<'db>, @@ -438,6 +438,7 @@ impl<'db> DynamicNamedTupleLiteral<'db> { fn spec(self, db: &'db dyn Db) -> NamedTupleSpec<'db> { #[salsa::tracked( + returns(copy), cycle_initial=|db, _, _| NamedTupleSpec::unknown(db), heap_size=ruff_memory_usage::heap_size )] @@ -481,7 +482,7 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// This enum provides stable identity for `DynamicNamedTupleLiteral` instances: /// - For assigned calls, the `Definition` uniquely identifies the class. /// - For dangling calls, a relative offset provides stable identity. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum DynamicNamedTupleAnchor<'db> { /// We're dealing with a `collections.namedtuple()` call /// that's assigned to a variable. @@ -566,6 +567,7 @@ pub struct NamedTupleSpec<'db> { #[returns(deref)] pub(crate) fields: Box<[NamedTupleField<'db>]>, + #[returns(copy)] pub(crate) has_known_fields: bool, } diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 35ecc5173f..2009f681bd 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -78,31 +78,42 @@ pub struct StaticClassLiteral<'db> { #[returns(ref)] pub(crate) name: Name, + #[returns(copy)] pub(crate) body_scope: ScopeId<'db>, + #[returns(copy)] pub(crate) known: Option, /// If this class is deprecated, this holds the deprecation message. + #[returns(copy)] pub(crate) deprecated: Option>, + #[returns(copy)] pub(crate) type_check_only: bool, + #[returns(copy)] pub(crate) dataclass_params: Option>, + #[returns(copy)] pub(crate) dataclass_transformer_params: Option>, /// Whether this class is decorated with `@functools.total_ordering` + #[returns(copy)] pub(crate) total_ordering: bool, /// Whether this class has any decorators. + #[returns(copy)] pub(crate) has_decorators: bool, /// Whether this class has PEP 695 type parameters. + #[returns(copy)] pub(crate) has_type_params: bool, /// Whether this class has any explicit base classes. + #[returns(copy)] pub(crate) has_explicit_bases: bool, /// Whether this class has an explicit `metaclass` keyword argument. + #[returns(copy)] pub(crate) has_explicit_metaclass: bool, } @@ -179,7 +190,7 @@ impl<'db> StaticClassLiteral<'db> { /// Returns `true` if this class defines any ordering method (`__lt__`, `__le__`, `__gt__`, /// `__ge__`) in its own body (not inherited). Used by `@total_ordering` to determine if /// synthesis is valid. - #[salsa::tracked] + #[salsa::tracked(returns(copy))] pub(crate) fn has_own_ordering_method(self, db: &'db dyn Db) -> bool { let body_scope = self.body_scope(db); ["__lt__", "__le__", "__gt__", "__ge__"] @@ -187,7 +198,7 @@ impl<'db> StaticClassLiteral<'db> { .any(|method| !class_member(db, body_scope, method).is_undefined()) } - #[salsa::tracked] + #[salsa::tracked(returns(copy))] pub(crate) fn has_own_comparison_methods(self, db: &'db dyn Db) -> bool { let body_scope = self.body_scope(db); ["__lt__", "__le__", "__gt__", "__ge__"] @@ -286,6 +297,7 @@ impl<'db> StaticClassLiteral<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size, )] @@ -316,6 +328,7 @@ impl<'db> StaticClassLiteral<'db> { db: &'db dyn Db, ) -> Option> { #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size, )] @@ -703,6 +716,7 @@ impl<'db> StaticClassLiteral<'db> { /// Return the properties that affect how instances of this class are represented. pub(super) fn instance_flags(self, db: &'db dyn Db) -> ClassInstanceFlags { #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| ClassInstanceFlags::empty(), heap_size=ruff_memory_usage::heap_size, )] @@ -737,7 +751,7 @@ impl<'db> StaticClassLiteral<'db> { } /// Return the module defining the `TypedDict` base of this class. - #[salsa::tracked(cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn typed_dict_module(self, db: &'db dyn Db) -> Option { self.iter_mro(db, None) .find_map(ClassBase::typed_dict_module) @@ -933,6 +947,7 @@ impl<'db> StaticClassLiteral<'db> { db: &'db dyn Db, ) -> Result<(Type<'db>, Option>), MetaclassError<'db>> { #[salsa::tracked( + returns(clone), cycle_initial=|_, _, _| Err(MetaclassError { kind: MetaclassErrorKind::Cycle, }), @@ -2359,6 +2374,7 @@ impl<'db> StaticClassLiteral<'db> { } #[salsa::tracked( + returns(copy), cycle_fn=implicit_attribute_cycle_recover, cycle_initial=|_, id, _| Member { inner: Place::bound(Type::divergent(id)).into(), @@ -2940,7 +2956,7 @@ impl<'db> StaticClassLiteral<'db> { /// A class definition like this will fail at runtime, /// but we must be resilient to it or we could panic. pub(crate) fn inheritance_cycle(self, db: &'db dyn Db) -> Option { - #[salsa::tracked(cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] fn inheritance_cycle_inner<'db>( db: &'db dyn Db, class: StaticClassLiteral<'db>, @@ -3146,7 +3162,7 @@ fn expanded_fixed_length_starred_class_base_tuple<'db>( #[salsa::tracked] impl<'db> VarianceInferable<'db> for StaticClassLiteral<'db> { - #[salsa::tracked(cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size)] fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>) -> TypeVarVariance { let typevar_in_generic_context = self .generic_context(db) @@ -3337,9 +3353,11 @@ fn explicit_bases_cycle_fn<'db>( #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] struct ImplicitAttributeName<'db> { + #[returns(copy)] class_body_scope: ScopeId<'db>, #[returns(deref)] name: CompactString, + #[returns(copy)] target_method_decorator: MethodDecorator, } diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs index c24b318069..3f9a08193b 100644 --- a/crates/ty_python_semantic/src/types/class/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -767,7 +767,7 @@ fn synthesize_typed_dict_merge<'db>( /// The type of `Movie` would be `type[Movie]` where `Movie` is a `DynamicTypedDictLiteral`. /// /// The field schema is represented by a separate [`TypedDictSchema`]. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum DynamicTypedDictAnchor<'db> { /// The `TypedDict()` call is assigned to a variable. /// @@ -828,6 +828,7 @@ pub struct DynamicTypedDictLiteral<'db> { #[returns(ref)] pub(crate) anchor: DynamicTypedDictAnchor<'db>, + #[returns(copy)] pub(crate) typed_dict_module: TypedDictModule, } diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 8d16deb127..7c0d1cd1a4 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -17,7 +17,7 @@ use crate::{Db, DisplaySettings}; /// Note that a non-specialized generic class _cannot_ be a class base. When we see a /// non-specialized generic class in any type expression (including the list of base classes), we /// automatically construct the default specialization for that class. -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum ClassBase<'db> { /// The `Any` special form used directly as a base class. /// diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 321b1e8eb3..fa48dbc140 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -239,13 +239,13 @@ where /// Note that you cannot interrogate an owned constraint set directly. Instead, use /// [`query`][OwnedConstraintSet::query] to query it in a builder with matching arenas, or /// [`load`][ConstraintSetBuilder::load] to remap it into an existing builder. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct OwnedConstraintSet<'db> { node: NodeId, inner: Option>>, } -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] struct OwnedConstraintSetInner<'db> { constraints: Box<[Constraint<'db>]>, constraint_indices: RankBitBox, @@ -1125,17 +1125,15 @@ impl IntersectionResult<'_> { /// The index of a bound typevar within a [`ConstraintSetStorage`]. #[newtype_index] -#[derive(Ord, PartialOrd, salsa::Update, get_size2::GetSize)] +#[derive(Ord, PartialOrd, get_size2::GetSize)] pub struct TypeVarId; /// The index of an individual constraint (i.e. a BDD variable) within a [`ConstraintSetStorage`]. #[newtype_index] -#[derive(salsa::Update, get_size2::GetSize)] +#[derive(get_size2::GetSize)] pub struct ConstraintId; -#[derive( - Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, salsa::Update, get_size2::GetSize, -)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, get_size2::GetSize)] enum NestedSubstitutionSide { Lower, Upper, @@ -1148,9 +1146,7 @@ enum NestedSubstitutionSide { /// _for_, and the side. Each derived path assignment records the substitution shapes in its own /// derivation history. This lets independent derivations apply the same substitution while /// preventing one derivation chain from repeatedly unfolding the same recursive pattern. -#[derive( - Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, salsa::Update, get_size2::GetSize, -)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, get_size2::GetSize)] struct NestedSubstitution { constrained_typevar: TypeVarId, substituted_typevar: TypeVarId, @@ -1205,7 +1201,7 @@ impl NestedSubstitutionHistory { /// A constraint derived from the sequent map, optionally annotated with the nested substitution /// step that produced it. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] struct DerivedConstraint { constraint: ConstraintId, nested_substitution: Option, @@ -1227,7 +1223,7 @@ fn nested_substitution<'db>( /// An individual constraint in a constraint set. This restricts a single typevar to be within a /// lower and upper bound. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct Constraint<'db> { pub(crate) typevar: BoundTypeVarInstance<'db>, pub(crate) bounds: ConstraintBounds<'db>, @@ -1238,7 +1234,7 @@ pub(crate) struct Constraint<'db> { /// Missing bounds are represented as `None`; callers can materialize them to the logical defaults /// (`Never` for lower bounds, `object` for upper bounds) when they need to reason about /// satisfiability. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct ConstraintBounds<'db> { pub(crate) lower: Option>, pub(crate) upper: Option>, @@ -1283,7 +1279,7 @@ impl<'db> ConstraintBounds<'db> { /// As an optimization, we will remove redundant clauses as we build up an `UpperBound`. This /// reduces the amount of work `IntersectionBuilder` needs to do when producing the solution for /// this upper bound. -#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct UpperBound<'db> { clauses: FxOrderSet>, } @@ -1854,7 +1850,7 @@ impl ConstraintId { /// cannot use this ordering as our BDD variable ordering, since we calculate it from already /// constructed BDDs, and we need the BDD variable ordering to be fixed and available before /// construction starts.) -#[derive(Clone, Copy, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Eq, Hash, PartialEq, get_size2::GetSize)] struct NodeId(u32); /// A special ID that is used for an "always true" / "always visible" constraint. @@ -3311,7 +3307,7 @@ impl Idx for NodeId { struct InteriorNode(NodeId); /// An interior node of a BDD -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] struct InteriorNodeData { constraint: ConstraintId, if_true: NodeId, @@ -3390,7 +3386,7 @@ impl<'db> ConstraintBoundsBuilder<'db> { } /// The explicit lower and upper bounds inferred for one typevar on one BDD path. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct PathBound<'db> { pub(crate) bound_typevar: BoundTypeVarInstance<'db>, pub(crate) lower: Option>, @@ -3460,6 +3456,7 @@ impl<'db> Type<'db> { } #[salsa::tracked( + returns(copy), cycle_initial = |_, _, _, _| true, heap_size = get_size2::GetSize::get_heap_size )] @@ -3474,7 +3471,7 @@ fn is_possibly_constraint_set_assignable<'db>( } /// Per-path bounds for all typevars. Each element is the set of typevar bounds for one BDD path. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum PathBounds<'db> { Unsatisfiable, Unconstrained, diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index fcfe86b2f0..e053398836 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -22,6 +22,7 @@ use crate::{Db, SemanticModel}; pub(crate) struct ModelMetadata<'db> { #[returns(deref)] pub(in crate::types) field_specifiers: Box<[Type<'db>]>, + #[returns(copy)] config: ModelConfig, } @@ -316,7 +317,7 @@ pub(in crate::types) fn field_metadata<'db>( metadata } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize)] pub(crate) struct ModelConfig { /// The `extra` configuration controls whether the synthesized constructor accepts keyword /// arguments that do not correspond to declared model fields. @@ -353,7 +354,7 @@ impl ModelConfig { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] enum ExtraBehavior { Allow, Forbid, @@ -372,7 +373,7 @@ impl ExtraBehavior { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum ConfigBoolean { /// No value was specified at this precedence level, so a lower-precedence value can apply. #[default] @@ -458,7 +459,7 @@ pub(in crate::types) fn constructor_fields_are_keyword_only( !is_root_model(db, class) } -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn is_root_model<'db>(db: &'db dyn Db, class: StaticClassLiteral<'db>) -> bool { class .iter_mro(db, None) @@ -481,6 +482,7 @@ pub(in crate::types) fn constructor_fields_are_optional( } #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| ModelConfig::unknown(), heap_size=ruff_memory_usage::heap_size, )] diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index a317d2fbff..44cb56e0cc 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -29,7 +29,7 @@ use ty_python_core::{definition::DefinitionKind, place_table, scope::ScopeId, us /// Standard-library methods and user-defined methods are both callable functions, but callers need /// to distinguish them: standard-library methods have modeled behavior, while user-defined or /// opaque methods may replace the member value arbitrarily. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, salsa::Update)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, salsa::SalsaValue)] pub(super) enum ResolvedEnumMethod<'db> { #[default] Absent, @@ -50,7 +50,7 @@ pub(super) enum ResolvedEnumMethod<'db> { /// /// User-defined data types are excluded because their construction, attribute access, equality, and /// hashing semantics can differ from the built-in scalar later in their MRO. -#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::Update)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum KnownEnumDataTypeMixin { Int, Str, @@ -110,7 +110,7 @@ impl<'db> ResolvedEnumMethod<'db> { /// Different consumers require different levels of conservatism. Value inference trusts known /// standard-library data types but treats user-defined data types and constructors as possible /// transformations, while alias detection follows the value captured before `__init__`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, salsa::Update)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, salsa::SalsaValue)] pub(super) struct EnumValueConstruction<'db> { pub(super) init: ResolvedEnumMethod<'db>, pub(super) new: ResolvedEnumMethod<'db>, @@ -205,7 +205,7 @@ impl<'db> EnumValueConstruction<'db> { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::Update)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::SalsaValue)] enum EnumValueAnnotation<'db> { /// An annotation declared on this enum or a user-defined parent enum. UserDefined(Type<'db>), @@ -221,7 +221,7 @@ impl<'db> EnumValueAnnotation<'db> { } } -#[derive(Debug, PartialEq, Eq, salsa::Update)] +#[derive(Debug, PartialEq, Eq, salsa::SalsaValue)] pub(crate) struct EnumMetadata<'db> { pub(crate) members: FxIndexMap>, pub(crate) aliases: FxHashMap, @@ -280,18 +280,21 @@ pub(super) fn class_defines_property<'db>( /// the underlying class literal. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct EnumClassLiteral<'db> { + #[returns(copy)] pub(crate) class_literal: ClassLiteral<'db>, #[returns(ref)] pub(crate) members: Box<[(Name, Type<'db>)]>, #[returns(ref)] pub(crate) aliases: Box<[(Name, Name)]>, /// Whether the canonical member and alias sets are known exactly. + #[returns(copy)] pub(super) aliases_are_known: bool, /// Whether the canonical members exhaust the runtime values of this enum class. /// /// `Flag` classes, transforming metaclasses, and enums with a custom `_missing_` method can /// create runtime members beyond those declared in the class body, so their declared members /// are not a closed value set. + #[returns(copy)] pub(crate) members_are_exhaustive: bool, } @@ -304,7 +307,7 @@ impl<'db> ClassLiteral<'db> { } } -#[salsa::tracked(cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] fn enum_class_literal<'db>( db: &'db dyn Db, class: ClassLiteral<'db>, @@ -639,6 +642,7 @@ impl<'db> EnumMetadata<'db> { /// ``` #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct EnumComplementType<'db> { + #[returns(copy)] pub(crate) enum_class_literal: EnumClassLiteral<'db>, /// Canonical enum-member names excluded by this complement. #[returns(ref)] @@ -1321,7 +1325,7 @@ fn inherited_user_defined_value_annotation<'db>( .find_map(|base| custom_value_annotation(db, base.body_scope(db))) } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, salsa::Update)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum InheritedEnumDataType { #[default] None, diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index d22e85a1ae..d37b4a8b26 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -1332,7 +1332,7 @@ impl ComparisonOperator { /// /// Two types with different known semantics cannot compare equal. Types with custom or otherwise /// unknown comparison methods are not assigned a value of this enum. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] enum KnownComparisonSemantics { Object, Int, diff --git a/crates/ty_python_semantic/src/types/equality/enums.rs b/crates/ty_python_semantic/src/types/equality/enums.rs index ba43fbd945..39aafdb3b9 100644 --- a/crates/ty_python_semantic/src/types/equality/enums.rs +++ b/crates/ty_python_semantic/src/types/equality/enums.rs @@ -783,7 +783,7 @@ impl<'db> EnumValueSet<'db> { } } -#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct EnumClassKeyProfile<'db> { members_are_exhaustive: bool, semantics: Option, @@ -823,7 +823,7 @@ fn enum_class_key_profile<'db>( } /// Whether distinct declared members are known to have distinct runtime comparison keys. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] enum SameEnumComparisonKeys { /// Different member names cannot compare equal. Distinct, @@ -832,14 +832,14 @@ enum SameEnumComparisonKeys { } /// Same-class facts that are unnecessary when projecting keys across different classes. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, get_size2::GetSize)] struct SameEnumComparisonProfile { members_are_exhaustive: bool, members_compare_by_identity: bool, comparison_keys: Option, } -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn same_enum_comparison_profile<'db>( db: &'db dyn Db, enum_class: EnumClassLiteral<'db>, diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 18aae82c69..1965980781 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -211,7 +211,7 @@ bitflags! { /// arguments that were passed in. For the precise meaning of the fields, see [1]. /// /// [1]: https://docs.python.org/3/library/typing.html#typing.dataclass_transform - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DataclassTransformerFlags: u8 { const EQ_DEFAULT = 1 << 0; const ORDER_DEFAULT = 1 << 1; @@ -232,6 +232,7 @@ impl Default for DataclassTransformerFlags { /// instance that we use as the return type for `dataclass_transform(…)` calls. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct DataclassTransformerParams<'db> { + #[returns(copy)] pub flags: DataclassTransformerFlags, #[returns(deref)] @@ -262,22 +263,28 @@ pub struct OverloadLiteral<'db> { pub name: ast::name::Name, /// Is this a function that we special-case somehow? If so, which one? + #[returns(copy)] pub(crate) known: Option, /// The scope that's created by the function, in which the function body is evaluated. + #[returns(copy)] pub(crate) body_scope: ScopeId<'db>, /// A set of special decorators that were applied to this function + #[returns(copy)] pub(crate) decorators: FunctionDecorators, /// If `Some` then contains the `@warnings.deprecated` + #[returns(copy)] pub(crate) deprecated: Option>, /// The arguments to `dataclass_transformer`, if this function was annotated /// with `@dataclass_transformer(...)`. + #[returns(copy)] pub(crate) dataclass_transformer_params: Option>, /// Whether this overload or implementation has an explicit return annotation. + #[returns(copy)] pub(crate) has_explicit_return_annotation: bool, } @@ -717,7 +724,7 @@ impl<'db> OverloadLiteral<'db> { /// Representation of a function definition in the AST, along with any previous overloads of the /// function. Each overload can be separately generic or not, and each generic overload uses /// distinct typevars. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct FunctionLiteral<'db> { pub(crate) last_definition: OverloadLiteral<'db>, overloaded: bool, @@ -916,7 +923,7 @@ impl<'db> FunctionLiteral<'db> { /// For functions without an implementation (e.g., overloaded functions), /// returns [`FunctionBodyKind::Stub`]. fn body_kind(self, db: &'db dyn Db) -> FunctionBodyKind { - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn implementation_body_kind<'db>( db: &'db dyn Db, implementation: OverloadLiteral<'db>, @@ -971,7 +978,7 @@ pub(super) fn same_module_uncached_raw_signature<'db>( } /// Indicates whether a method is explicitly or implicitly abstract. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub(super) enum AbstractMethodKind { /// The method is explicitly marked as abstract using `@abstractmethod`. Explicit, @@ -997,7 +1004,7 @@ impl AbstractMethodKind { /// /// This uncommon payload is boxed so that ordinary function types only retain the literal and one /// optional pointer. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct UpdatedFunctionSignatures<'db> { /// Contains a potentially modified signature for this function literal, in case certain /// operations (like type mappings) have been applied to it. @@ -1030,6 +1037,7 @@ impl<'db> UpdatedFunctionSignatures<'db> { /// generic function. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct FunctionType<'db> { + #[returns(copy)] pub(crate) literal: FunctionLiteral<'db>, #[returns(ref)] @@ -1396,6 +1404,7 @@ impl<'db> FunctionType<'db> { /// This is tracked because signatures can contain recursive `TypeOf` references back to the /// function itself. Class and generic-alias variance use the same `Bivariant` cycle fallback. #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size, )] diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 7f9a7e6613..c301a90a2c 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -245,7 +245,7 @@ pub(crate) fn typing_self<'db>( /// Membership is keyed by [`BoundTypeVarIdentity`], including any freshness nonce. This lets a /// fresh generic-callable occurrence be inferable without making the surrounding source-level /// typevar inferable. -#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum InferableTypeVars<'db> { None, Some(InferableTypeVarsInner<'db>), @@ -291,7 +291,7 @@ impl<'db> BoundTypeVarInstance<'db> { #[salsa::tracked] impl<'db> InferableTypeVars<'db> { - #[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] pub(crate) fn merge(self, db: &'db dyn Db, other: Self) -> Self { match (self, other) { (InferableTypeVars::None, other) | (other, InferableTypeVars::None) => other, @@ -491,6 +491,7 @@ impl<'db> GenericContext<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| InferableTypeVars::None, heap_size=ruff_memory_usage::heap_size, )] @@ -1079,6 +1080,7 @@ impl<'db> GenericContext<'db> { /// the lexically containing context. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct Specialization<'db> { + #[returns(copy)] pub(crate) generic_context: GenericContext<'db>, #[returns(deref)] pub(crate) types: Box<[Type<'db>]>, @@ -1089,10 +1091,12 @@ pub struct Specialization<'db> { /// with `Some(MaterializationKind::Bottom)`. /// The `materialization_kind` field may be non-`None` only if the specialization contains /// dynamic types in invariant positions. + #[returns(copy)] pub(crate) materialization_kind: Option, /// For specializations of `tuple`, we also store more detailed information about the tuple's /// elements, above what the class's (single) typevar can represent. + #[returns(copy)] tuple_inner: Option>, } @@ -1987,6 +1991,7 @@ pub(crate) struct SpecializationBuilder<'db, 'c> { /// entry means the type variable was not solved and should be projected according to the use site. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub(crate) struct TypeVarInference<'db> { + #[returns(copy)] pub(crate) generic_context: GenericContext<'db>, #[returns(deref)] types: Box<[Option>]>, @@ -1998,7 +2003,7 @@ impl get_size2::GetSize for TypeVarInference<'_> {} impl<'db> TypeVarInference<'db> { /// Project this inference result into a closed specialization. pub(crate) fn specialization(self, db: &'db dyn Db) -> Specialization<'db> { - #[salsa::tracked] + #[salsa::tracked(returns(copy))] fn specialization_inner<'db>( db: &'db dyn Db, inference: TypeVarInference<'db>, diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 890b773f63..4ee85ba16b 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -78,7 +78,7 @@ mod tests; bitflags::bitflags! { /// Metadata for expressions inferred as type expressions. - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, salsa::Update)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub(crate) struct TypeExpressionFlags: u8 { /// The expression is syntactically an `Unpack[...]` type expression. const UNPACK = 1 << 0; @@ -87,7 +87,7 @@ bitflags::bitflags! { impl get_size2::GetSize for TypeExpressionFlags {} -#[derive(Debug, Clone, Copy, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] struct TypeAndRange<'db> { ty: Type<'db>, range: TextRange, @@ -206,7 +206,7 @@ pub(crate) fn function_known_decorator_flags<'db>( /// Unlike [`DefinitionInference`], this stores only decorator expression types and /// diagnostics, plus the expression-side state that needs to be merged back into /// function-definition inference. -#[derive(Debug, Eq, PartialEq, Default, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, Default, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct FunctionDecoratorInference<'db> { expression_types: FrozenMap>, bindings: Box<[(Definition<'db>, Type<'db>)]>, @@ -436,6 +436,7 @@ pub(crate) fn infer_expression_type<'db>( } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _| { result.cycle_normalized(db, *previous, cycle) @@ -506,7 +507,7 @@ fn infer_statement_types_impl<'db>( /// /// This is a Salsa supertype used as the input to `infer_expression_types` to avoid /// interning an `ExpressionWithContext` unnecessarily when no type context is provided. -#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update)] +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype)] pub(super) enum InferExpression<'db> { Bare(Expression<'db>), WithContext(ExpressionWithContext<'db>), @@ -514,7 +515,9 @@ pub(super) enum InferExpression<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub(super) struct ExpressionWithContext<'db> { + #[returns(copy)] expression: Expression<'db>, + #[returns(copy)] tcx: TypeContext<'db>, } @@ -543,7 +546,7 @@ impl<'db> InferExpression<'db> { } /// A `ScopeId` with an optional `TypeContext`. -#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype, salsa::Update)] +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, salsa::Supertype)] pub(super) enum InferScope<'db> { Bare(ScopeId<'db>), WithContext(ScopeWithContext<'db>), @@ -551,7 +554,9 @@ pub(super) enum InferScope<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub(super) struct ScopeWithContext<'db> { + #[returns(copy)] scope: ScopeId<'db>, + #[returns(copy)] tcx: TypeContext<'db>, } @@ -583,7 +588,9 @@ impl<'db> InferScope<'db> { /// /// Knowing the outer type context when inferring an expression can enable /// more precise inference results, aka "bidirectional type inference". -#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive( + Default, Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue, +)] pub(crate) struct TypeContext<'db> { pub(crate) annotation: Option>, } @@ -769,7 +776,7 @@ impl<'db> InferenceRegion<'db> { } /// The inferred types for a scope region. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct ScopeInference<'db> { /// The types of every expression in this region. expressions: FrozenValueMap>, @@ -778,7 +785,7 @@ pub(crate) struct ScopeInference<'db> { extra: Option>>, } -#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update, Default)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, Default, salsa::SalsaValue)] struct ScopeInferenceExtra<'db> { /// String annotations found in this region string_annotations: FrozenSet, @@ -900,7 +907,7 @@ impl<'db> ScopeInference<'db> { } /// The result of inferring a declaration recorded by the semantic index. -#[derive(Debug, Clone, Copy, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, get_size2::GetSize)] pub(crate) enum InferredDeclaration<'db> { /// A valid declaration with an inferred declared type. Declared(TypeAndQualifiers<'db>), @@ -921,7 +928,7 @@ impl<'db> InferredDeclaration<'db> { } /// The inferred types for a definition region. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct DefinitionInference<'db> { /// The types of every expression in this region. expressions: FrozenMap>, @@ -941,7 +948,7 @@ pub(crate) struct DefinitionInference<'db> { /// /// Almost all regions contain a single binding, optionally paired with a declaration for the same /// definition and type. Keep those cases inline instead of retaining separate allocations. -#[derive(Debug, Default, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Default, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] enum DefinitionTypes<'db> { #[default] Empty, @@ -954,7 +961,7 @@ enum DefinitionTypes<'db> { type DefinitionBinding<'db> = (Definition<'db>, Type<'db>); type DefinitionDeclaration<'db> = (Definition<'db>, TypeAndQualifiers<'db>); -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] struct OtherDefinitionTypes<'db> { bindings: Box<[DefinitionBinding<'db>]>, declarations: Box<[DefinitionDeclaration<'db>]>, @@ -1132,7 +1139,7 @@ impl<'db> DefinitionTypes<'db> { /// Compact representations for common combinations of extra definition inference data. /// `Other` stores uncommon combinations that require multiple fields. -#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] enum DefinitionInferenceExtra<'db> { /// Type qualifiers are the only extra data for most annotated definitions. Qualifiers(FrozenMap), @@ -1158,13 +1165,13 @@ enum DefinitionInferenceExtra<'db> { Other(Box>), } -#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] struct DeferredAndUndecorated<'db> { deferred: Box<[Definition<'db>]>, undecorated_type: Type<'db>, } -#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update, Default)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, Default, salsa::SalsaValue)] struct OtherDefinitionInferenceExtra<'db> { /// String annotations found in this region string_annotations: FrozenSet, @@ -1495,7 +1502,7 @@ impl<'db> DefinitionInference<'db> { } /// The inferred types for an expression region. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct ExpressionInference<'db> { /// The types of every expression in this region. expressions: FrozenMap>, @@ -1508,7 +1515,7 @@ pub(crate) struct ExpressionInference<'db> { } /// Extra data that only exists for few inferred expression regions. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize, Default)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, Default, salsa::SalsaValue)] struct ExpressionInferenceExtra<'db> { /// String annotations found in this region string_annotations: FrozenSet, @@ -1657,7 +1664,7 @@ impl<'db> StatementInference<'db> { } /// The inferred types for a statement region. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct StatementInferenceInner<'db> { /// The types of every expression in this region. expressions: FrozenMap>, @@ -1676,7 +1683,7 @@ pub(crate) struct StatementInferenceInner<'db> { extra: Option>>, } -#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::Update, Default)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, Default, salsa::SalsaValue)] struct StatementInferenceInnerExtra<'db> { /// String annotations found in this region string_annotations: FrozenSet, diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 3864689405..1393fed646 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -167,7 +167,7 @@ impl<'db> Type<'db> { } /// A type representing the set of runtime objects which are instances of a certain nominal class. -#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct NominalInstanceType<'db>( // Keep this field private, so that the only way of constructing `NominalInstanceType` instances // is through the `Type::instance` constructor function. @@ -646,6 +646,7 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// The class of a nominal instance whose MRO contains an explicit `Any` base. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] struct ExplicitAnyInstanceClass<'db> { + #[returns(copy)] class: ClassType<'db>, } @@ -656,7 +657,7 @@ impl get_size2::GetSize for ExplicitAnyInstanceClass<'_> {} /// /// Interning the uncommon explicit-`Any` case lets this type store the additional semantic bit /// without increasing the size of [`Type`]. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum NominalInstanceClass<'db> { Plain(ClassType<'db>), InheritsFromExplicitAny(ExplicitAnyInstanceClass<'db>), @@ -695,7 +696,7 @@ impl<'db> NominalInstanceClass<'db> { /// [`NominalInstanceType`] is split into several variants internally as a pure optimization to /// avoid having to materialize the [`ClassType`] for tuple instances where it would be unnecessary /// (this is somewhat expensive!). -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum NominalInstanceInner<'db> { /// An instance of `object`. /// @@ -738,7 +739,7 @@ impl<'db> VarianceInferable<'db> for NominalInstanceType<'db> { /// A `ProtocolInstanceType` represents the set of all possible runtime objects /// that conform to the interface described by a certain protocol. -#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct ProtocolInstanceType<'db> { pub(super) inner: Protocol<'db>, @@ -837,7 +838,7 @@ impl<'db> ProtocolInstanceType<'db> { /// Such a protocol is therefore an equivalent type to `object`, which would in fact be /// normalised to `object`. pub(super) fn is_equivalent_to_object(self, db: &'db dyn Db) -> bool { - #[salsa::tracked(cycle_initial=|_, _, _, ()| true, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, ()| true, heap_size=ruff_memory_usage::heap_size)] fn is_equivalent_to_object_inner<'db>( db: &'db dyn Db, protocol: ProtocolInstanceType<'db>, @@ -930,7 +931,7 @@ impl<'db> VarianceInferable<'db> for ProtocolInstanceType<'db> { /// An enumeration of the two kinds of protocol types: those that originate from a class /// definition in source code, and those that are synthesized from a set of members. -#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(super) enum Protocol<'db> { FromClass(ProtocolClass<'db>), Synthesized(SynthesizedProtocolType<'db>), @@ -988,7 +989,7 @@ mod synthesized_protocol { use ty_python_core::definition::Definition; /// A "synthesized" protocol type that is dissociated from a class definition in source code. - #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, salsa::Update, get_size2::GetSize)] + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(in crate::types) struct SynthesizedProtocolType<'db>(ProtocolInterface<'db>); impl<'db> SynthesizedProtocolType<'db> { diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index 0b5cc82a7e..295fb74d41 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -31,6 +31,7 @@ pub struct InternedConstraintSet<'db> { #[returns(ref)] pub(super) constraints: OwnedConstraintSet<'db>, + #[returns(copy)] pub(super) detailed_display: bool, } @@ -50,7 +51,9 @@ impl<'db> InternedConstraintSet<'db> { /// A salsa-interned payload for `functools.partial(...)` instances. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct FunctoolsPartialInstance<'db> { + #[returns(copy)] pub wrapped: InternedType<'db>, + #[returns(copy)] pub partial: CallableType<'db>, } @@ -68,7 +71,7 @@ impl get_size2::GetSize for FunctoolsPartialInstance<'_> {} /// are generally created by operations at runtime in some way, such as a type alias /// statement, a typevar definition, or an instance of `Generic[T]` in a class's /// bases list. -#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum KnownInstanceType<'db> { /// The type of `Protocol[T]`, `Protocol[U, S]`, etc -- usually only found in a class's bases list. /// @@ -462,6 +465,7 @@ impl<'db> KnownInstanceType<'db> { pub struct SentinelInstance<'db> { #[returns(ref)] pub name: Name, + #[returns(copy)] pub definition: Definition<'db>, } @@ -479,7 +483,7 @@ impl<'db> SentinelInstance<'db> { } /// Data regarding a `warnings.deprecated` or `typing_extensions.deprecated` decorator. -#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct DeprecatedInstance<'db> { /// The message for the deprecation pub(crate) message: Option>, @@ -491,12 +495,15 @@ pub struct DeprecatedInstance<'db> { pub struct FieldInstance<'db> { /// The type of the default value for this field. This is derived from the `default` or /// `default_factory` arguments to `dataclasses.field()`. + #[returns(copy)] pub default_type: Option>, /// Whether this field is part of the `__init__` signature, or not. + #[returns(copy)] pub init: bool, /// Whether or not this field can only be passed as a keyword argument to `__init__`. + #[returns(copy)] pub kw_only: Option, /// This name is used to provide an alternative parameter name in the synthesized `__init__` method. @@ -506,9 +513,11 @@ pub struct FieldInstance<'db> { /// The converter types for this field, if a `converter` argument was provided. /// The first element is the input type (first positional parameter), the second is the /// output type (return type of the converter callable). + #[returns(copy)] pub converter: Option<(Type<'db>, Type<'db>)>, /// The mode selected by Pydantic's `strict` argument. + #[returns(copy)] pub strict: ConfigBoolean, } @@ -760,6 +769,7 @@ impl<'db> FunctoolsPartialInstance<'db> { /// A salsa-interned `Type` #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct InternedType<'db> { + #[returns(copy)] pub(super) inner: Type<'db>, } diff --git a/crates/ty_python_semantic/src/types/literal.rs b/crates/ty_python_semantic/src/types/literal.rs index 92fee1e1db..f6a68ac107 100644 --- a/crates/ty_python_semantic/src/types/literal.rs +++ b/crates/ty_python_semantic/src/types/literal.rs @@ -10,14 +10,14 @@ use ty_python_core::definition::Definition; use ty_python_core::{place_table, use_def_map}; /// A literal value. See [`LiteralValueTypeKind`] for details. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct LiteralValueType<'db>(LiteralValueTypeInner<'db>); /// Each variant carries a [`LiteralFlags`] byte alongside its payload. /// Because the flags byte fits into the padding between the enum discriminant /// (1 byte) and the 4-byte-aligned payload, the overall size of this enum /// stays at 12 bytes, the same as the original flagless representation. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum LiteralValueTypeInner<'db> { Int(IntLiteralType, LiteralFlags), Bool(bool, LiteralFlags), @@ -32,7 +32,7 @@ bitflags! { /// /// Stored in each [`LiteralValueTypeInner`] variant, fitting into the /// discriminant's padding so that the enum size is unchanged. - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update)] + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] struct LiteralFlags: u8 { const PROMOTABLE = 1 << 0; const RECURSIVELY_DEFINED = 1 << 1; @@ -72,7 +72,7 @@ impl LiteralFlags { } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum LiteralValueTypeKind<'db> { /// An integer literal Int(IntLiteralType), @@ -298,7 +298,7 @@ impl<'db> From> for Type<'db> { // This type has the same alignment as `salsa::Id`, allowing `LiteralValueType` to use a smaller // discriminant. -#[derive(Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub(crate) struct IntLiteralType { high: u32, low: u32, @@ -387,6 +387,7 @@ impl<'db> BytesLiteralType<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct EnumLiteralType<'db> { /// The enum class this literal belongs to. + #[returns(copy)] pub(crate) enum_class_literal: EnumClassLiteral<'db>, /// The name of the enum member #[returns(ref)] diff --git a/crates/ty_python_semantic/src/types/member.rs b/crates/ty_python_semantic/src/types/member.rs index 6865a356a0..6f75d70b84 100644 --- a/crates/ty_python_semantic/src/types/member.rs +++ b/crates/ty_python_semantic/src/types/member.rs @@ -8,7 +8,7 @@ use ty_python_core::{place_table, scope::ScopeId, use_def_map}; /// The return type of certain member-lookup operations. Contains information /// about the type, type qualifiers, boundness/declaredness. -#[derive(Debug, Clone, Copy, PartialEq, Eq, salsa::Update, get_size2::GetSize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, Default, salsa::SalsaValue)] pub(super) struct Member<'db> { /// Type, qualifiers, and boundness information of this member pub(super) inner: PlaceAndQualifiers<'db>, diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 6052b096f2..1ca0b41ea1 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -24,9 +24,11 @@ use crate::{ pub struct BoundMethodType<'db> { /// The function that is being bound. Corresponds to the `__func__` attribute on a /// bound method object + #[returns(copy)] pub(crate) function: FunctionType<'db>, /// The instance on which this method has been called. Corresponds to the `__self__` /// attribute on a bound method object + #[returns(copy)] pub(super) self_instance: Type<'db>, } @@ -64,6 +66,7 @@ impl<'db> BoundMethodType<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|db, _, _| CallableType::bottom(db), heap_size=ruff_memory_usage::heap_size )] @@ -150,7 +153,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { /// /// Unlike bound methods of user-defined classes, these are not generally instances /// of `types.BoundMethodType` at runtime. -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum KnownBoundMethodType<'db> { /// Method wrapper for `some_function.__get__` FunctionTypeDunderGet(FunctionType<'db>), @@ -554,7 +557,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } /// Represents a specific instance of `types.WrapperDescriptorType` -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] pub enum WrapperDescriptorKind { /// `FunctionType.__get__` FunctionTypeDunderGet, diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 8b23222751..58210d5d35 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -33,7 +33,7 @@ use itertools::Itertools; /// ``` /// /// See [`ClassType::iter_mro`] for more details. -#[derive(PartialEq, Eq, Clone, Debug, salsa::Update, get_size2::GetSize)] +#[derive(PartialEq, Eq, Clone, Debug, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct Mro<'db>(Box<[ClassBase<'db>]>); impl<'db> Mro<'db> { @@ -692,7 +692,7 @@ impl DoubleEndedIterator for MroIterator<'_> { } } -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct StaticMroError<'db> { kind: StaticMroErrorKind<'db>, fallback_mro: Mro<'db>, @@ -721,7 +721,7 @@ impl<'db> StaticMroError<'db> { } /// Possible ways in which attempting to resolve the MRO of a statically-defined class might fail. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) enum StaticMroErrorKind<'db> { /// The class inherits from one or more invalid bases. /// @@ -773,7 +773,7 @@ impl<'db> StaticMroErrorKind<'db> { } /// Error recording the fact that a class definition was found to have duplicate bases. -#[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct DuplicateBaseError<'db> { /// The base that is duplicated in the class's bases list. pub(super) duplicate_base: ClassBase<'db>, @@ -876,7 +876,7 @@ fn check_generic_reorder_fixes_mro<'db>( /// Error for dynamic class MRO computation with fallback MRO. /// /// Separate from [`StaticMroError`] because dynamic classes can only have a subset of MRO errors. -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct DynamicMroError<'db> { kind: DynamicMroErrorKind<'db>, fallback_mro: Mro<'db>, @@ -897,7 +897,7 @@ impl<'db> DynamicMroError<'db> { /// Error kinds for dynamic class MRO computation. /// /// These mirror the relevant variants from `MroErrorKind` for static classes. -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum DynamicMroErrorKind<'db> { /// The class inherits from one or more invalid bases. /// diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 9123914163..ae72b1629f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -172,7 +172,7 @@ fn all_narrowing_constraints_for_subject_element_pattern<'db>( /// This positive structural analysis infers the type of each supported name bound by a successful /// pattern. Definite-match analysis, which is used for negative narrowing and exhaustiveness, /// intentionally remains separate. -#[derive(Debug, Eq, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Eq, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct PatternSuccessTypes<'db> { bindings: FrozenMap>, missing_binding_ty: Type<'db>, @@ -603,7 +603,7 @@ impl ClassInfoConstraintFunction { } } -#[derive(Hash, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +#[derive(Hash, PartialEq, Debug, Eq, Clone, get_size2::GetSize, salsa::SalsaValue)] struct Conjunctions<'db> { conjuncts: SmallVec<[Type<'db>; 2]>, } @@ -661,7 +661,7 @@ impl<'db> Conjunctions<'db> { /// ===> `NarrowingConstraint { intersection_disjuncts: [], replacement_disjuncts: [B] }` /// => `NarrowingConstraint { intersection_disjuncts: [A], replacement_disjuncts: [B] }` /// => evaluates to `(P & A) | B`, where `P` is our previously-known type -#[derive(Hash, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +#[derive(Hash, PartialEq, Debug, Eq, Clone, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct NarrowingConstraint<'db> { /// Intersection constraint (from `isinstance()` narrowing comparisons, `TypeIs`, and /// similar). We keep these as a disjunction of conjunctions to avoid constructing @@ -817,7 +817,7 @@ impl<'db> PatternNarrowingResult<'db> { } } -#[derive(Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Default, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] struct ExpressionNarrowingConstraints<'db> { positive: Option>, negative: Option>, diff --git a/crates/ty_python_semantic/src/types/newtype.rs b/crates/ty_python_semantic/src/types/newtype.rs index a22bd99bf3..d061bfc80c 100644 --- a/crates/ty_python_semantic/src/types/newtype.rs +++ b/crates/ty_python_semantic/src/types/newtype.rs @@ -29,6 +29,7 @@ pub struct NewType<'db> { pub name: ast::name::Name, /// The binding where this NewType is first created. + #[returns(copy)] pub definition: Definition<'db>, // The base type of this NewType, if it's eagerly specified. This is typically `None` when a @@ -36,6 +37,7 @@ pub struct NewType<'db> { // the recursive case. This becomes `Some` when a `NewType` is modified by methods like // `.normalize()`. Callers should use the `base` method instead of accessing this field // directly. + #[returns(copy)] eager_base: Option>, } @@ -51,6 +53,7 @@ impl<'db> NewType<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|db, _, _| NewTypeBase::ClassType(ClassType::object(db)), heap_size=ruff_memory_usage::heap_size )] @@ -256,7 +259,7 @@ pub(crate) fn walk_newtype_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Si } /// `typing.NewType` typically wraps a class type, but it can also wrap another newtype. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum NewTypeBase<'db> { ClassType(ClassType<'db>), NewType(NewType<'db>), diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index f8322fdbeb..b52e3f9860 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -669,7 +669,7 @@ fn check_class_declaration<'db>( } /// Whether an attribute declaration is a class variable or an instance variable. -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize)] enum VariableKind { /// A variable annotated with `ClassVar`. Class, @@ -733,7 +733,7 @@ fn superclass_variable_kind<'db>( /// x: ClassVar[int] = 2 /// ``` #[allow(clippy::needless_pass_by_value)] -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn effective_superclass_variable_kind<'db>( db: &'db dyn Db, superclass: ClassType<'db>, @@ -815,7 +815,7 @@ fn effective_superclass_variable_kind<'db>( /// This is a Salsa-tracked query because it has to look at the AST node for the definition, /// which might be in a different Python module. If this weren't a tracked query, we could /// introduce cross-module dependencies and over-invalidation. -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn is_function_definition<'db>( db: &'db dyn Db, scope: ScopeId<'db>, diff --git a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs index bebb90eb2c..65e49d9e93 100644 --- a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs +++ b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs @@ -133,7 +133,7 @@ enum ParamKind { KeywordVariadic, } -#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] fn create_bound_method<'db>( db: &'db dyn Db, function: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index b2d908be20..543ba198c7 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -49,7 +49,7 @@ impl<'db> ClassType<'db> { } /// Representation of a single `Protocol` class definition. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct ProtocolClass<'db>(ClassType<'db>); impl<'db> ProtocolClass<'db> { @@ -451,7 +451,7 @@ impl<'db> VarianceInferable<'db> for ProtocolInterface<'db> { /// Property accessors remain as callables until a relation needs their read or write type. Once /// resolved, `Value` retains the accessor's binding context so that only its own `Self` type is /// rebound during protocol checks. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum ProtocolMemberType<'db> { Value { ty: Type<'db>, @@ -574,7 +574,7 @@ impl<'db> ProtocolMemberType<'db> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] /// The types supported by one way of accessing a protocol member. /// /// `read` is covariant and `write` is contravariant. Either operation can be absent: for example, @@ -642,7 +642,7 @@ fn cycle_normalized_optional_type<'db>( } } -#[derive(Debug, PartialEq, Eq, Clone, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(super) struct ProtocolMemberData<'db> { kind: ProtocolMemberKind<'db>, qualifiers: TypeQualifiers, @@ -825,7 +825,7 @@ impl<'db> ProtocolMemberData<'db> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum ProtocolMemberKind<'db> { Method(ProtocolMemberType<'db>), Property { @@ -1979,6 +1979,7 @@ impl BoundOnClass { /// Inner Salsa query for [`ProtocolClass::interface`]. #[salsa::tracked( + returns(copy), cycle_initial=|db, _, _| ProtocolInterface::empty(db), cycle_fn=proto_interface_cycle_recover, heap_size=ruff_memory_usage::heap_size, diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index a55685a1c3..0b87719ee1 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -526,7 +526,7 @@ impl<'db> Type<'db> { /// /// See [`TypeRelation::Redundancy`] for more details. pub(super) fn is_redundant_with(self, db: &'db dyn Db, other: Type<'db>) -> bool { - #[salsa::tracked(cycle_initial=|_, _, _, _| true, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _| true, heap_size=ruff_memory_usage::heap_size)] fn is_redundant_with_impl<'db>( db: &'db dyn Db, self_ty: Type<'db>, diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index 10bcd594a0..a029f4756e 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -22,6 +22,7 @@ pub struct UnionType<'db> { pub elements: Box<[Type<'db>]>, /// Whether the value pointed to by this type is recursively defined. /// If `Yes`, union literal widening is performed early. + #[returns(copy)] pub(crate) recursively_defined: RecursivelyDefined, } @@ -68,6 +69,7 @@ impl<'db> UnionType<'db> { /// Create a union type `A | B` from two elements `A` and `B`. #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { result.cycle_normalized(db, *previous, cycle) @@ -496,7 +498,7 @@ pub struct IntersectionType<'db> { /// and `Self::Single` would add overhead to methods like `Self::swap_remove`, /// and would have little value. At the point when you're calling that method, a /// heap allocation has already taken place. -#[derive(Debug, Clone, get_size2::GetSize, salsa::Update, Default)] +#[derive(Debug, Clone, get_size2::GetSize, Default, salsa::SalsaValue)] pub enum NegativeIntersectionElements<'db> { #[default] Empty, @@ -847,6 +849,7 @@ impl<'db> IntersectionType<'db> { /// Create an intersection type `A & B` from two elements `A` and `B`. #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { result.cycle_normalized(db, *previous, cycle) diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 4c8675f639..5f96abfbb0 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -101,7 +101,7 @@ fn function_signature_type_expression_flags<'db>( /// The signature of a single callable. If the callable is overloaded, there is a separate /// [`Signature`] for each overload. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct CallableSignature<'db> { /// The signatures of each overload of this callable. Will be empty if the type is not /// callable. @@ -478,7 +478,7 @@ impl<'db> VarianceInferable<'db> for &CallableSignature<'db> { } /// The signature of one of the overloads of a callable. -#[derive(Clone, Debug, salsa::Update, get_size2::GetSize, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, get_size2::GetSize, PartialEq, Eq, Hash, salsa::SalsaValue)] pub struct Signature<'db> { /// The generic context for this overload, if it is generic. pub(crate) generic_context: Option>, @@ -3102,7 +3102,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } /// The tail of a `Concatenate[T1, T2, Tn, tail]` form. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum ConcatenateTail<'db> { /// Represents the `Concatenate[T1, T2, Tn, ...]` form where the prefix parameters are followed /// by a gradual `*args: Any, **kwargs: Any`. @@ -3118,7 +3118,9 @@ pub(crate) enum ConcatenateTail<'db> { /// For annotation-derived parameter lists, the kind records the form of the original annotation /// and must be preserved when its parameter types are transformed. In particular, specializing a /// standard `(*args: T, **kwargs: T)` parameter list with `T = Any` does not make it gradual. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive( + Copy, Clone, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue, +)] pub(crate) enum ParametersKind<'db> { /// A standard parameter list. #[default] @@ -3174,14 +3176,14 @@ pub(crate) enum ParametersKind<'db> { // TODO: Given how the current structure is laid out which needs to follow certain invariants // between the `value` and `kind` field, it would be better to structure it such that these // invariants are followed at the type level instead. -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] struct ParametersData<'db> { // TODO: use SmallVec here once invariance bug is fixed value: Box<[Parameter<'db>]>, kind: ParametersKind<'db>, } -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct Parameters<'db> { data: Arc>, } @@ -3982,7 +3984,7 @@ impl ParameterNamePrefix { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct Parameter<'db> { /// Annotated type of the parameter. If no annotation was provided, this is `Unknown`. annotated_type: Type<'db>, @@ -4008,7 +4010,7 @@ pub(crate) struct Parameter<'db> { kind: ParameterKind<'db>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] enum ParameterAnnotationKind { Normal, @@ -4408,7 +4410,7 @@ impl<'db> Parameter<'db> { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum ParameterKind<'db> { /// Positional-only parameter, e.g. `def f(x, /): ...` PositionalOnly { diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index ff23cda4e0..7e52944ab8 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -14,7 +14,7 @@ use crate::{Db, FxOrderSet}; use ty_python_core::definition::Definition; /// A type that represents `type[C]`, i.e. the class object `C` and class objects that are subclasses of `C`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct SubclassOfType<'db> { // Keep this field private, so that the only way of constructing the struct is through the `from` method. subclass_of: SubclassOfInner<'db>, @@ -379,7 +379,7 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// Note that this enum is similar to the [`super::ClassBase`] enum, /// but does not include the `ClassBase::Protocol` and `ClassBase::Generic` variants /// (`type[Protocol]` and `type[Generic]` are not valid types). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum SubclassOfInner<'db> { Class(ClassType<'db>), Dynamic(DynamicType<'db>), diff --git a/crates/ty_python_semantic/src/types/tests.rs b/crates/ty_python_semantic/src/types/tests.rs index 42e9a43356..22b9f51137 100644 --- a/crates/ty_python_semantic/src/types/tests.rs +++ b/crates/ty_python_semantic/src/types/tests.rs @@ -60,6 +60,7 @@ fn oscillating_generic_alias_cycle_recover<'db>( } #[salsa::tracked( + returns(copy), cycle_initial=|_, id| Type::divergent(id), cycle_fn=oscillating_generic_alias_cycle_recover, )] diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index f257fc2eb7..31c4893224 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -204,7 +204,7 @@ impl<'db> TupleType<'db> { // N.B. If this method is not Salsa-tracked, we take 10 minutes to check // `static-frame` as part of the ecosystem analysis. This is because it's called // from `NominalInstanceType::class()`, which is a very hot method. - #[salsa::tracked(cycle_initial=to_class_type_cycle_initial, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=to_class_type_cycle_initial, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn to_class_type(self, db: &'db dyn Db) -> ClassType<'db> { let tuple_class = KnownClass::Tuple .try_to_class_literal(db) @@ -625,7 +625,7 @@ pub(crate) type TupleSpec<'db> = Tuple>; /// /// Our tuple representation can hold instances of any Rust type. For tuples containing Python /// types, use [`TupleSpec`], which defines some additional type-specific methods. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct FixedLengthTuple(Box<[T]>); impl FixedLengthTuple { @@ -804,7 +804,7 @@ impl<'db> PySlice<'db> for FixedLengthTuple> { /// /// Our tuple representation can hold instances of any Rust type. For tuples containing Python /// types, use [`TupleSpec`], which defines some additional type-specific methods. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct VariableLengthTuple { pub(crate) elements: smallvec::SmallVec<[T; 1]>, variable_index: usize, @@ -1944,7 +1944,7 @@ impl<'db> PyIndex<'db> for &VariableLengthTuple> { /// /// Our tuple representation can hold instances of any Rust type. For tuples containing Python /// types, use [`TupleSpec`], which defines some additional type-specific methods. -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum Tuple { Fixed(FixedLengthTuple), Variable(VariableLengthTuple), diff --git a/crates/ty_python_semantic/src/types/type_alias.rs b/crates/ty_python_semantic/src/types/type_alias.rs index a84166333b..b8bac8c35b 100644 --- a/crates/ty_python_semantic/src/types/type_alias.rs +++ b/crates/ty_python_semantic/src/types/type_alias.rs @@ -26,8 +26,10 @@ pub struct PEP695TypeAliasType<'db> { #[returns(ref)] pub name: Name, + #[returns(copy)] rhs_scope: ScopeId<'db>, + #[returns(copy)] pub(super) specialization: Option>, } @@ -58,6 +60,7 @@ impl<'db> PEP695TypeAliasType<'db> { /// The RHS type of a PEP-695 style type alias with *no* specialization applied. /// Returns `Divergent` if the type alias is defined cyclically. #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _| { value.cycle_normalized(db, *previous, cycle) @@ -128,7 +131,7 @@ impl<'db> PEP695TypeAliasType<'db> { self.specialization(db).is_some() } - #[salsa::tracked(cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size)] pub(crate) fn generic_context(self, db: &'db dyn Db) -> Option> { let scope = self.rhs_scope(db); let file = scope.file(db); @@ -155,6 +158,7 @@ impl<'db> PEP695TypeAliasType<'db> { pub struct ManualPEP695TypeAliasType<'db> { #[returns(ref)] pub name: Name, + #[returns(copy)] pub definition: Definition<'db>, } @@ -176,6 +180,7 @@ impl<'db> ManualPEP695TypeAliasType<'db> { /// Computed lazily from the definition to avoid including the value in the interned /// struct's identity. Returns `Divergent` if the type alias is defined cyclically. #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _| Type::divergent(id), cycle_fn=|db, cycle, previous: &Type<'db>, value: Type<'db>, _| { value.cycle_normalized(db, *previous, cycle) @@ -201,7 +206,7 @@ impl<'db> ManualPEP695TypeAliasType<'db> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum TypeAliasType<'db> { /// A type alias defined using the PEP 695 `type` statement. PEP695(PEP695TypeAliasType<'db>), @@ -307,6 +312,7 @@ impl<'db> TypeAliasType<'db> { #[salsa::tracked] impl<'db> VarianceInferable<'db> for TypeAliasType<'db> { #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _, _| TypeVarVariance::Bivariant, heap_size=ruff_memory_usage::heap_size )] diff --git a/crates/ty_python_semantic/src/types/type_form.rs b/crates/ty_python_semantic/src/types/type_form.rs index c0a562c00b..295668255b 100644 --- a/crates/ty_python_semantic/src/types/type_form.rs +++ b/crates/ty_python_semantic/src/types/type_form.rs @@ -7,6 +7,7 @@ use crate::Db; #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct TypeFormType<'db> { + #[returns(copy)] pub(crate) type_argument: Type<'db>, } diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 605284544f..fe3c7837ca 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -53,7 +53,9 @@ impl Default for TypedDictParams { /// An implicitly open `TypedDict` may contain hidden items, but those items are not directly /// accessible through most operations. A `TypedDict` with explicit extra items exposes those items /// with a known type. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue, +)] pub enum TypedDictOpenness<'db> { /// Undeclared items may exist at runtime, but are not directly accessible through most /// `TypedDict` operations. @@ -167,7 +169,7 @@ impl<'db> TypedDictOpenness<'db> { /// This represents either an explicit `extra_items` declaration or the synthetic read-only /// `object` policy returned for an implicitly open `TypedDict` by /// [`TypedDictOpenness::effective_extra_items`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct TypedDictExtraItems<'db> { pub(crate) declared_ty: Type<'db>, is_read_only: bool, @@ -200,7 +202,7 @@ pub(super) fn functional_typed_dict_field( /// Type that represents the set of all inhabitants (`dict` instances) that conform to /// a given `TypedDict` schema. -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, Hash, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub enum TypedDictType<'db> { /// A reference to the class (inheriting from `typing.TypedDict`) that specifies the /// schema of this `TypedDict`. @@ -210,7 +212,7 @@ pub enum TypedDictType<'db> { Synthesized(SynthesizedTypedDictType<'db>), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum SynthesizedTypedDictKind { Schema, Patch, @@ -234,6 +236,7 @@ impl<'db> TypedDictType<'db> { /// declares its own `closed` or `extra_items` argument. pub(crate) fn openness(self, db: &'db dyn Db) -> TypedDictOpenness<'db> { #[salsa::tracked( + returns(copy), cycle_initial=|_, _, _| TypedDictOpenness::ImplicitlyOpen, heap_size=ruff_memory_usage::heap_size )] @@ -1322,6 +1325,7 @@ pub(super) fn deferred_functional_typed_dict_schema<'db>( /// Movie = TypedDict("Movie", {"name": str}, extra_items=ReadOnly[int]) /// ``` #[salsa::tracked( + returns(copy), cycle_initial = |_, _, _| TypedDictOpenness::ImplicitlyOpen, heap_size = ruff_memory_usage::heap_size )] @@ -2883,8 +2887,10 @@ pub(super) fn validate_typed_dict_dict_literal<'db>( pub struct SynthesizedTypedDictType<'db> { #[returns(ref)] pub(crate) items: TypedDictSchema<'db>, + #[returns(copy)] pub(crate) kind: SynthesizedTypedDictKind, /// Whether keys absent from `items` are hidden, forbidden, or explicitly typed. + #[returns(copy)] pub(crate) openness: TypedDictOpenness<'db>, } @@ -2942,7 +2948,7 @@ impl<'db> SynthesizedTypedDictType<'db> { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::SalsaValue)] pub struct TypedDictSchema<'db>(BTreeMap>); impl<'db> TypedDictSchema<'db> { @@ -2999,7 +3005,7 @@ impl<'db> FromIterator<(Name, TypedDictField<'db>)> for TypedDictSchema<'db> { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub struct TypedDictField<'db> { pub(super) declared_ty: Type<'db>, flags: TypedDictFieldFlags, @@ -3095,7 +3101,7 @@ impl<'db> TypedDictFieldBuilder<'db> { } bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct TypedDictFieldFlags: u8 { const REQUIRED = 1 << 0; const READ_ONLY = 1 << 1; diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index a527aa6a37..b37a65de31 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -117,18 +117,22 @@ impl<'db> Type<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct TypeVarInstance<'db> { /// The identity of this typevar + #[returns(copy)] pub(crate) identity: TypeVarIdentity<'db>, /// The upper bound or constraint on the type of this TypeVar, if any. Don't use this field /// directly; use the `bound_or_constraints` (or `upper_bound` and `constraints`) methods /// instead (to evaluate any lazy bound or constraints). + #[returns(copy)] _bound_or_constraints: Option>, /// The explicitly specified variance of the TypeVar + #[returns(copy)] pub(super) explicit_variance: Option, /// The default type for this TypeVar, if any. Don't use this field directly, use the /// `default_type` method instead (to evaluate any lazy default). + #[returns(copy)] _default: Option>, } @@ -464,6 +468,7 @@ impl<'db> TypeVarInstance<'db> { /// Returns the "unchecked" upper bound of a type variable instance. /// `lazy_bound` checks if the upper bound type is generic (generic upper bound is not allowed). #[salsa::tracked( + returns(copy), cycle_fn=lazy_bound_cycle_recover, cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size @@ -502,6 +507,7 @@ impl<'db> TypeVarInstance<'db> { /// Returns the "unchecked" constraints of a type variable instance. /// `lazy_constraints` checks if any of the constraint types are generic (generic constraints are not allowed). #[salsa::tracked( + returns(copy), cycle_fn=lazy_constraints_cycle_recover, cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size @@ -560,7 +566,7 @@ impl<'db> TypeVarInstance<'db> { /// Returns the "unchecked" default type of a type variable instance. /// `lazy_default` checks if the default type is not self-referential. - #[salsa::tracked(cycle_initial=|_, id, _| Some(Type::divergent(id)), cycle_fn=lazy_default_cycle_recover, heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(copy), cycle_initial=|_, id, _| Some(Type::divergent(id)), cycle_fn=lazy_default_cycle_recover, heap_size=ruff_memory_usage::heap_size)] fn lazy_default_unchecked(self, db: &'db dyn Db) -> Option> { fn convert_type_to_paramspec_value<'db>(db: &'db dyn Db, ty: Type<'db>) -> Type<'db> { let parameters = match ty { @@ -684,7 +690,7 @@ impl<'db> TypeVarInstance<'db> { /// /// `0` is reserved for source-level, non-freshened typevars. Positive values identify fresh /// occurrences. -#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, salsa::Update)] +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct TypeVarNonce(u32); // This type does not have any heap storage. @@ -849,10 +855,12 @@ pub(crate) fn max_typevar_freshness_matching_generic_context<'db>( heap_size = ruff_memory_usage::heap_size )] pub struct BoundTypeVarInstance<'db> { + #[returns(copy)] pub typevar: TypeVarInstance<'db>, // This duplicates the source-level identity accessible through `typevar`, but keeps // `identity()` to a single interned-field read. Storing only the occurrence-specific fields // and reconstructing the full identity regresses hot-path project benchmarks. + #[returns(copy)] identity_inner: BoundTypeVarIdentity<'db>, } @@ -1321,9 +1329,11 @@ pub struct TypeVarIdentity<'db> { pub(crate) name: Name, /// The type var's definition (None if synthesized) + #[returns(copy)] pub(crate) definition: Option>, /// The kind of typevar (PEP 695, Legacy, or TypingSelf) + #[returns(copy)] pub(crate) kind: TypeVarKind, } @@ -1386,7 +1396,7 @@ fn lazy_default_cycle_recover<'db>( } /// Where a type variable is bound and usable. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub enum BindingContext<'db> { /// The definition of the generic class, function, or type alias that binds this typevar. Definition(Definition<'db>), @@ -1436,7 +1446,7 @@ impl std::fmt::Display for ParamSpecAttrKind { /// bounds or constraints. Two bound typevars have the same identity if they represent the same /// occurrence, even if their bounds have been materialized differently. Two fresh occurrences of /// the same source-level typevar have different bound identities. -#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)] +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] pub struct BoundTypeVarIdentity<'db> { pub(crate) identity: TypeVarIdentity<'db>, pub(crate) binding_context: BindingContext<'db>, @@ -1469,6 +1479,7 @@ impl<'db> BoundTypeVarIdentity<'db> { } #[salsa::tracked( + returns(copy), cycle_initial=|_, id, _| Some(Type::divergent(id)), cycle_fn=bound_typevar_default_type_cycle_recover, heap_size=ruff_memory_usage::heap_size @@ -1503,7 +1514,7 @@ fn bound_typevar_default_type_cycle_recover<'db>( } /// Whether a typevar default is eagerly specified or lazily evaluated. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum TypeVarDefaultEvaluation<'db> { /// The default type is lazily evaluated. Lazy, @@ -1518,7 +1529,7 @@ impl<'db> From> for TypeVarDefaultEvaluation<'db> { } /// Whether a typevar bound/constraints is eagerly specified or lazily evaluated. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum TypeVarBoundOrConstraintsEvaluation<'db> { /// There is a lazily-evaluated upper bound. LazyUpperBound, @@ -1693,7 +1704,7 @@ impl<'db> TypeVarConstraints<'db> { } } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub enum TypeVarBoundOrConstraints<'db> { UpperBound(Type<'db>), Constraints(TypeVarConstraints<'db>), diff --git a/crates/ty_python_semantic/src/types/unpacker.rs b/crates/ty_python_semantic/src/types/unpacker.rs index 0d331a9faa..b594eb0ac6 100644 --- a/crates/ty_python_semantic/src/types/unpacker.rs +++ b/crates/ty_python_semantic/src/types/unpacker.rs @@ -278,7 +278,7 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { } } -#[derive(Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Debug, Default, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) struct UnpackResult<'db> { targets: FrozenMap>, diagnostics: TypeCheckDiagnostics, diff --git a/crates/ty_python_semantic/src/types/variance.rs b/crates/ty_python_semantic/src/types/variance.rs index a8a93360fb..21a3253dbf 100644 --- a/crates/ty_python_semantic/src/types/variance.rs +++ b/crates/ty_python_semantic/src/types/variance.rs @@ -1,6 +1,6 @@ use crate::{Db, types::BoundTypeVarIdentity}; -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, get_size2::GetSize)] pub enum TypeVarVariance { Invariant, Covariant, diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index b15a167e20..2606538826 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -227,6 +227,7 @@ struct Settings { #[returns(deref)] rule_selection: MdtestRuleSelection, #[default] + #[returns(copy)] verbose: bool, } diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e755aef0ca..dd4169279c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -31,7 +31,7 @@ ty_vendored = { path = "../crates/ty_vendored" } ty_python_core = { path = "../crates/ty_python_core" } libfuzzer-sys = { git = "https://github.com/rust-fuzz/libfuzzer", default-features = false } -salsa = { version = "0.27.2", default-features = false, features = [ +salsa = { version = "0.28.0", default-features = false, features = [ "compact_str", "macros", "salsa_unstable", From b291fbdcadb1246c7c6db652450f0bdbb3cbcd80 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 12 Jul 2026 09:42:48 -0400 Subject: [PATCH 030/222] [ty] Reuse common TypedDict constraints through intersections (#26747) ## Summary Prior to this change, the common-protocol-constraint optimization for unions of `TypedDict`s did not apply after an `isinstance(value, dict)` check. Narrowing represents each surviving arm as an intersection with `Top[dict[Unknown, Unknown]]`, so a call like `dict(value)` fell back to combining equivalent generic protocol constraints independently across every union member: ```python from typing import Literal, TypedDict class A(TypedDict): tag: Literal["a"] class B(TypedDict): tag: Literal["b"] def copy(value: A | B | str) -> None: if isinstance(value, dict): reveal_type(dict(value)) # dict[str, object] ``` With a sufficiently wide union, that fallback exhibited pathological runtime. On the issue's 21-arm reproduction, the pre-fix build did not complete within ten seconds; the optimized path completes in under 0.2 seconds. We now recognize intersections containing a positive `TypedDict` as `TypedDict` alternatives, while retaining the complete intersection when comparing its protocol constraints with `Mapping[str, object]`. Closes https://github.com/astral-sh/ty/issues/3974. Co-authored-by: Nate Bracy --- .../resources/mdtest/typed_dict.md | 33 ++++++++++++++++++- .../ty_python_semantic/src/types/generics.rs | 11 +++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 93b3ffb0eb..c09394201d 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2579,6 +2579,13 @@ Item = A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S def _(item: Item) -> None: reveal_type(dict(item)) # revealed: dict[str, object] +# Runtime narrowing retains a `Top[dict[Unknown, Unknown]]` intersection around each `TypedDict`. +# Those intersections should still reuse the common protocol constraints of the union. +# Regression test for https://github.com/astral-sh/ty/issues/3974. +def _(item: Item | str) -> None: + if isinstance(item, dict): + reveal_type(dict(item)) # revealed: dict[str, object] + type FirstGroup = A | B | C | D | E | F | G | H type SecondGroup = I | J | K | L | M | N | O | P type AliasedItem = FirstGroup | SecondGroup | Q | R | S | T | U | V | W | X @@ -2649,7 +2656,7 @@ Generic protocol inference must preserve structural constraints that differ from ```py from _collections_abc import dict_items from collections.abc import Callable -from typing import Protocol, TypeVar, TypedDict +from typing import Protocol, TypeVar, TypedDict, runtime_checkable ItemsT = TypeVar("ItemsT") @@ -2666,6 +2673,30 @@ def accept(value: HasItems[ItemsT], callback: Callable[[ItemsT], None]) -> None: def takes_dict_items(value: dict_items[str, object]) -> None: ... def _(value: ItemsA | ItemsB) -> None: accept(value, takes_dict_items) + +ClearT = TypeVar("ClearT", covariant=True) + +@runtime_checkable +class HasClear(Protocol): + def clear(self) -> None: ... + +class ClearResult(Protocol[ClearT]): + def clear(self) -> ClearT: ... + +class ClearA(TypedDict): + a: int + +class ClearB(TypedDict): + b: int + +def clear_result(value: ClearResult[ClearT]) -> ClearT: + raise NotImplementedError + +def _(value: ClearA | ClearB) -> None: + if isinstance(value, HasClear): + # Preserve the protocol constraints added by narrowing instead of extracting only the + # positive `TypedDict` elements from these intersections. + reveal_type(clear_result(value)) # revealed: None ``` Rejected common-constraint probes must not affect fallback protocol inference: diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index c301a90a2c..243229ad17 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2499,6 +2499,17 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { resolving.remove(&ty); result } + Type::Intersection(intersection) + if intersection + .iter_positive(db) + .any(|element| element.resolve_type_alias(db).is_typed_dict()) => + { + // `isinstance(value, dict)` narrows a `TypedDict` to an intersection with + // `Top[dict[Unknown, Unknown]]`. Keep the full intersection so the normal + // constraint-equivalence check below remains authoritative. + typed_dicts.insert(ty); + true + } _ => false, }; completed.insert(ty, result); From aede2c151d4b663defb2249420455da5891136c1 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 12 Jul 2026 17:31:00 +0200 Subject: [PATCH 031/222] [ty] Cache generic context (#26745) --- crates/ty_python_semantic/src/types/class/static_literal.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index 2009f681bd..fe1a418139 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -267,6 +267,11 @@ impl<'db> StaticClassLiteral<'db> { None } + #[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _| None, + heap_size=ruff_memory_usage::heap_size, + )] pub(crate) fn generic_context(self, db: &'db dyn Db) -> Option> { // Several typeshed definitions examine `sys.version_info`. To break cycles, we hard-code // the knowledge that this class is not generic. From c3291250713675649cd496a65e9d2914d8257a74 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 12 Jul 2026 17:47:42 +0200 Subject: [PATCH 032/222] [ty] Avoid broad invalidation from file check eligibility (#26741) --- crates/ty_ide/src/hints.rs | 2 +- crates/ty_project/src/db.rs | 13 ++- crates/ty_project/src/lib.rs | 157 +++++++++++++++++------------------ 3 files changed, 85 insertions(+), 87 deletions(-) diff --git a/crates/ty_ide/src/hints.rs b/crates/ty_ide/src/hints.rs index f776dec6e8..a358326f74 100644 --- a/crates/ty_ide/src/hints.rs +++ b/crates/ty_ide/src/hints.rs @@ -41,7 +41,7 @@ impl HintKind { } pub fn hints(db: &dyn Db, file: File) -> Vec { - if !db.project().should_check_file(db, file) { + if !db.should_check_file(file) { return Vec::new(); } diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 474a7ed7f6..127562e796 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -175,7 +175,7 @@ impl ProjectDatabase { #[tracing::instrument(level = "debug", skip(self))] pub fn check_file(&self, file: File) -> Vec { - self.project().check_file(self, file) + crate::check_file(self, file) } /// Set the check mode for the project. @@ -560,8 +560,13 @@ impl SemanticDb for ProjectDatabase { #[salsa::db] impl ty_python_core::Db for ProjectDatabase { fn should_check_file(&self, file: File) -> bool { + // Avoid creating a dependency on the `should_check_file` query for vendored files. + if file.path(self).is_vendored_path() { + return false; + } + self.project - .is_some_and(|project| project.should_check_file(self, file)) + .is_some_and(|_| crate::should_check_file(self, file)) } } @@ -754,7 +759,7 @@ pub(crate) mod testing { #[salsa::db] impl ty_python_core::Db for TestDb { fn should_check_file(&self, file: ruff_db::files::File) -> bool { - !file.path(self).is_vendored_path() + crate::should_check_file(self, file) } } @@ -762,7 +767,7 @@ pub(crate) mod testing { impl ty_python_semantic::Db for TestDb { #[inline] fn check_file(&self, file: File) -> Vec { - self.project().check_file(self, file) + crate::check_file(self, file) } fn rule_selection(&self, _file: ruff_db::files::File) -> &RuleSelection { diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index f1760c4e38..db4d906a0e 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -426,16 +426,6 @@ impl Project { ); } - pub(crate) fn check_file(self, db: &dyn Db, file: File) -> Vec { - if !self.should_check_file(db, file) { - return Vec::new(); - } - - check_file_impl(db, file) - .map(<[Diagnostic]>::to_vec) - .unwrap_or_else(|diagnostic| vec![diagnostic.clone()]) - } - /// Opens a file in the project. pub fn open_file(self, db: &mut dyn Db, file: File) { tracing::debug!("Opening file `{}`", file.path(db)); @@ -522,78 +512,6 @@ impl Project { self.set_open_fileset(db).to(FxHashSet::default()) } - /// Returns `true` if the file should be checked. - /// - /// This depends on the project's check mode: - /// * For [`OpenFiles`], it checks if the file is either explicitly set as an open file using - /// [`open_file`] or a system virtual path - /// * For [`AllFiles`], it checks if the file is either a system virtual path or a part of the - /// indexed files in the project - /// - /// [`open_file`]: Self::open_file - /// [`OpenFiles`]: CheckMode::OpenFiles - /// [`AllFiles`]: CheckMode::AllFiles - pub fn should_check_file(self, db: &dyn Db, file: File) -> bool { - let path = file.path(db); - - // NOTE: The tracing messages below were added because - // whether a file should be checked or not can sometimes - // be at the root of confusing UX like "diagnostics all - // of a sudden stopped working." Having a trace message - // indicating *why* a particular file isn't being checked - // can be quite helpful for narrowing down the issue. - // - // The problem is that it's incredibly noisy. Which is why - // we set them to the TRACE level. - - // Try to return early to avoid adding a dependency on `open_files` or `file_set` which - // both have a durability of `LOW`. - if path.is_vendored_path() { - tracing::trace!("Not checking {path} because it is a vendored path"); - return false; - } - - match self.check_mode(db) { - CheckMode::OpenFiles => { - let should_check = self.open_files(db).contains(&file); - if !should_check { - tracing::trace!( - "Not checking {path} because check mode is `OpenFiles` \ - and it is not in the open file set" - ); - } - should_check - } - CheckMode::AllFiles => { - // Virtual files are always checked. - // - // We also check the open file set. In theory, we - // shouldn't need to do this since it is accounted for - // by the virtual file check (for the case when a file - // wants to be checked but isn't saved to disk yet). - // However, not all clients follow the LSP convention - // that URIs for documents not on disk yet use the - // `untitled://...` scheme. That is, we assume that a - // `file://...` scheme corresponds to a saved file on - // disk, and anything else is "virtual." For example, - // neovim uses `file://...` even for an open buffer - // that does not correspond to a file saved to disk - // yet. - let should_check = path.is_system_virtual_path() - || self.files(db).contains(&file) - || self.open_files(db).contains(&file); - if !should_check { - tracing::trace!( - "Not checking {path} because check mode is `AllFiles` \ - and it is not a virtual path, in the project files \ - or in the open file set" - ); - } - should_check - } - } - } - #[tracing::instrument(level = "debug", skip(self, db))] pub fn remove_file(self, db: &mut dyn Db, file: File) { tracing::debug!( @@ -730,6 +648,81 @@ impl Project { } } +pub(crate) fn check_file(db: &dyn Db, file: File) -> Vec { + if !db.should_check_file(file) { + return Vec::new(); + } + + check_file_impl(db, file) + .map(<[Diagnostic]>::to_vec) + .unwrap_or_else(|diagnostic| vec![diagnostic.clone()]) +} + +/// Returns `true` if the file should be checked. +/// +/// This depends on the project's check mode: +/// * For [`CheckMode::OpenFiles`], it checks if the file is explicitly in the open file set. +/// * For [`CheckMode::AllFiles`], it checks if the file is virtual, indexed in the project, or in +/// the open file set. +/// +/// This query provides a per-file backdating boundary around the project-wide file sets. Updating +/// either set still revalidates this query, but unchanged results are backdated before invalidation +/// reaches semantic-index and type-inference queries. +#[salsa::tracked(returns(copy))] +pub(crate) fn should_check_file(db: &dyn Db, file: File) -> bool { + let project = db.project(); + let path = file.path(db); + + // NOTE: The tracing messages below were added because whether a file should be checked or not + // can sometimes be at the root of confusing UX like "diagnostics all of a sudden stopped + // working." Having a trace message indicating why a particular file isn't being checked can + // be quite helpful for narrowing down the issue. The messages are at TRACE because they are + // extremely noisy. + + if path.is_vendored_path() { + tracing::trace!("Not checking {path} because it is a vendored path"); + return false; + } + + match project.check_mode(db) { + CheckMode::OpenFiles => { + let should_check = project.open_files(db).contains(&file); + if !should_check { + tracing::trace!( + "Not checking {path} because check mode is `OpenFiles` \ + and it is not in the open file set" + ); + } + should_check + } + CheckMode::AllFiles => { + // Virtual files are always checked. + // + // We also check the open file set. In theory, we shouldn't need to do this since it is + // accounted for by the virtual file check (for the case when a file wants to be checked + // but isn't saved to disk yet). However, not all clients follow the LSP convention that + // URIs for documents not on disk yet use the `untitled://...` scheme. That is, we assume + // that a `file://...` scheme corresponds to a saved file on disk, and anything else is + // "virtual." For example, neovim uses `file://...` even for an open buffer that does not + // correspond to a file saved to disk yet. + if path.is_system_virtual_path() { + return true; + } + + let should_check = + project.files(db).contains(&file) || project.open_files(db).contains(&file); + if !should_check { + tracing::trace!( + "Not checking {path} because check mode is `AllFiles` \ + and it is not a virtual path, in the project files \ + or in the open file set" + ); + } + should_check + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProjectReloadResult { /// Neither project metadata nor settings changed. From 9ccd45b6c29aec122abf364e1c99f742c9f3e163 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 12 Jul 2026 18:05:23 +0200 Subject: [PATCH 033/222] [ty] Cache known class instances (#26746) --- .../src/types/class/known.rs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index fbdc1b2038..684b680b30 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -1020,10 +1020,21 @@ impl KnownClass { KnownClass::Tuple, "Use `Type::heterogeneous_tuple` or `Type::homogeneous_tuple` to create `tuple` instances" ); - self.to_class_literal(db) - .to_class_type(db) - .map(|class| Type::instance(db, class)) - .unwrap_or_else(Type::unknown) + + #[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] + fn known_class_to_instance<'db>( + db: &'db dyn Db, + class: KnownClassArgument<'db>, + ) -> Type<'db> { + class + .class(db) + .to_class_literal(db) + .to_class_type(db) + .map(|class| Type::instance(db, class)) + .unwrap_or_else(Type::unknown) + } + + known_class_to_instance(db, KnownClassArgument::new(db, self)) } /// Similar to [`KnownClass::to_instance`], but returns the Unknown-specialization where each type @@ -1125,12 +1136,6 @@ impl KnownClass { self, db: &dyn Db, ) -> Result>, KnownClassLookupError<'_>> { - #[salsa::interned(heap_size=ruff_memory_usage::heap_size)] - struct KnownClassArgument { - #[returns(copy)] - class: KnownClass, - } - #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| Ok(None), heap_size=ruff_memory_usage::heap_size)] fn known_class_to_class_literal<'db>( db: &'db dyn Db, @@ -1974,6 +1979,12 @@ impl KnownClass { } } +#[salsa::interned(heap_size=ruff_memory_usage::heap_size)] +struct KnownClassArgument { + #[returns(copy)] + class: KnownClass, +} + /// Enumeration of ways in which looking up a [`KnownClass`] in its canonical module could fail. #[derive(Debug, Clone, Copy, PartialEq, Eq, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum KnownClassLookupError<'db> { From 04ff791a198844b1a897b765713b30e9cd78f003 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 12 Jul 2026 12:39:00 -0400 Subject: [PATCH 034/222] [ty] Respect `init=False` in dataclass field-order checks (#26749) ## Summary Prior to this change, we emitted `dataclass-field-order` for `@dataclass(init=False)` even though no constructor is generated and a custom `__init__` can validly accept the required fields. We now skip the positional field-order check when the class-level `init` flag is false, while preserving the existing field-level `init=False`, keyword-only, and duplicate-`KW_ONLY` checks. The regression test covers the original custom-constructor case. This deliberately does not attempt to reconstruct inherited field order when a subclass re-enables `init`; the added TODO test documents that remaining false negative without expanding the field-inheritance model. Closes https://github.com/astral-sh/ty/issues/3976. --- .../mdtest/dataclasses/dataclasses.md | 20 +++++++++++++++++++ .../builder/post_inference/static_class.rs | 11 +++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 6a0a1e28cf..5904987ce4 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -156,6 +156,26 @@ class BadWithInitFalse: z: float ``` +Class-level `init=False` suppresses the ordering check because no constructor is generated: + +```py +@dataclass(init=False) +class GoodWithClassInitFalse: + x: int = 1 + y: str + + def __init__(self, y: str) -> None: + self.y = y + +GoodWithClassInitFalse("value") + +# Re-enabling `init` makes the inherited default-before-required ordering invalid at runtime. +# TODO: error: [dataclass-field-order] +@dataclass +class BadWithReenabledInit(GoodWithClassInitFalse): + pass +``` + Keyword-only fields (using `kw_only=True`) also don't participate in the positional ordering check: ```toml diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index d96aa2623b..8b5f6bbb3d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -13,9 +13,9 @@ use crate::{ diagnostic::format_enumeration, place::{DefinedPlace, Place, TypeOrigin, place_from_bindings, place_from_declarations}, types::{ - CallArguments, ClassBase, ClassLiteral, ClassType, KnownClass, KnownInstanceType, - MemberLookupPolicy, MetaclassCandidate, Parameters, Signature, SpecialFormType, - StaticClassLiteral, Type, TypeVarVariance, binding_type, + CallArguments, ClassBase, ClassLiteral, ClassType, DataclassFlags, KnownClass, + KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, Parameters, Signature, + SpecialFormType, StaticClassLiteral, Type, TypeVarVariance, binding_type, call::Argument, class::{ AbstractMethod, CodeGeneratorKind, FieldKind, MetaclassErrorKind, @@ -897,6 +897,7 @@ pub(crate) fn check_static_class_definitions<'db>( CodeGeneratorKind::from_class(db, class.into()) { let specialization = None; + let class_init = class.has_dataclass_param(db, field_policy, DataclassFlags::INIT); let mut kw_only_sentinel_fields = vec![]; let mut required_after_default_field_names = vec![]; @@ -919,8 +920,8 @@ pub(crate) fn check_static_class_definitions<'db>( continue; }; - // Fields with init=False or kw_only=true don't participate in ordering check - if !init || *kw_only == Some(true) { + // Classes or fields with init=False and kw_only fields don't participate in ordering. + if !class_init || !init || *kw_only == Some(true) { continue; } From 70e251023409e583cb0580805d99b1651b633229 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:49:47 +0900 Subject: [PATCH 035/222] [ty] skip `Self` type scans for non-generic instances (#26703) ## Summary Extracted performance improvement from #26503 (2) ## Test Plan --- crates/ty_python_semantic/src/types.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index a6964654ce..2433d43ff0 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1130,8 +1130,14 @@ impl<'db> Type<'db> { } /// Returns `true` if this type contains a `Self` type variable. - pub(crate) fn contains_self(&self, db: &'db dyn Db) -> bool { - any_over_type(db, *self, false, |ty| { + pub(crate) fn contains_self(self, db: &'db dyn Db) -> bool { + if let Type::NominalInstance(instance) = self + && !instance.is_definition_generic(db) + { + return false; + } + + any_over_type(db, self, false, |ty| { ty.as_typevar().is_some_and(|tv| tv.typevar(db).is_self(db)) }) } From b9b621d906067828a794a854df17a8f477f67f9e Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Jul 2026 15:23:18 +0200 Subject: [PATCH 036/222] [ty] Pydantic: Support `populate_by_name` and custom initializers (#26764) ## Summary This PR contains two loosely related features/fixes around constructor signatures for Pydantic models: - Recognize Pydantic's legacy `populate_by_name` configuration option as enabling validation by both field name and alias. - Inspect the model itself, in addition to its bases, when classifying custom `__init__` behavior ## Test Plan New Markdown tests --- .../resources/mdtest/external/pydantic.md | 17 +++ .../src/types/dedicated/pydantic.rs | 123 +++++++++++++----- 2 files changed, 105 insertions(+), 35 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index 42c27a55cb..11f14553d4 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -690,6 +690,20 @@ AliasAndName(name=1) AliasAndName(name=None) # error: [invalid-argument-type] ``` +The older `populate_by_name=True` setting has the same behavior: + +```py +class PopulatedByName(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + name: int = Field(alias="alias") + +PopulatedByName(alias=1) +PopulatedByName(name=1) +PopulatedByName(alias=None) # error: [invalid-argument-type] +PopulatedByName(name=None) # error: [invalid-argument-type] +``` + Passing none of these should be an error: ```py @@ -805,6 +819,9 @@ class RestrictiveBase(BaseModel): def __init__(self, name: str) -> None: super().__init__(name=name) +RestrictiveBase(name="Alice") +RestrictiveBase(name="Alice", city="Berlin") # error: [unknown-argument] + class RestrictiveUser(RestrictiveBase): name: str diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index e053398836..6af44820f8 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -46,25 +46,17 @@ impl<'db> ModelMetadata<'db> { } pub(in crate::types) fn validates_by_alias(self, db: &'db dyn Db) -> bool { - self.config(db).validate_by_alias.enabled_or(true) + let (validate_by_alias, _) = self.config(db).validation_config(); + validate_by_alias.enabled_or(true) } pub(in crate::types) fn validates_by_name(self, db: &'db dyn Db) -> bool { - let config = self.config(db); - let validate_by_name = config.validate_by_name; - // If `validate_by_alias=False` is set without specifying `validate_by_name`, Pydantic - // implicitly enables validation by name. - if matches!(validate_by_name, ConfigBoolean::Unspecified) - && matches!(config.validate_by_alias, ConfigBoolean::Disabled) - { - true - } else { - validate_by_name.enabled_or(false) - } + let (_, validate_by_name) = self.config(db).validation_config(); + validate_by_name.enabled_or(false) } pub(in crate::types) fn is_frozen(self, db: &'db dyn Db) -> bool { - matches!(self.config(db).frozen, ConfigBoolean::Enabled) + self.config(db).frozen.is_enabled() } } @@ -255,7 +247,7 @@ impl<'db> FieldMetadata<'db> { if let Some(alias) = field.alias(db) { self.alias = Some(alias.clone()); } - if !matches!(field.strict(db), ConfigBoolean::Unspecified) { + if !field.strict(db).is_unspecified() { self.strict = field.strict(db); } } @@ -331,6 +323,8 @@ pub(crate) struct ModelConfig { validate_by_alias: ConfigBoolean, /// Whether fields with aliases can be initialized by their field name. validate_by_name: ConfigBoolean, + /// The deprecated setting that enables validation by both alias and field name. + populate_by_name: ConfigBoolean, } impl ModelConfig { @@ -341,6 +335,7 @@ impl ModelConfig { frozen: ConfigBoolean::Unknown, validate_by_alias: ConfigBoolean::Unknown, validate_by_name: ConfigBoolean::Unknown, + populate_by_name: ConfigBoolean::Unknown, } } @@ -351,6 +346,28 @@ impl ModelConfig { self.frozen = other.frozen.or(self.frozen); self.validate_by_alias = other.validate_by_alias.or(self.validate_by_alias); self.validate_by_name = other.validate_by_name.or(self.validate_by_name); + self.populate_by_name = other.populate_by_name.or(self.populate_by_name); + } + + /// Resolve compatibility behavior after inherited and local configuration has been merged. + fn validation_config(self) -> (ConfigBoolean, ConfigBoolean) { + let mut validate_by_alias = self.validate_by_alias; + let mut validate_by_name = self.validate_by_name; + + // `populate_by_name` enables validation by both alias and field name. The newer + // `validate_by_name` setting takes precedence when both are specified. + if validate_by_name.is_unspecified() && self.populate_by_name.is_specified() { + validate_by_alias = ConfigBoolean::Enabled; + validate_by_name = self.populate_by_name; + } + + // If `validate_by_alias=False` is set without specifying `validate_by_name`, Pydantic + // implicitly enables validation by name. + if validate_by_alias.is_disabled() && validate_by_name.is_unspecified() { + validate_by_name = ConfigBoolean::Enabled; + } + + (validate_by_alias, validate_by_name) } } @@ -387,12 +404,24 @@ pub enum ConfigBoolean { } impl ConfigBoolean { + const fn is_unspecified(self) -> bool { + matches!(self, Self::Unspecified) + } + + const fn is_specified(self) -> bool { + matches!(self, Self::Disabled | Self::Enabled | Self::Unknown) + } + + const fn is_disabled(self) -> bool { + matches!(self, Self::Disabled) + } + + const fn is_enabled(self) -> bool { + matches!(self, Self::Enabled) + } + const fn or(self, other: Self) -> Self { - if matches!(self, Self::Unspecified) { - other - } else { - self - } + if self.is_unspecified() { other } else { self } } /// Resolve a boolean configuration value from its inferred type. @@ -603,6 +632,11 @@ fn own_model_config(db: &dyn Db, class: StaticClassLiteral<'_>) -> Option) -> Option, dict: &ExprDi "frozen" => config.frozen = ConfigBoolean::from_type(value), "validate_by_alias" => config.validate_by_alias = ConfigBoolean::from_type(value), "validate_by_name" => config.validate_by_name = ConfigBoolean::from_type(value), + "populate_by_name" => config.populate_by_name = ConfigBoolean::from_type(value), _ => {} } } @@ -679,6 +715,8 @@ fn class_keyword_config(db: &dyn Db, class: StaticClassLiteral<'_>) -> ModelConf config_boolean(db, definition, arguments.find_keyword("validate_by_alias")); let validate_by_name = config_boolean(db, definition, arguments.find_keyword("validate_by_name")); + let populate_by_name = + config_boolean(db, definition, arguments.find_keyword("populate_by_name")); ModelConfig { extra, @@ -686,6 +724,7 @@ fn class_keyword_config(db: &dyn Db, class: StaticClassLiteral<'_>) -> ModelConf frozen, validate_by_alias, validate_by_name, + populate_by_name, } } @@ -696,7 +735,7 @@ pub(in crate::types) fn constructor_parameter_type<'db>( field_strict: ConfigBoolean, metadata: ModelMetadata<'db>, ) -> Type<'db> { - if field_strict.or(metadata.config(db).strict) == ConfigBoolean::Enabled { + if field_strict.or(metadata.config(db).strict).is_enabled() { return field_type; } @@ -904,48 +943,62 @@ fn lax_alias<'db>(db: &'db dyn Db, name: &str) -> Type<'db> { } } -/// Return `true` if `class` should accept extra keywords in its synthesized constructor. -pub(in crate::types) fn model_init_accepts_extra( - db: &dyn Db, - class: StaticClassLiteral<'_>, - metadata: ModelMetadata<'_>, -) -> bool { - if !metadata.accepts_extra(db) { - return false; - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModelInitBehavior { + BaseModel, + CustomVariadic, + Other, +} +fn model_init_behavior(db: &dyn Db, class: StaticClassLiteral<'_>) -> ModelInitBehavior { for base in class .iter_mro(db, None) - .skip(1) .filter_map(ClassBase::into_class) .filter_map(|base| base.static_class_literal(db)) .map(|(base, _)| base) { if base.is_known(db, KnownClass::PydanticBaseModel) { - return true; + return ModelInitBehavior::BaseModel; } // These constructors use variadic keywords for specialized inputs, not arbitrary extras. if base.is_known(db, KnownClass::PydanticRootModel) || base.is_known(db, KnownClass::PydanticBaseSettings) { - return false; + return ModelInitBehavior::Other; } let init = class_member(db, base.body_scope(db), "__init__"); if !init.is_undefined() { - return init + return if init .ignore_possibly_undefined() .and_then(Type::as_function_literal) .is_some_and(|init| { init.signature(db) .iter() .any(|signature| signature.parameters().keyword_variadic().is_some()) - }); + }) { + ModelInitBehavior::CustomVariadic + } else { + ModelInitBehavior::Other + }; } } - false + ModelInitBehavior::Other +} + +/// Return `true` if `class` should accept extra keywords in its synthesized constructor. +pub(in crate::types) fn model_init_accepts_extra( + db: &dyn Db, + class: StaticClassLiteral<'_>, + metadata: ModelMetadata<'_>, +) -> bool { + metadata.accepts_extra(db) + && matches!( + model_init_behavior(db, class), + ModelInitBehavior::BaseModel | ModelInitBehavior::CustomVariadic + ) } /// Create the catch-all keyword parameter for a Pydantic model constructor. From 6fd508aa62401d6196971b6f218d2d2b38e206f9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 13 Jul 2026 09:52:50 -0400 Subject: [PATCH 037/222] Avoid unnecessary identifier lookahead (#26525) ## Summary Identifier lexing previously inspected the following one or two characters for a quote before checking whether the identifier could be a string prefix. Gate those lookaheads behind a shared string-prefix check, so ordinary identifiers avoid unnecessary cursor reads while string-prefix flags continue through the existing path. --- crates/ruff_python_parser/src/lexer.rs | 54 +++++++++++++------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index 968303eab8..911214f5be 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -626,19 +626,24 @@ impl<'src> Lexer<'src> { /// Lex an identifier. Also used for keywords and string/bytes literals with a prefix. fn lex_identifier(&mut self, first: char) -> TokenKind { // Detect potential string like rb'' b'' f'' t'' u'' r'' - let quote = match (first, self.cursor.first()) { - (_, quote @ ('\'' | '"')) => self.try_single_char_prefix(first).then(|| { - self.cursor.bump(); - quote - }), - (_, second) if is_quote(self.cursor.second()) => { - self.try_double_char_prefix([first, second]).then(|| { + let quote = if let Some(prefix) = single_char_prefix(first) { + match self.cursor.first() { + quote @ ('\'' | '"') => { + self.current_flags |= prefix; self.cursor.bump(); - // SAFETY: Safe because of the `is_quote` check in this match arm's guard - self.cursor.bump().unwrap() - }) + Some(quote) + } + second if is_quote(self.cursor.second()) => { + self.try_double_char_prefix([first, second]).then(|| { + self.cursor.bump(); + // SAFETY: Safe because of the `is_quote` check in this match arm's guard + self.cursor.bump().unwrap() + }) + } + _ => None, } - _ => None, + } else { + None }; if let Some(quote) = quote { @@ -719,21 +724,6 @@ impl<'src> Lexer<'src> { } } - /// Try lexing the single character string prefix, updating the token flags accordingly. - /// Returns `true` if it matches. - fn try_single_char_prefix(&mut self, first: char) -> bool { - match first { - 'f' | 'F' => self.current_flags |= TokenFlags::F_STRING, - 't' | 'T' => self.current_flags |= TokenFlags::T_STRING, - 'u' | 'U' => self.current_flags |= TokenFlags::UNICODE_STRING, - 'b' | 'B' => self.current_flags |= TokenFlags::BYTE_STRING, - 'r' => self.current_flags |= TokenFlags::RAW_STRING_LOWERCASE, - 'R' => self.current_flags |= TokenFlags::RAW_STRING_UPPERCASE, - _ => return false, - } - true - } - /// Try lexing the double character string prefix, updating the token flags accordingly. /// Returns `true` if it matches. fn try_double_char_prefix(&mut self, value: [char; 2]) -> bool { @@ -1571,6 +1561,18 @@ const fn is_quote(c: char) -> bool { matches!(c, '\'' | '"') } +fn single_char_prefix(c: char) -> Option { + Some(match c { + 'f' | 'F' => TokenFlags::F_STRING, + 't' | 'T' => TokenFlags::T_STRING, + 'u' | 'U' => TokenFlags::UNICODE_STRING, + 'b' | 'B' => TokenFlags::BYTE_STRING, + 'r' => TokenFlags::RAW_STRING_LOWERCASE, + 'R' => TokenFlags::RAW_STRING_UPPERCASE, + _ => return None, + }) +} + const fn is_ascii_identifier_start(c: char) -> bool { matches!(c, 'a'..='z' | 'A'..='Z' | '_') } From 39281658ff5d73e2786fb3114306153ee44e1ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Mon, 13 Jul 2026 09:36:13 -0700 Subject: [PATCH 038/222] [ty] Prevent type that is written with a code span from being incorrectly re-wrapped during docstring Markdown rendering (#26723) ## Summary This does two things: 1. (abe15fe4) It refactors and consolidates the helpers that we use for parsing runs of backticks (which denote the opening and closing of Markdown fences). This lays a foundation for [rendering Google- and NumPy- style docstrings](https://github.com/astral-sh/ty/issues/1667)[^1]. 2. (3acd8a4d) It fixes a bug in Markdown rendering for reStructuredText docstrings. Previously the parameter type in the following docstring would incorrectly be rendered as a code span containing literal backticks (i.e., `` `str` ``), because we would re-wrap the type in another set of backticks: ```text :param value: The value. :type value: `str` ``` With this change, we now output a type that is already wrapped verbatim (so that the above example renders as `str`). [^1]: Google- and NumPy- style docstrings may contain types that are already wrapped in code spans: ```text Args: value (`str`): The value. ``` ```text Parameters ---------- value : `str` The value. ``` ## Test Plan See included tests. --- .../ty_ide/src/docstring/document/syntax.rs | 75 +++++++++++++++++++ .../src/docstring/markdown/general/inline.rs | 50 ++++--------- .../src/docstring/markdown/structured.rs | 8 +- .../src/docstring/markdown/structured/rst.rs | 15 ++++ 4 files changed, 113 insertions(+), 35 deletions(-) diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index 22128f0d7c..e19561df1d 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -50,6 +50,55 @@ pub(in crate::docstring) fn starts_with_markdown_list_item(line: &str) -> bool { && matches!(bytes.get(digits + 1), Some(b' ' | b'\t')) } +/// Returns whether `text` consists of a complete Markdown code span. +/// +/// For example, this returns `true` for ``"`value`"`` and `false` for +/// ``"`value` trailing"``. +pub(in crate::docstring) fn is_markdown_code_span(text: &str) -> bool { + markdown_code_span_len(text) == Some(TextSize::of(text)) +} + +/// Returns the length of a complete Markdown code span at the start of `text`. +/// +/// For example, given `text` equal to ``"`value` trailing"``, the returned length covers only +/// ``"`value`"``. +/// +/// Lengths are measured in UTF-8 bytes. A closing backtick run must be the same length as the +/// opening run. +pub(in crate::docstring) fn markdown_code_span_len(text: &str) -> Option { + let opening = find_backtick_run(text, TextSize::ZERO)?; + if opening.start() != TextSize::ZERO { + return None; + } + + let mut search_from = opening.end(); + while let Some(closing) = find_backtick_run(text, search_from) { + if closing.len() == opening.len() { + return Some(closing.end()); + } + search_from = closing.end(); + } + + None +} + +/// Returns the byte range of the first consecutive backtick run at or after `from`. +/// +/// For example, searching ``"value `code`"`` from the start returns the range covering the +/// opening ``"`"``. +pub(in crate::docstring) fn find_backtick_run(text: &str, from: TextSize) -> Option { + let from = from.to_usize(); + let start = from + text.get(from..)?.find('`')?; + let len = text[start..] + .bytes() + .take_while(|byte| *byte == b'`') + .count(); + Some(TextRange::new( + TextSize::of(&text[..start]), + TextSize::of(&text[..start + len]), + )) +} + /// Returns the end of an indented Markdown or reStructuredText container block. pub(super) fn container_block_end(lines: &[ParsedLine<'_>], index: usize) -> Option { let marker = lines.get(index)?; @@ -188,3 +237,29 @@ pub(super) fn indentation(line: &str) -> TextSize { }), ) } + +#[cfg(test)] +mod tests { + use ruff_text_size::TextSize; + + use super::markdown_code_span_len; + + #[test] + fn finds_markdown_code_span_len() { + assert_eq!( + markdown_code_span_len("`value` trailing"), + Some(TextSize::of("`value`")) + ); + assert_eq!( + markdown_code_span_len("``value`with:ticks``"), + Some(TextSize::of("``value`with:ticks``")) + ); + assert_eq!( + markdown_code_span_len("`first` second`"), + Some(TextSize::of("`first`")) + ); + assert_eq!(markdown_code_span_len("``value```"), None); + assert_eq!(markdown_code_span_len("``"), None); + assert_eq!(markdown_code_span_len("value"), None); + } +} diff --git a/crates/ty_ide/src/docstring/markdown/general/inline.rs b/crates/ty_ide/src/docstring/markdown/general/inline.rs index f063f2b435..938496b13c 100644 --- a/crates/ty_ide/src/docstring/markdown/general/inline.rs +++ b/crates/ty_ide/src/docstring/markdown/general/inline.rs @@ -49,6 +49,10 @@ use std::borrow::Cow; +use ruff_text_size::TextSize; + +use crate::docstring::document::syntax::{find_backtick_run, markdown_code_span_len}; + /// Exposes an interface for rendering a line of prose that may contain a hyperlink. #[derive(Default)] pub(super) struct Renderer { @@ -272,21 +276,20 @@ enum Candidate<'a> { /// Finds the first complete hyperlink or plausible wrapped candidate in `input`. fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> { - let mut offset = 0; + let mut offset = TextSize::ZERO; // Visit each backtick run that could delimit inline markup. - while let Some(relative_index) = input[offset..].find('`') { - let index = offset + relative_index; - let tick_count = leading_backtick_count(&input[index..]); + while let Some(run) = find_backtick_run(input, offset) { + let index = run.start().to_usize(); // An escaped run is literal text, so continue immediately after it. if is_escaped(input, index) { - offset = index + tick_count; + offset = run.end(); continue; } // Try parsing a link only when a single backtick has valid surrounding characters. - if tick_count == 1 + if run.len() == TextSize::new(1) && is_link_start(input, index) && let Some(candidate) = parse_candidate(&input[index..]) { @@ -294,9 +297,8 @@ fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> { } // Skip other backtick-delimited spans rather than searching inside them. - let after_opening = index + tick_count; - let closing_end = find_closing_backtick_run(&input[after_opening..], tick_count)?; - offset = after_opening + closing_end; + let span_len = markdown_code_span_len(&input[index..])?; + offset = run.start() + span_len; } None @@ -316,7 +318,7 @@ fn parse_candidate(input: &str) -> Option> { return None; } - let Some(relative_closing) = after_opening.find('`') else { + let Some(closing) = find_backtick_run(input, TextSize::new(1)) else { // Eliminate candidates whose content already contains a disallowed // backslash or closing `>`, or whose target cannot become HTTP(S). A // partial URI scheme remains valid so it can wrap immediately after @@ -330,22 +332,21 @@ fn parse_candidate(input: &str) -> Option> { } return Some(Candidate::Pending); }; - let closing_index = relative_closing + 1; - if leading_backtick_count(&input[closing_index..]) != 1 { + if closing.len() != TextSize::new(1) { return None; } - let content = &input[1..closing_index]; + let content = &input[1..closing.start().to_usize()]; if content.contains('\\') { return None; } - let after_closing = &input[closing_index + 1..]; + let after_closing = &input[closing.end().to_usize()..]; let underscore_count = after_closing .bytes() .take_while(|byte| *byte == b'_') .count(); - let len = closing_index + 1 + underscore_count; + let len = closing.end().to_usize() + underscore_count; if !(1..=2).contains(&underscore_count) || !is_link_suffix(&after_closing[underscore_count..]) { return None; } @@ -466,25 +467,6 @@ fn is_rst_section_adornment(line: &str) -> bool { marker.is_ascii_punctuation() && characters.all(|character| character == marker) } -fn leading_backtick_count(input: &str) -> usize { - input.bytes().take_while(|byte| *byte == b'`').count() -} - -fn find_closing_backtick_run(input: &str, opening_tick_count: usize) -> Option { - let mut offset = 0; - - while let Some(relative_index) = input[offset..].find('`') { - let index = offset + relative_index; - let tick_count = leading_backtick_count(&input[index..]); - if tick_count == opening_tick_count { - return Some(index + tick_count); - } - offset = index + tick_count; - } - - None -} - /// Returns whether the backtick at `index` may begin inline markup. /// /// Uses the supported ASCII subset of reStructuredText's diff --git a/crates/ty_ide/src/docstring/markdown/structured.rs b/crates/ty_ide/src/docstring/markdown/structured.rs index 999eb2a7ec..f7519c85a8 100644 --- a/crates/ty_ide/src/docstring/markdown/structured.rs +++ b/crates/ty_ide/src/docstring/markdown/structured.rs @@ -6,7 +6,7 @@ use strum::IntoEnumIterator; use super::general; use crate::docstring::document::SectionKind; use crate::docstring::document::preformatted::MarkdownFence; -use crate::docstring::document::syntax::starts_with_markdown_list_item; +use crate::docstring::document::syntax::{is_markdown_code_span, starts_with_markdown_list_item}; mod rst; @@ -320,6 +320,12 @@ fn description_block_start(description: &str) -> Option { fn render_type_code_span_into(output: &mut String, ty: &str) { let normalized = normalize_type_for_code_span(ty); + + if is_markdown_code_span(&normalized) { + output.push_str(&normalized); + return; + } + render_code_span_into(output, normalized.as_ref()); } diff --git a/crates/ty_ide/src/docstring/markdown/structured/rst.rs b/crates/ty_ide/src/docstring/markdown/structured/rst.rs index 5860163465..6c2a073905 100644 --- a/crates/ty_ide/src/docstring/markdown/structured/rst.rs +++ b/crates/ty_ide/src/docstring/markdown/structured/rst.rs @@ -261,6 +261,21 @@ Summary. "); } + #[test] + fn preserve_code_span_wrapped_type() { + let docstring = "\ +:param value: The value. +:type value: `str` +"; + let rendered = render_docstring(docstring); + + assert_snapshot!(rendered, @" + ## Parameters + **value**: `str` + The value. + "); + } + #[test] fn render_returns_with_supplemental_type() { let docstring = "\ From 8c1f4076b794882ce04995b75f70a08cc471a046 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Mon, 13 Jul 2026 18:18:43 +0100 Subject: [PATCH 039/222] [`pycodestyle`] Add an autofix for `E402` (#22212) ## Summary This implements a fixer for E402, as suggested at https://github.com/astral-sh/ruff/issues/6514. This enables users to move imports up to the top of the file when they are found lower down. This is similar to isort's `float-to-top` logic, though not quite the same. As suggested by @ntBre on that issue, the expected path here is for people to enable unsafe-fixes to access this feature, potentially marking this fix "safe" in their config, rather than to implement an `float-to-top` option in the isort config. Internally this means that there are separate fix edits for each import found to be out of place rather than a single one (the latter being how isort tends to work). The current state of this is a _first draft_. It's working, I've done some manual testing and I have copied over a lot of the handling from the isort-like `organize_imports` function, though I may have missed things. I'm also fairly new to rust and to this project so e.g: I don't know if e.g: the code is the best way to spell some of the concepts. I'm very happy to refactor things if there's better ways, including if e.g: we want to share more code between this and the isort crate -- I've deliberately not done that so far (purely to get to something which works). This remains a bit work-in-progress, so I've included a test project I'm using for testing against though obviously that would be removed before this gets anywhere near merging. (I might also tidy the history a bit too) Fixes https://github.com/astral-sh/ruff/issues/6514 ## Test Plan Manual testing so far, see various demo files currently committed. I'd like to add some tests and will look into that next. It looks like it's mostly snapshot based testing. Do we just add a new test function in `crates/ruff_linter/src/rules/pycodestyle/mod.rs`? Noting them down so they're written, test cases: - interaction with editor organise-imports logic -- with and without the E402 fix being accepted as safe - variations on import spelling - variations of other statements near the import, including on the same line (e.g: peruse the isort tests and pick relevant looking ones) --------- Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> --- .../fixtures/pycodestyle/E402_comments.py | 25 +++ .../fixtures/pycodestyle/E402_docstring.py | 5 + .../test/fixtures/pycodestyle/E402_future.py | 5 + .../test/fixtures/pycodestyle/E402_shebang.py | 5 + .../E402_shebang_docstring_and_future.py | 9 + crates/ruff_linter/src/importer/mod.rs | 32 ++-- crates/ruff_linter/src/preview.rs | 5 + .../ruff_linter/src/rules/pycodestyle/mod.rs | 16 ++ .../rules/module_import_not_at_top_of_file.rs | 54 +++++- ...ules__pycodestyle__tests__E402_E40.py.snap | 6 + ...__pycodestyle__tests__E402_E402.ipynb.snap | 3 + ...s__pycodestyle__tests__E402_E402_0.py.snap | 3 + ...s__pycodestyle__tests__E402_E402_1.py.snap | 2 + ...odestyle__tests__preview__E402_E40.py.snap | 110 ++++++++++++ ...tyle__tests__preview__E402_E402.ipynb.snap | 35 ++++ ...style__tests__preview__E402_E402_0.py.snap | 42 +++++ ...style__tests__preview__E402_E402_1.py.snap | 42 +++++ ...style__tests__preview__E402_E402_2.py.snap | 4 + ...style__tests__preview__E402_E402_3.py.snap | 4 + ...style__tests__preview__E402_E402_4.py.snap | 4 + ...style__tests__preview__E402_E402_5.py.snap | 4 + ...tests__preview__E402_E402_comments.py.snap | 156 ++++++++++++++++++ ...ests__preview__E402_E402_docstring.py.snap | 21 +++ ...__tests__preview__E402_E402_future.py.snap | 21 +++ ..._tests__preview__E402_E402_shebang.py.snap | 20 +++ ..._E402_shebang_docstring_and_future.py.snap | 21 +++ .../api/requests/code_action_resolve.rs | 7 + 27 files changed, 646 insertions(+), 15 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_comments.py create mode 100644 crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_docstring.py create mode 100644 crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_future.py create mode 100644 crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang.py create mode 100644 crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang_docstring_and_future.py create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap create mode 100644 crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_comments.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_comments.py new file mode 100644 index 0000000000..c49e0ce37e --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_comments.py @@ -0,0 +1,25 @@ +import os + +print("something") + +import last + +import late # comment-late + +from late_paren1 import ( # comment-late_paren1 + value +) + +from late_paren2 import ( + value # comment-late_paren2 +) + +from late_paren3 import ( + value +) # comment-late_paren3 + +from late_paren4 import ( + value1, + value2, # comment-late_paren4 + value3, +) diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_docstring.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_docstring.py new file mode 100644 index 0000000000..779de756bb --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_docstring.py @@ -0,0 +1,5 @@ +"""module docstring""" + +print("something") + +import os diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_future.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_future.py new file mode 100644 index 0000000000..ef5e540d4b --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_future.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +print("something") + +import os diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang.py new file mode 100644 index 0000000000..42c502e7be --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang.py @@ -0,0 +1,5 @@ +#!/usr/bin/python3 + +print("something") + +import os diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang_docstring_and_future.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang_docstring_and_future.py new file mode 100644 index 0000000000..ec17d3c9c0 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_shebang_docstring_and_future.py @@ -0,0 +1,9 @@ +#!/usr/bin/python3 + +"""docstring""" + +from __future__ import annotations + +print("something") + +import os diff --git a/crates/ruff_linter/src/importer/mod.rs b/crates/ruff_linter/src/importer/mod.rs index 87c064f8ab..3387a79a96 100644 --- a/crates/ruff_linter/src/importer/mod.rs +++ b/crates/ruff_linter/src/importer/mod.rs @@ -17,8 +17,9 @@ use ruff_python_parser::Parsed; use ruff_python_semantic::{ ImportedName, MemberNameImport, ModuleNameImport, NameImport, SemanticModel, }; -use ruff_python_trivia::textwrap::indent; -use ruff_text_size::{Ranged, TextSize}; +use ruff_python_trivia::{PythonWhitespace, textwrap::indent}; +use ruff_source_file::LineRanges; +use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::cst::matchers::{match_aliases, match_import_from, match_statement}; use crate::fix; @@ -77,16 +78,23 @@ impl<'a> Importer<'a> { // Insert after the last top-level import. Insertion::end_of_statement(stmt, self.source, self.stylist).into_edit(&required_import) } else { - // Check if there are any future imports that we need to respect - if let Some(last_future_import) = self.find_last_future_import() { - // Insert after the last future import - Insertion::end_of_statement(last_future_import, self.source, self.stylist) - .into_edit(&required_import) - } else { - // Insert at the start of the file. - Insertion::start_of_file(self.python_ast, self.source, self.stylist, None) - .into_edit(&required_import) - } + self.add_at_start(&required_import) + } + } + + /// Add an existing import statement to the start of the file. + pub(crate) fn add_import_at_start(&self, import: &Stmt) -> Edit { + let range = TextRange::new(import.start(), self.source.line_end(import.end())); + self.add_at_start(self.source[range].trim_whitespace()) + } + + fn add_at_start(&self, text: &str) -> Edit { + if let Some(last_future_import) = self.find_last_future_import() { + Insertion::end_of_statement(last_future_import, self.source, self.stylist) + .into_edit(text) + } else { + Insertion::start_of_file(self.python_ast, self.source, self.stylist, None) + .into_edit(text) } } diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 36db7f67f9..7de6ef4411 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -335,6 +335,11 @@ pub(crate) const fn is_incorrect_dict_iterator_comprehension_enabled( settings.preview.is_enabled() } +// https://github.com/astral-sh/ruff/pull/22212 +pub(crate) const fn is_e402_fix_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} + // https://github.com/astral-sh/ruff/pull/23260 pub(crate) const fn is_up006_future_annotations_fix_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs index 7ac29d7619..455646e0a1 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs @@ -76,6 +76,22 @@ mod tests { } #[test_case(Rule::LineTooLong, Path::new("E501_5.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E40.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_0.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_1.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_2.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_3.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_4.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_5.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_docstring.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_comments.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_future.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_shebang.py"))] + #[test_case( + Rule::ModuleImportNotAtTopOfFile, + Path::new("E402_shebang_docstring_and_future.py") + )] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402.ipynb"))] #[test_case(Rule::RedundantBackslash, Path::new("E502.py"))] #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_0.py"))] #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_1.py"))] diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs index 3da13717a3..0112808f32 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/module_import_not_at_top_of_file.rs @@ -1,9 +1,11 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{PySourceType, Stmt}; -use ruff_text_size::Ranged; +use ruff_source_file::LineRanges; +use ruff_text_size::{Ranged, TextRange}; -use crate::Violation; use crate::checkers::ast::Checker; +use crate::preview::is_e402_fix_enabled; +use crate::{Edit, Fix, FixAvailability, Violation}; /// ## What it does /// Checks for imports that are not at the top of the file. @@ -38,6 +40,12 @@ use crate::checkers::ast::Checker; /// ## Notebook behavior /// For Jupyter notebooks, this rule checks for imports that are not at the top of a *cell*. /// +/// ## Fix safety +/// This rule's fix is marked as unsafe as imports moved to the top of the file +/// are placed above existing imports, in reverse order than they were in the +/// file. Re-ordering imports is unsafe as it can change the execution order of +/// the imported code. +/// /// [PEP 8]: https://peps.python.org/pep-0008/#imports #[derive(ViolationMetadata)] #[violation_metadata(stable_since = "v0.0.28")] @@ -46,6 +54,8 @@ pub(crate) struct ModuleImportNotAtTopOfFile { } impl Violation for ModuleImportNotAtTopOfFile { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { if self.source_type.is_ipynb() { @@ -54,16 +64,54 @@ impl Violation for ModuleImportNotAtTopOfFile { "Module level import not at top of file".to_string() } } + + fn fix_title(&self) -> Option { + if self.source_type.is_ipynb() { + Some("Move module level imports to top of cell".to_string()) + } else { + Some("Move module level imports to top of file".to_string()) + } + } } /// E402 pub(crate) fn module_import_not_at_top_of_file(checker: &Checker, stmt: &Stmt) { if checker.semantic().seen_import_boundary() && checker.semantic().at_top_level() { - checker.report_diagnostic( + let mut diagnostic = checker.report_diagnostic( ModuleImportNotAtTopOfFile { source_type: checker.source_type, }, stmt.range(), ); + + if !is_e402_fix_enabled(checker.settings()) { + return; + } + + // Support for fixing notebooks is not yet implemented. + if checker.cell_offsets().is_some() { + return; + } + + let indexer = checker.indexer(); + let locator = checker.locator(); + + // Special-cases: there's leading or trailing content in the import block. These + // are too hard to get right, and relatively rare, so flag but don't fix. + if indexer.preceded_by_multi_statement_line(stmt, locator.contents()) + || indexer.followed_by_multi_statement_line(stmt, locator.contents()) + { + return; + } + + let edit = checker.importer().add_import_at_start(stmt); + + // Include trailing comments and the newline in the removal. + let removal_range = TextRange::new(stmt.start(), locator.full_line_end(stmt.end())); + + diagnostic.set_fix(Fix::unsafe_edits( + Edit::range_deletion(removal_range), + [edit], + )); } } diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap index 137665e69c..e5e1c75287 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E40.py.snap @@ -11,6 +11,7 @@ E402 Module level import not at top of file 57 | #: E402 58 | import foo | +help: Move module level imports to top of file E402 Module level import not at top of file --> E40.py:58:1 @@ -22,6 +23,7 @@ E402 Module level import not at top of file 59 | 60 | a = 1 | +help: Move module level imports to top of file E402 Module level import not at top of file --> E40.py:62:1 @@ -33,6 +35,7 @@ E402 Module level import not at top of file 63 | 64 | #: E401 | +help: Move module level imports to top of file E402 Module level import not at top of file --> E40.py:65:1 @@ -42,6 +45,7 @@ E402 Module level import not at top of file | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 66 | import re as regex, string; x = 1 | +help: Move module level imports to top of file E402 Module level import not at top of file --> E40.py:66:1 @@ -53,6 +57,7 @@ E402 Module level import not at top of file 67 | 68 | x = 1; import re as regex, string | +help: Move module level imports to top of file E402 Module level import not at top of file --> E40.py:68:8 @@ -62,3 +67,4 @@ E402 Module level import not at top of file 68 | x = 1; import re as regex, string | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | +help: Move module level imports to top of file diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap index 470676927d..0895a0b946 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402.ipynb.snap @@ -11,6 +11,7 @@ E402 Module level import not at top of cell 10 | 11 | import a | +help: Move module level imports to top of cell E402 Module level import not at top of cell --> E402.ipynb:22:1 @@ -21,6 +22,7 @@ E402 Module level import not at top of cell | ^^^^^^^^ 23 | import ok | +help: Move module level imports to top of cell E402 Module level import not at top of cell --> E402.ipynb:30:1 @@ -30,3 +32,4 @@ E402 Module level import not at top of cell 31 | 32 | %%time | +help: Move module level imports to top of cell diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap index f3a5e4f3f1..758432087d 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_0.py.snap @@ -9,6 +9,7 @@ E402 Module level import not at top of file 35 | import h | ^^^^^^^^ | +help: Move module level imports to top of file E402 Module level import not at top of file --> E402_0.py:45:1 @@ -18,6 +19,7 @@ E402 Module level import not at top of file 45 | import k; import l | ^^^^^^^^ | +help: Move module level imports to top of file E402 Module level import not at top of file --> E402_0.py:45:11 @@ -27,3 +29,4 @@ E402 Module level import not at top of file 45 | import k; import l | ^^^^^^^^ | +help: Move module level imports to top of file diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap index 2e45cb19b6..1309f16b55 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_1.py.snap @@ -11,6 +11,7 @@ E402 Module level import not at top of file 6 | 7 | """Some other docstring.""" | +help: Move module level imports to top of file E402 Module level import not at top of file --> E402_1.py:9:1 @@ -20,3 +21,4 @@ E402 Module level import not at top of file 9 | import c | ^^^^^^^^ | +help: Move module level imports to top of file diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap new file mode 100644 index 0000000000..178481cc69 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E40.py.snap @@ -0,0 +1,110 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E40.py:56:1 + | +54 | VERSION = '1.2.3' +55 | +56 | import foo + | ^^^^^^^^^^ +57 | #: E402 +58 | import foo + | +help: Move module level imports to top of file + | +1 | #: E401 +2 + import foo +3 | import os, sys +-------------------------------------------------------------------------------- +56 | + - import foo +57 | #: E402 + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E40.py:58:1 + | +56 | import foo +57 | #: E402 +58 | import foo + | ^^^^^^^^^^ +59 | +60 | a = 1 + | +help: Move module level imports to top of file + | +1 | #: E401 +2 + import foo +3 | import os, sys +-------------------------------------------------------------------------------- +58 | #: E402 + - import foo +59 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E40.py:62:1 + | +60 | a = 1 +61 | +62 | import bar + | ^^^^^^^^^^ +63 | +64 | #: E401 + | +help: Move module level imports to top of file + | +1 | #: E401 +2 + import bar +3 | import os, sys +-------------------------------------------------------------------------------- +62 | + - import bar +63 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E40.py:65:1 + | +64 | #: E401 +65 | import re as regex, string # also with a comment! + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ +66 | import re as regex, string; x = 1 + | +help: Move module level imports to top of file + | +1 | #: E401 +2 + import re as regex, string # also with a comment! +3 | import os, sys +-------------------------------------------------------------------------------- +65 | #: E401 + - import re as regex, string # also with a comment! +66 | import re as regex, string; x = 1 + | +note: This is an unsafe fix and may change runtime behavior + +E402 Module level import not at top of file + --> E40.py:66:1 + | +64 | #: E401 +65 | import re as regex, string # also with a comment! +66 | import re as regex, string; x = 1 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ +67 | +68 | x = 1; import re as regex, string + | +help: Move module level imports to top of file + +E402 Module level import not at top of file + --> E40.py:68:8 + | +66 | import re as regex, string; x = 1 +67 | +68 | x = 1; import re as regex, string + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Move module level imports to top of file diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap new file mode 100644 index 0000000000..0895a0b946 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402.ipynb.snap @@ -0,0 +1,35 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 Module level import not at top of cell + --> E402.ipynb:9:1 + | + 7 | os.path + 8 | + 9 | import pathlib + | ^^^^^^^^^^^^^^ +10 | +11 | import a + | +help: Move module level imports to top of cell + +E402 Module level import not at top of cell + --> E402.ipynb:22:1 + | +20 | __some__magic = 1 +21 | +22 | import c + | ^^^^^^^^ +23 | import ok + | +help: Move module level imports to top of cell + +E402 Module level import not at top of cell + --> E402.ipynb:30:1 + | +30 | import no_ok + | ^^^^^^^^^^^^ +31 | +32 | %%time + | +help: Move module level imports to top of cell diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap new file mode 100644 index 0000000000..7701ecae60 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_0.py.snap @@ -0,0 +1,42 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_0.py:35:1 + | +33 | __some__magic = 1 +34 | +35 | import h + | ^^^^^^^^ + | +help: Move module level imports to top of file + | +1 | """Top-level docstring.""" +2 + import h +3 | +-------------------------------------------------------------------------------- +35 | + - import h +36 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 Module level import not at top of file + --> E402_0.py:45:1 + | +43 | import j +44 | +45 | import k; import l + | ^^^^^^^^ + | +help: Move module level imports to top of file + +E402 Module level import not at top of file + --> E402_0.py:45:11 + | +43 | import j +44 | +45 | import k; import l + | ^^^^^^^^ + | +help: Move module level imports to top of file diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap new file mode 100644 index 0000000000..45ed06923e --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_1.py.snap @@ -0,0 +1,42 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_1.py:5:1 + | +3 | """Some other docstring.""" +4 | +5 | import b + | ^^^^^^^^ +6 | +7 | """Some other docstring.""" + | +help: Move module level imports to top of file + | +1 + import b +2 | import a +3 | +4 | """Some other docstring.""" +5 | + - import b +6 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_1.py:9:1 + | +7 | """Some other docstring.""" +8 | +9 | import c + | ^^^^^^^^ + | +help: Move module level imports to top of file + | +1 + import c +2 | import a +-------------------------------------------------------------------------------- +9 | + - import c + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap new file mode 100644 index 0000000000..6dcc4546f1 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_2.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap new file mode 100644 index 0000000000..6dcc4546f1 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_3.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap new file mode 100644 index 0000000000..6dcc4546f1 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_4.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap new file mode 100644 index 0000000000..6dcc4546f1 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_5.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap new file mode 100644 index 0000000000..605ceda0f9 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_comments.py.snap @@ -0,0 +1,156 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_comments.py:5:1 + | +3 | print("something") +4 | +5 | import last + | ^^^^^^^^^^^ +6 | +7 | import late # comment-late + | +help: Move module level imports to top of file + | +1 + import last +2 | import os +3 | +4 | print("something") +5 | + - import last +6 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_comments.py:7:1 + | +5 | import last +6 | +7 | import late # comment-late + | ^^^^^^^^^^^ +8 | +9 | from late_paren1 import ( # comment-late_paren1 + | +help: Move module level imports to top of file + | +1 + import late # comment-late +2 | import os +-------------------------------------------------------------------------------- +7 | + - import late # comment-late +8 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_comments.py:9:1 + | + 7 | import late # comment-late + 8 | + 9 | / from late_paren1 import ( # comment-late_paren1 +10 | | value +11 | | ) + | |_^ +12 | +13 | from late_paren2 import ( + | +help: Move module level imports to top of file + | +1 + from late_paren1 import ( # comment-late_paren1 +2 + value +3 + ) +4 | import os +-------------------------------------------------------------------------------- +11 | + - from late_paren1 import ( # comment-late_paren1 + - value + - ) +12 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_comments.py:13:1 + | +11 | ) +12 | +13 | / from late_paren2 import ( +14 | | value # comment-late_paren2 +15 | | ) + | |_^ +16 | +17 | from late_paren3 import ( + | +help: Move module level imports to top of file + | +1 + from late_paren2 import ( +2 + value # comment-late_paren2 +3 + ) +4 | import os +-------------------------------------------------------------------------------- +15 | + - from late_paren2 import ( + - value # comment-late_paren2 + - ) +16 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_comments.py:17:1 + | +15 | ) +16 | +17 | / from late_paren3 import ( +18 | | value +19 | | ) # comment-late_paren3 + | |_^ +20 | +21 | from late_paren4 import ( + | +help: Move module level imports to top of file + | +1 + from late_paren3 import ( +2 + value +3 + ) # comment-late_paren3 +4 | import os +-------------------------------------------------------------------------------- +19 | + - from late_paren3 import ( + - value + - ) # comment-late_paren3 +20 | + | +note: This is an unsafe fix and may change runtime behavior + +E402 [*] Module level import not at top of file + --> E402_comments.py:21:1 + | +19 | ) # comment-late_paren3 +20 | +21 | / from late_paren4 import ( +22 | | value1, +23 | | value2, # comment-late_paren4 +24 | | value3, +25 | | ) + | |_^ + | +help: Move module level imports to top of file + | +1 + from late_paren4 import ( +2 + value1, +3 + value2, # comment-late_paren4 +4 + value3, +5 + ) +6 | import os +-------------------------------------------------------------------------------- +25 | + - from late_paren4 import ( + - value1, + - value2, # comment-late_paren4 + - value3, + - ) + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap new file mode 100644 index 0000000000..1fc4af4423 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_docstring.py.snap @@ -0,0 +1,21 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_docstring.py:5:1 + | +3 | print("something") +4 | +5 | import os + | ^^^^^^^^^ + | +help: Move module level imports to top of file + | +1 | """module docstring""" +2 + import os +3 | +4 | print("something") +5 | + - import os + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap new file mode 100644 index 0000000000..e7598f7f42 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_future.py.snap @@ -0,0 +1,21 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_future.py:5:1 + | +3 | print("something") +4 | +5 | import os + | ^^^^^^^^^ + | +help: Move module level imports to top of file + | +1 | from __future__ import annotations +2 + import os +3 | +4 | print("something") +5 | + - import os + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap new file mode 100644 index 0000000000..0fa105895f --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang.py.snap @@ -0,0 +1,20 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_shebang.py:5:1 + | +3 | print("something") +4 | +5 | import os + | ^^^^^^^^^ + | +help: Move module level imports to top of file + | +2 | +3 + import os +4 | print("something") +5 | + - import os + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap new file mode 100644 index 0000000000..1f30b27fde --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E402_E402_shebang_docstring_and_future.py.snap @@ -0,0 +1,21 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E402 [*] Module level import not at top of file + --> E402_shebang_docstring_and_future.py:9:1 + | +7 | print("something") +8 | +9 | import os + | ^^^^^^^^^ + | +help: Move module level imports to top of file + | +5 | from __future__ import annotations +6 + import os +7 | +8 | print("something") +9 | + - import os + | +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_server/src/server/api/requests/code_action_resolve.rs b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs index 9e34ab6be0..4ccc3cab4d 100644 --- a/crates/ruff_server/src/server/api/requests/code_action_resolve.rs +++ b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs @@ -148,6 +148,13 @@ pub(super) fn organize_imports_edit( linter_settings.rules = [ Rule::UnsortedImports, // I001 Rule::MissingRequiredImport, // I002 + // Note: ModuleImportNotAtTopOfFile's fixes are unsafe. We include them + // here in order to match isort's behaviour and what we believe + // developers want. Since the fixes are unsafe, we're relying on this + // edit action not performing them unless the user has opted-in to these + // fixes in their settings (i.e: `extend-safe-fixes` in the + // `pyproject.toml` or similar). + Rule::ModuleImportNotAtTopOfFile, // E402 ] .into_iter() .collect(); From aaa594c24e9ceb1620d17f2ffc22037c1605211a Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 13 Jul 2026 10:40:41 -0700 Subject: [PATCH 040/222] [ty] Treat transparent callable decorators consistently in class assignments (#26720) ## Summary Recognize transparent callable decorators in class-body assignment form, not just decorator syntax. Preserve descriptor behavior when a decorator `Callable[P, R] -> Callable[P, R]` is called in an assignment to a method name inside a class. Closes https://github.com/astral-sh/ty/issues/3961 ## Testing Update mdtests. --- .../mdtest/call/callables_as_descriptors.md | 31 ++-- .../src/types/infer/builder.rs | 149 +++++++++++------- 2 files changed, 112 insertions(+), 68 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md index fd0746200d..7e50e13b19 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md +++ b/crates/ty_python_semantic/resources/mdtest/call/callables_as_descriptors.md @@ -174,27 +174,34 @@ class C3: reveal_type(C3().method_decorated(1)) # revealed: int | str ``` -Note that we currently only apply this heuristic when calling a function such as `memoize` via the -decorator syntax. This is inconsistent, because the above *should* be equivalent to the following, -but here we emit errors: +Transparent decorators are also treated consistently when spelled as an equivalent assignment: ```py -def memoize3(f: Callable[[C4, int], str]) -> Callable[[C4, int], str]: - raise NotImplementedError - class C4: def method(self, x: int) -> str: return str(x) - method_decorated = memoize3(method) + method_decorated = memoize(method) -# error: [missing-argument] -# error: [invalid-argument-type] C4().method_decorated(1) ``` -The reason for this is that the heuristic is problematic. We don't *know* that the `Callable` in the -return type of `memoize` is actually related to the method that we pass in. But when `memoize` is -applied as a decorator, it is reasonable to assume so. +For non-transparent decorators, avoid resolving the decorated function's signature before the +decorator itself has been rejected. Doing so can introduce a cycle when the signature refers back to +the decorated name: + +```py +decorated = lambda: decorated +try: + pass +except* Exception: + pass + +unknown_decorator: Any + +@unknown_decorator # error: [unresolved-reference] +def decorated(argument: lambda: decorated, /): # error: [invalid-type-form] + pass +``` In general, a function call might however return a `Callable` that is unrelated to the argument passed in. And here, it seems more reasonable and safe to treat the `Callable` as a non-descriptor. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index b31187c9ec..a88b63c272 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -361,6 +361,57 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// An expression cache shared across builders during multi-inference. type ExpressionCache<'db> = FxHashMap<(ExpressionNodeKey, TypeContext<'db>), Type<'db>>; +fn callable_paramspec_and_return_typevar<'db>( + db: &'db dyn Db, + ty: Type<'db>, +) -> Option<(BoundTypeVarInstance<'db>, BoundTypeVarInstance<'db>)> { + let callable = ty.resolve_type_alias(db).as_callable()?; + if callable.kind(db) != CallableTypeKind::Regular { + return None; + } + let [signature] = callable.signatures(db).overloads.as_slice() else { + return None; + }; + let paramspec = signature.parameters().as_paramspec()?; + Some((paramspec, signature.return_ty.as_typevar()?)) +} + +fn transparent_callable_decorator_result<'db>( + db: &'db dyn Db, + decorator_ty: Type<'db>, + decorated_ty: Type<'db>, +) -> Option> { + if !matches!(decorated_ty, Type::FunctionLiteral(_) | Type::Callable(_)) { + return None; + } + let decorator_callable = decorator_ty + .try_upcast_to_callable(db) + .and_then(CallableTypes::exactly_one)?; + let decorator_signatures = decorator_callable.signatures(db); + let [decorator_signature] = decorator_signatures.overloads.as_slice() else { + return None; + }; + let [parameter] = decorator_signature.parameters().as_slice() else { + return None; + }; + + let (parameter_callable_paramspec, parameter_callable_return_typevar) = + callable_paramspec_and_return_typevar(db, parameter.annotated_type())?; + let (return_callable_paramspec, return_callable_typevar) = + callable_paramspec_and_return_typevar(db, decorator_signature.return_ty)?; + if !parameter_callable_paramspec.is_same_typevar_as(db, return_callable_paramspec) + || !parameter_callable_return_typevar.is_same_typevar_as(db, return_callable_typevar) + { + return None; + } + + match decorated_ty { + Type::FunctionLiteral(function) => Some(Type::Callable(function.into_callable_type(db))), + Type::Callable(_) => Some(decorated_ty), + _ => None, + } +} + impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// How big a string do we build before bailing? /// @@ -3132,6 +3183,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }; + let ty = if target.as_name_expr().is_some() + && self + .index + .scope(self.scope().file_scope_id(self.db())) + .kind() + == ScopeKind::Class + { + self.apply_desugared_decorator(callable_type, call_expr, ty) + } else { + ty + }; + self.store_expression_type(value, ty); ty } else { @@ -4680,6 +4743,33 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_expression(expression, TypeContext::default()) } + /// Preserve the descriptor behavior of a transparent callable decorator when it is written + /// as the equivalent assignment form in a class body. + fn apply_desugared_decorator( + &mut self, + decorator_ty: Type<'db>, + call_expression: &ast::ExprCall, + return_ty: Type<'db>, + ) -> Type<'db> { + let arguments = &call_expression.arguments; + let [decorated_expression] = &arguments.args[..] else { + return return_ty; + }; + if !arguments.keywords.is_empty() || decorated_expression.is_starred_expr() { + return return_ty; + } + + let decorated_ty = + self.get_or_infer_expression(decorated_expression, TypeContext::default()); + let call_arguments = CallArguments::positional([decorated_ty]); + if decorator_ty.try_call(self.db(), &call_arguments).is_err() { + return return_ty; + } + + transparent_callable_decorator_result(self.db(), decorator_ty, decorated_ty) + .unwrap_or(return_ty) + } + /// Apply a decorator to a function or class type and return the resulting type. /// /// Constructor semantics for class-like decorators are handled by `Type::bindings`, so we @@ -4744,57 +4834,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - fn callable_paramspec_and_return_typevar<'db>( - db: &'db dyn Db, - ty: Type<'db>, - ) -> Option<(BoundTypeVarInstance<'db>, BoundTypeVarInstance<'db>)> { - let callable = ty.resolve_type_alias(db).as_callable()?; - if callable.kind(db) != CallableTypeKind::Regular { - return None; - } - let [signature] = callable.signatures(db).overloads.as_slice() else { - return None; - }; - let paramspec = signature.parameters().as_paramspec()?; - Some((paramspec, signature.return_ty.as_typevar()?)) - } - - fn is_transparent_callable_decorator<'d>( - db: &'d dyn Db, - decorator_ty: Type<'d>, - decorated_ty: Type<'d>, - ) -> bool { - if !matches!(decorated_ty, Type::FunctionLiteral(_) | Type::Callable(_)) { - return false; - } - let Some(decorator_callable) = decorator_ty - .try_upcast_to_callable(db) - .and_then(CallableTypes::exactly_one) - else { - return false; - }; - let decorator_signatures = decorator_callable.signatures(db); - let [decorator_signature] = decorator_signatures.overloads.as_slice() else { - return false; - }; - let [parameter] = decorator_signature.parameters().as_slice() else { - return false; - }; - - let Some((parameter_callable_paramspec, parameter_callable_return_typevar)) = - callable_paramspec_and_return_typevar(db, parameter.annotated_type()) - else { - return false; - }; - let Some((return_callable_paramspec, return_callable_typevar)) = - callable_paramspec_and_return_typevar(db, decorator_signature.return_ty) - else { - return false; - }; - parameter_callable_paramspec.is_same_typevar_as(db, return_callable_paramspec) - && parameter_callable_return_typevar.is_same_typevar_as(db, return_callable_typevar) - } - // For FunctionLiteral, get the kind directly without computing the full signature. // This avoids a query cycle when the function has default parameter values, since // computing the signature requires evaluating those defaults which may trigger @@ -4833,12 +4872,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // TODO: Remove this special case once the new constraint solver can preserve // per-overload ParamSpec/return correlations for `Callable[P, R] -> Callable[P, R]`. if decorator_call_succeeded - && is_transparent_callable_decorator(self.db(), decorator_ty, decorated_ty) - && let Some(callable) = decorated_ty - .try_upcast_to_callable(self.db()) - .and_then(CallableTypes::exactly_one) + && let Some(result) = + transparent_callable_decorator_result(self.db(), decorator_ty, decorated_ty) { - return Type::Callable(callable); + return result; } // When a method on a class is decorated with a function that returns a From 124acf11d9d9f8c403845870e18a9924f3e5c3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Mon, 13 Jul 2026 13:41:24 -0700 Subject: [PATCH 041/222] [ty] Represent Markdown code spans in docstrings as text ranges (#26777) ## Summary This is a small addendum to https://github.com/astral-sh/ruff/pull/26723 that does two things: 1. Centralizes another backtick helper (`is_backtick_run_escaped`). 2. Partially follows up on [this review feedback](https://github.com/astral-sh/ruff/pull/26723#discussion_r3568841410) by making the Markdown code-span helpers to represent complete spans as `TextRange`s rather than only returning their lengths. The result is an interface that is clearer and a bit simpler to consume. ## Test Plan See included tests. --- .../ty_ide/src/docstring/document/syntax.rs | 90 ++++++++++--------- .../src/docstring/markdown/general/inline.rs | 18 ++-- 2 files changed, 51 insertions(+), 57 deletions(-) diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index e19561df1d..78908e3bbb 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -55,31 +55,8 @@ pub(in crate::docstring) fn starts_with_markdown_list_item(line: &str) -> bool { /// For example, this returns `true` for ``"`value`"`` and `false` for /// ``"`value` trailing"``. pub(in crate::docstring) fn is_markdown_code_span(text: &str) -> bool { - markdown_code_span_len(text) == Some(TextSize::of(text)) -} - -/// Returns the length of a complete Markdown code span at the start of `text`. -/// -/// For example, given `text` equal to ``"`value` trailing"``, the returned length covers only -/// ``"`value`"``. -/// -/// Lengths are measured in UTF-8 bytes. A closing backtick run must be the same length as the -/// opening run. -pub(in crate::docstring) fn markdown_code_span_len(text: &str) -> Option { - let opening = find_backtick_run(text, TextSize::ZERO)?; - if opening.start() != TextSize::ZERO { - return None; - } - - let mut search_from = opening.end(); - while let Some(closing) = find_backtick_run(text, search_from) { - if closing.len() == opening.len() { - return Some(closing.end()); - } - search_from = closing.end(); - } - - None + find_backtick_run(text, TextSize::ZERO).and_then(|opening| markdown_code_span(text, opening)) + == Some(TextRange::up_to(TextSize::of(text))) } /// Returns the byte range of the first consecutive backtick run at or after `from`. @@ -99,6 +76,36 @@ pub(in crate::docstring) fn find_backtick_run(text: &str, from: TextSize) -> Opt )) } +/// Returns the Markdown code span delimited by `opening`, if it has a matching closing run. +/// +/// For example, the opening run in "``value`with:ticks`` trailing" produces the range covering +/// "``value`with:ticks``". +pub(in crate::docstring) fn markdown_code_span( + text: &str, + opening: TextRange, +) -> Option { + let mut search_from = opening.end(); + loop { + let closing = find_backtick_run(text, search_from)?; + if closing.len() == opening.len() { + return Some(opening.cover(closing)); + } + search_from = closing.end(); + } +} + +/// Returns whether the backtick run at `index` is escaped by a preceding backslash. +/// +/// For example, the backtick in ``"\`"`` is escaped, while the backtick in ``"\\`"`` is not. +pub(in crate::docstring) fn is_backtick_run_escaped(text: &str, index: usize) -> bool { + !text[..index] + .bytes() + .rev() + .take_while(|byte| *byte == b'\\') + .count() + .is_multiple_of(2) +} + /// Returns the end of an indented Markdown or reStructuredText container block. pub(super) fn container_block_end(lines: &[ParsedLine<'_>], index: usize) -> Option { let marker = lines.get(index)?; @@ -240,26 +247,21 @@ pub(super) fn indentation(line: &str) -> TextSize { #[cfg(test)] mod tests { - use ruff_text_size::TextSize; - - use super::markdown_code_span_len; + use super::is_markdown_code_span; #[test] - fn finds_markdown_code_span_len() { - assert_eq!( - markdown_code_span_len("`value` trailing"), - Some(TextSize::of("`value`")) - ); - assert_eq!( - markdown_code_span_len("``value`with:ticks``"), - Some(TextSize::of("``value`with:ticks``")) - ); - assert_eq!( - markdown_code_span_len("`first` second`"), - Some(TextSize::of("`first`")) - ); - assert_eq!(markdown_code_span_len("``value```"), None); - assert_eq!(markdown_code_span_len("``"), None); - assert_eq!(markdown_code_span_len("value"), None); + fn recognizes_complete_markdown_code_spans() { + for (text, expected) in [ + ("`value`", true), + ("``value`with:ticks``", true), + ("`value` trailing", false), + ("before `value`", false), + ("`first` second`", false), + ("``value```", false), + ("``", false), + ("value", false), + ] { + assert_eq!(is_markdown_code_span(text), expected, "{text:?}"); + } } } diff --git a/crates/ty_ide/src/docstring/markdown/general/inline.rs b/crates/ty_ide/src/docstring/markdown/general/inline.rs index 938496b13c..2c79fa8852 100644 --- a/crates/ty_ide/src/docstring/markdown/general/inline.rs +++ b/crates/ty_ide/src/docstring/markdown/general/inline.rs @@ -51,7 +51,9 @@ use std::borrow::Cow; use ruff_text_size::TextSize; -use crate::docstring::document::syntax::{find_backtick_run, markdown_code_span_len}; +use crate::docstring::document::syntax::{ + find_backtick_run, is_backtick_run_escaped, markdown_code_span, +}; /// Exposes an interface for rendering a line of prose that may contain a hyperlink. #[derive(Default)] @@ -283,7 +285,7 @@ fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> { let index = run.start().to_usize(); // An escaped run is literal text, so continue immediately after it. - if is_escaped(input, index) { + if is_backtick_run_escaped(input, index) { offset = run.end(); continue; } @@ -297,8 +299,7 @@ fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> { } // Skip other backtick-delimited spans rather than searching inside them. - let span_len = markdown_code_span_len(&input[index..])?; - offset = run.start() + span_len; + offset = markdown_code_span(input, run)?.end(); } None @@ -502,15 +503,6 @@ fn is_link_suffix(input: &str) -> bool { }) } -fn is_escaped(input: &str, index: usize) -> bool { - !input[..index] - .bytes() - .rev() - .take_while(|byte| *byte == b'\\') - .count() - .is_multiple_of(2) -} - fn render_markdown_link(output: &mut String, label: Option<&str>, uri: &str) { output.push('['); if let Some(label) = label { From 6f2f4862aa2ca0460dcc657efabf256bf1322dfb Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 13 Jul 2026 22:59:17 +0200 Subject: [PATCH 042/222] [ty] Pydantic: Warn about immediately discarded extra arguments (#26762) ## Summary By default, Pydantic allows arbitrary extra constructor arguments, which is simply ignored (`extra="ignore"`). This often indicates a mistake though, so we now emit emit a new diagnostic with severity "warning": ```py class Person(BaseModel): name: str admin: bool = Field(default=false) # a typo: should be `admin`, not `admni` Person(name="Alice", admni=true) # warning: [pydantic-discarded-extra-argument] ``` closes https://github.com/astral-sh/ty/issues/3966 ## Ecosystem impact One true positive. ## Test plan New and updated Markdown tests --- crates/ty/docs/rules.md | 266 ++++++++++-------- .../pydantic-discarded-extra-argument.md | 26 ++ .../resources/mdtest/external/pydantic.md | 45 ++- .../ty_python_semantic/src/types/call/bind.rs | 24 ++ .../src/types/call/bind/constructor.rs | 10 + .../src/types/dedicated/pydantic.rs | 79 +++++- .../src/types/diagnostic.rs | 10 + .../src/types/infer/builder.rs | 5 + ty.schema.json | 10 + 9 files changed, 345 insertions(+), 130 deletions(-) create mode 100644 crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 2c87d4cd3e..380c593097 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -54,7 +54,7 @@ class Derived(Base): # error Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -118,7 +118,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -201,7 +201,7 @@ value = unknown # ty: ignore[unresolved-reference] Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -256,7 +256,7 @@ Foo.method() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -284,7 +284,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -319,7 +319,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -353,7 +353,7 @@ a = 1 # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -388,7 +388,7 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -424,7 +424,7 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -460,7 +460,7 @@ type B = A # error Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -497,7 +497,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -536,7 +536,7 @@ old_func() # error: [deprecated] Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -569,7 +569,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -600,7 +600,7 @@ class B(A, A): ... # error Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -643,7 +643,7 @@ class A: # error Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -720,7 +720,7 @@ def foo() -> "intt\b": ... # error Default level: warn · Added in 0.0.50 · Related issues · -View source +View source @@ -760,7 +760,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax] Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -795,7 +795,7 @@ def my_function() -> int: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -911,7 +911,7 @@ def test() -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -947,7 +947,7 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -977,7 +977,7 @@ t[3] # error Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -1014,7 +1014,7 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -1115,7 +1115,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1147,7 +1147,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1178,7 +1178,7 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1236,7 +1236,7 @@ C.instance_only_var = 56 # error Default level: error · Added in 0.0.33 · Related issues · -View source +View source @@ -1282,7 +1282,7 @@ class Sub(Base): Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1324,7 +1324,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1351,7 +1351,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1381,7 +1381,7 @@ with 1: # error Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1434,7 +1434,7 @@ See: Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -1470,7 +1470,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1502,7 +1502,7 @@ a: str # error Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -1559,7 +1559,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1623,7 +1623,7 @@ This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](h Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1676,7 +1676,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1727,7 +1727,7 @@ class NonFrozenChild(FrozenBase): # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1776,7 +1776,7 @@ class D(Generic[U, T]): ... # error Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1872,7 +1872,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1920,7 +1920,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -1982,7 +1982,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2022,7 +2022,7 @@ def f(t: TypeVar("U")): ... # ty: ignore[invalid-type-form] Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -2072,7 +2072,7 @@ match object(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2107,7 +2107,7 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2225,7 +2225,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -2282,7 +2282,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: warn · Added in 0.0.31 · Related issues · -View source +View source @@ -2330,7 +2330,7 @@ admin[0] # "Alice" Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -2368,7 +2368,7 @@ Baz = NewType("Baz", int | str) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2425,7 +2425,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2454,7 +2454,7 @@ def f(a: int = ""): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2490,7 +2490,7 @@ P2 = ParamSpec() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2526,7 +2526,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2597,7 +2597,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2629,7 +2629,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2740,7 +2740,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2791,7 +2791,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -2832,7 +2832,7 @@ NewAlias = TypeAliasType(get_name(), int) # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2899,7 +2899,7 @@ Bar[int] # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2932,7 +2932,7 @@ TYPE_CHECKING = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2968,7 +2968,7 @@ b: Annotated[int] # error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -3015,7 +3015,7 @@ is_int(value=1) # error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -3072,7 +3072,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -3116,7 +3116,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3173,7 +3173,7 @@ V = TypeVar("V", list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -3215,7 +3215,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -3251,7 +3251,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -3294,7 +3294,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -3329,7 +3329,7 @@ class Foo(TypedDict): Default level: error · Added in 0.0.25 · Related issues · -View source +View source @@ -3364,7 +3364,7 @@ def gen() -> Iterator[int]: Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -3431,7 +3431,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -3481,7 +3481,7 @@ def g(arg: object): Default level: warn · Added in 0.0.30 · Related issues · -View source +View source @@ -3524,7 +3524,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3555,7 +3555,7 @@ func() # error Default level: ignore · Added in 0.0.41 · Related issues · -View source +View source @@ -3614,7 +3614,7 @@ class ExplicitChild(Parent): Default level: ignore · Added in 0.0.45 · Related issues · -View source +View source @@ -3653,7 +3653,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -3692,7 +3692,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3730,7 +3730,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.30 · Related issues · -View source +View source @@ -3768,7 +3768,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3797,7 +3797,7 @@ for i in 34: # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3825,7 +3825,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -3862,7 +3862,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -3899,7 +3899,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3930,7 +3930,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3961,7 +3961,7 @@ f(x=1) # error Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4000,7 +4000,7 @@ A.c # error Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4039,7 +4039,7 @@ A()[0] # error Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4085,7 +4085,7 @@ from module import a # error Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -4117,7 +4117,7 @@ html.parser # error Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4148,6 +4148,46 @@ for i in range(int(input())): print(x) # error ``` +## `pydantic-discarded-extra-argument` + + +Default level: warn · +Added in 0.0.60 · +Related issues · +View source + + + +**What it does** + + +Checks for extra keyword arguments that Pydantic silently discards when a model uses +`extra="ignore"`, either implicitly or explicitly. + +**Why is this bad?** + + +A discarded argument has no effect on the constructed model, but it may indicate a misspelled field +name or an incorrect assumption about the model's schema. + +**Example** + + +```ignore +from pydantic import BaseModel + + +class User(BaseModel): + name: str + admin: bool = False + + +user = User(name="Alice", admni=True) # error: [pydantic-discarded-extra-argument] +``` + +If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument, +or explicitly configure the model with `extra="allow"`. + ## `raw-string-type-annotation` @@ -4189,7 +4229,7 @@ def test() -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4224,7 +4264,7 @@ cast(int, f()) # error Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -4262,7 +4302,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -4306,7 +4346,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4341,7 +4381,7 @@ static_assert(int(2.0 * 3.0) == 6) # error Default level: warn · Added in 0.0.39 · Related issues · -View source +View source @@ -4392,7 +4432,7 @@ Consider using [`functools.total_ordering`][total_ordering] instead, which does Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4426,7 +4466,7 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -4466,7 +4506,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4496,7 +4536,7 @@ f("foo") # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4535,7 +4575,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4593,7 +4633,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -4637,7 +4677,7 @@ class C(Generic[T]): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4666,7 +4706,7 @@ reveal_type(1) # revealed: Literal[1] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4697,7 +4737,7 @@ f(x=1, y=2) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4730,7 +4770,7 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -4805,7 +4845,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4834,7 +4874,7 @@ import foo # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4862,7 +4902,7 @@ print(x) # error Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -4909,7 +4949,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4958,7 +4998,7 @@ b1 < b2 < b1 # error Default level: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -5005,7 +5045,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5038,7 +5078,7 @@ A() + A() # error Default level: warn · Added in 0.0.21 · Related issues · -View source +View source @@ -5158,7 +5198,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -5237,7 +5277,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md new file mode 100644 index 0000000000..60fc667364 --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md @@ -0,0 +1,26 @@ +## What it does + +Checks for extra keyword arguments that Pydantic silently discards when a model uses +`extra="ignore"`, either implicitly or explicitly. + +## Why is this bad? + +A discarded argument has no effect on the constructed model, but it may indicate a misspelled field +name or an incorrect assumption about the model's schema. + +## Example + +```ignore +from pydantic import BaseModel + + +class User(BaseModel): + name: str + admin: bool = False + + +user = User(name="Alice", admni=True) # error: [pydantic-discarded-extra-argument] +``` + +If the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument, +or explicitly configure the model with `extra="allow"`. diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index 11f14553d4..63ac983339 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -753,7 +753,8 @@ ValidationAlias(alias=1) # error: [missing-argument] ## Extra fields -By default, Pydantic allows arbitrary extra data which is simply ignored: +By default, Pydantic allows arbitrary extra data which is simply ignored. This often indicates a +mistake though, so ty emits a warning by default: ```py from pydantic import BaseModel, ConfigDict @@ -761,28 +762,24 @@ from pydantic import BaseModel, ConfigDict class Person(BaseModel): name: str -Person(name="Alice", something_else=7) +Person(name="Alice", something_else=7) # error: [pydantic-discarded-extra-argument] ``` -By setting `extra="forbid"`, this can be disallowed: +The same thing happens when explicitly setting `extra="ignore"`: ```py -class PersonWithoutExtras(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str - -# revealed: (self: PersonWithoutExtras, *, name: LaxStr) -> None -reveal_type(PersonWithoutExtras.__init__) -PersonWithoutExtras(name="Alice", something_else=7) # error: [unknown-argument] - class PersonIgnoringExtras(BaseModel): model_config = ConfigDict(extra="ignore") name: str -PersonIgnoringExtras(name="Alice", something_else=7) +PersonIgnoringExtras(name="Alice", something_else=7) # error: [pydantic-discarded-extra-argument] +``` + +When `extra="allow"` is set, extra arguments are explicitly allowed (and stored in the model at +runtime), so we do not emit a warning in this case: +```py class PersonAllowingExtras(BaseModel): model_config = ConfigDict(extra="allow") @@ -791,6 +788,20 @@ class PersonAllowingExtras(BaseModel): PersonAllowingExtras(name="Alice", something_else=7) ``` +Conversely, when setting `extra="forbid"`, a hard `unknown-argument` error is emitted, since the +construction would fail at runtime: + +```py +class PersonWithoutExtras(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + +# revealed: (self: PersonWithoutExtras, *, name: LaxStr) -> None +reveal_type(PersonWithoutExtras.__init__) +PersonWithoutExtras(name="Alice", something_else=7) # error: [unknown-argument] +``` + ## Custom initializers and extra fields A custom initializer that accepts arbitrary keyword arguments does not prevent a subclass from @@ -835,9 +846,11 @@ The variadic keyword parameter uses a collision-free name when the model already `extra`: ```py -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class PersonWithExtraField(BaseModel): + model_config = ConfigDict(extra="allow") + extra: int # revealed: (self: PersonWithExtraField, *, extra: LaxInt, **extra_: Any) -> None @@ -1100,7 +1113,7 @@ class InheritsForbidExtras(ForbidExtras): InheritsForbidExtras(name="Alice", something_else=7) # error: [unknown-argument] class OverridesForbidExtras(ForbidExtras): - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict(extra="allow") name: str @@ -1297,6 +1310,8 @@ reveal_type(User.__init__) # revealed: (self: User, *, name: LaxStr, age: LaxIn User(name="alice") User(name="alice", age=1) + +# error: [pydantic-discarded-extra-argument] User(name="alice", extra=1) # error: [missing-argument] diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 028909546f..f65828e395 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -761,6 +761,16 @@ impl<'db> Bindings<'db> { .filter_map(CallableItem::as_constructor_mut) } + /// Return `true` if `argument_index` is matched to the keyword-variadic parameter of an + /// `__init__` constructor. + pub(crate) fn constructor_init_argument_matches_keyword_variadic( + &self, + argument_index: usize, + ) -> bool { + self.iter_constructor_items() + .any(|constructor| constructor.init_argument_matches_keyword_variadic(argument_index)) + } + fn clear_deferred_constructor_errors_for_partial_application(&mut self) { for binding in self.iter_flat_mut() { binding.clear_deferred_constructor_errors_for_partial_application(); @@ -3145,6 +3155,20 @@ impl<'db> CallableBinding<'db> { self } + pub(super) fn argument_matches_keyword_variadic(&self, argument_index: usize) -> bool { + let argument_index = argument_index + usize::from(self.bound_type.is_some()); + self.matching_overloads().any(|(_, overload)| { + overload + .argument_matches() + .get(argument_index) + .is_some_and(|argument| { + argument.parameters.iter().any(|parameter| { + overload.signature.parameters()[parameter.index].is_keyword_variadic() + }) + }) + }) + } + /// Returns the source overload indexes that should be shown in diagnostics. /// /// Bound method overloads preserve their source indexes after receiver filtering. Method diff --git a/crates/ty_python_semantic/src/types/call/bind/constructor.rs b/crates/ty_python_semantic/src/types/call/bind/constructor.rs index 6dd9683b35..9559234094 100644 --- a/crates/ty_python_semantic/src/types/call/bind/constructor.rs +++ b/crates/ty_python_semantic/src/types/call/bind/constructor.rs @@ -161,6 +161,16 @@ impl<'db> ConstructorBinding<'db> { self.downstream_constructor.as_deref_mut() } + pub(super) fn init_argument_matches_keyword_variadic(&self, argument_index: usize) -> bool { + if self.constructor_kind().is_init() { + self.entry.argument_matches_keyword_variadic(argument_index) + } else { + self.downstream_constructor().is_some_and(|downstream| { + downstream.constructor_init_argument_matches_keyword_variadic(argument_index) + }) + } + } + pub(super) fn map(self, f: &F) -> ConstructorBinding<'db> where F: Fn(CallableBinding<'db>) -> CallableBinding<'db>, diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index 6af44820f8..908e65f3c8 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -1,17 +1,21 @@ use ruff_db::parsed::parsed_module; -use ruff_python_ast::{Expr, ExprCall, ExprDict, Keyword, name::Name}; +use ruff_python_ast::{ArgOrKeyword, Arguments, Expr, ExprCall, ExprDict, Keyword, name::Name}; use rustc_hash::FxHashSet; use ty_module_resolver::{KnownModule, file_to_module}; use ty_python_core::definition::{Definition, DefinitionKind}; +use crate::diagnostic::format_enumeration; use crate::place::{DefinedPlace, Definedness, Place, Provenance, known_module_symbol}; +use crate::types::call::Bindings; use crate::types::class::CodeGeneratorKind; +use crate::types::context::InferContext; +use crate::types::diagnostic::PYDANTIC_DISCARDED_EXTRA_ARGUMENT; use crate::types::ide_support::{ImportAliasResolution, definitions_for_name}; use crate::types::known_instance::FieldInstance; use crate::types::member::class_member; use crate::types::special_form::SpecialFormType; use crate::types::{ - ClassBase, DataclassTransformerParams, FunctionType, KnownClass, KnownFunction, + ClassBase, ClassType, DataclassTransformerParams, FunctionType, KnownClass, KnownFunction, KnownInstanceType, KnownUnion, Parameter, Specialization, StaticClassLiteral, Type, UnionType, definition_expression_type, }; @@ -45,6 +49,10 @@ impl<'db> ModelMetadata<'db> { !matches!(self.config(db).extra, Some(ExtraBehavior::Forbid)) } + fn discards_extra(self, db: &'db dyn Db) -> bool { + matches!(self.config(db).extra, None | Some(ExtraBehavior::Ignore)) + } + pub(in crate::types) fn validates_by_alias(self, db: &'db dyn Db) -> bool { let (validate_by_alias, _) = self.config(db).validation_config(); validate_by_alias.enabled_or(true) @@ -1001,6 +1009,73 @@ pub(in crate::types) fn model_init_accepts_extra( ) } +/// Return `true` if extra keywords passed to `class` are silently discarded by Pydantic. +pub(in crate::types) fn model_init_discards_extra( + db: &dyn Db, + class: StaticClassLiteral<'_>, + metadata: ModelMetadata<'_>, +) -> bool { + metadata.discards_extra(db) && model_init_behavior(db, class) == ModelInitBehavior::BaseModel +} + +/// Report keyword arguments that the Pydantic model constructor silently discards. +pub(in crate::types) fn report_discarded_extra_arguments<'db>( + context: &InferContext<'db, '_>, + class: ClassType<'db>, + arguments: &Arguments, + bindings: &Bindings<'db>, +) { + if !context.is_lint_enabled(&PYDANTIC_DISCARDED_EXTRA_ARGUMENT) { + return; + } + + let db = context.db(); + let Some((class, _)) = class.static_class_literal(db) else { + return; + }; + let Some(metadata) = CodeGeneratorKind::from_class(db, class.into()) + .and_then(CodeGeneratorKind::pydantic_metadata) + else { + return; + }; + if !model_init_discards_extra(db, class, metadata) { + return; + } + + let extra_names: Vec<_> = arguments + .iter_source_order() + .enumerate() + .filter_map(|(argument_index, argument)| { + let ArgOrKeyword::Keyword(keyword) = argument else { + return None; + }; + let name = keyword.arg.as_ref()?; + bindings + .constructor_init_argument_matches_keyword_variadic(argument_index) + .then_some(name) + }) + .collect(); + + if extra_names.is_empty() { + return; + } + + let Some(builder) = context.report_lint(&PYDANTIC_DISCARDED_EXTRA_ARGUMENT, arguments) else { + return; + }; + + if let [name] = extra_names.as_slice() { + builder.into_diagnostic(format_args!( + "Extra argument `{name}` is discarded by Pydantic" + )); + } else { + builder.into_diagnostic(format_args!( + "Extra arguments {} are discarded by Pydantic", + format_enumeration(extra_names.iter().map(|name| format!("`{name}`"))) + )); + } +} + /// Create the catch-all keyword parameter for a Pydantic model constructor. /// /// Start with `extra` and append underscores until the name does not collide with a model field. diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 5a40ede403..0e8d3f52fc 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -142,6 +142,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS); registry.register_lint(&UNDEFINED_REVEAL); registry.register_lint(&UNKNOWN_ARGUMENT); + registry.register_lint(&PYDANTIC_DISCARDED_EXTRA_ARGUMENT); registry.register_lint(&POSITIONAL_ONLY_PARAMETER_AS_KWARG); registry.register_lint(&UNRESOLVED_ATTRIBUTE); registry.register_lint(&UNRESOLVED_IMPORT); @@ -1054,6 +1055,15 @@ declare_lint! { } } +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/pydantic-discarded-extra-argument.md")] + pub(crate) static PYDANTIC_DISCARDED_EXTRA_ARGUMENT = { + summary: "detects extra constructor arguments that Pydantic silently discards", + status: LintStatus::stable("0.0.60"), + default_level: Level::Warn, + } +} + declare_lint! { #[doc = include_str!("../../resources/lint_docs/positional-only-parameter-as-kwarg.md")] pub(crate) static POSITIONAL_ONLY_PARAMETER_AS_KWARG = { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index a88b63c272..cbc0d8e6e2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -47,6 +47,7 @@ use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; use crate::types::class::{ClassLiteral, CodeGeneratorKind, MethodDecorator}; use crate::types::constraints::{ConstraintSetBuilder, PathBounds, Solutions}; use crate::types::context::InferContext; +use crate::types::dedicated::pydantic; use crate::types::diagnostic::{ self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CYCLIC_TYPE_ALIAS_DEFINITION, GeneratorMismatchKind, INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, @@ -8244,6 +8245,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }; + if let Some(class) = class { + pydantic::report_discarded_extra_arguments(&self.context, class, arguments, &bindings); + } + for binding in bindings.iter_flat_mut() { let binding_type = binding.callable_type; for (_, overload) in binding.matching_overloads_mut() { diff --git a/ty.schema.json b/ty.schema.json index d05b26ccd3..76453078b7 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1287,6 +1287,16 @@ } ] }, + "pydantic-discarded-extra-argument": { + "title": "detects extra constructor arguments that Pydantic silently discards", + "description": "## What it does\n\nChecks for extra keyword arguments that Pydantic silently discards when a model uses\n`extra=\"ignore\"`, either implicitly or explicitly.\n\n## Why is this bad?\n\nA discarded argument has no effect on the constructed model, but it may indicate a misspelled field\nname or an incorrect assumption about the model's schema.\n\n## Example\n\n```ignore\nfrom pydantic import BaseModel\n\n\nclass User(BaseModel):\n name: str\n admin: bool = False\n\n\nuser = User(name=\"Alice\", admni=True) # error: [pydantic-discarded-extra-argument]\n```\n\nIf the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument,\nor explicitly configure the model with `extra=\"allow\"`.", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "raw-string-type-annotation": { "title": "detects raw strings in type annotation positions", "description": "## What it does\n\nChecks for raw-strings in type annotation positions.\n\n## Why is this bad?\n\nStatic analysis tools like ty can't analyze type annotations that use raw-string notation.\n\n## Examples\n\n```python\ndef test() -> r\"int\": # error\n return 1\n```\n\nUse instead:\n\n```python\ndef test() -> \"int\":\n return 1\n```", From 61a06420aedb2d592c761239746e178bfb3b9605 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Mon, 13 Jul 2026 18:22:25 -0400 Subject: [PATCH 043/222] [ty] Extend `PathBounds` fast-path to support both upper and lower bounds (#26782) Fixes the exponential blowup described in https://github.com/astral-sh/ty/issues/3989 by extending our constraint-set solutions fast-path to support type variables with simple upper bounds. Note that this does not solve the root problem of combinatorial explosion in `SequentMap` construction, which will likely require a more involved fix, but it should avoid it in the common case. --- crates/ruff_benchmark/benches/ty.rs | 38 ++++++ .../src/types/constraints.rs | 113 +++++++++++++++--- 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index 03b0fada67..b213c0aedf 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1642,6 +1642,43 @@ consume(ALIASES.items()) }); } +fn benchmark_many_invariant_typevars(criterion: &mut Criterion) { + setup_rayon(); + + // Regression benchmark for https://github.com/astral-sh/ty/issues/3989. + let code = r#" +class Invariant[T]: + x: T + +def f[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]( + box1: Invariant[T1], + box2: Invariant[T2], + box3: Invariant[T3], + box4: Invariant[T4], + box5: Invariant[T5], + box6: Invariant[T6], + box7: Invariant[T7], + box8: Invariant[T8], + box9: Invariant[T9], + box10: Invariant[T10], +) -> None: ... + +x = Invariant[int]() +f(x, x, x, x, x, x, x, x, x, x) +"#; + + criterion.bench_function("ty_micro[many_invariant_typevars]", |b| { + b.iter_batched_ref( + || setup_micro_case(code), + |case| { + let Case { db, .. } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); +} fn benchmark_pydantic_core_schema_dict(criterion: &mut Criterion) { const NUM_CORE_SCHEMA_VARIANTS: usize = 24; @@ -1898,6 +1935,7 @@ criterion_group!( benchmark_recursive_typed_dict_union_contextual_inference, benchmark_invariant_generic_return_union, benchmark_invariant_generic_union_bound, + benchmark_many_invariant_typevars, benchmark_pydantic_core_schema_dict, ); criterion_group!(project, anyio, attrs, hydra, datetype); diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index fa48dbc140..ce7272e8db 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -89,6 +89,7 @@ use std::cell::{Ref, RefCell}; use std::cmp::Ordering; use std::fmt::{Debug, Display}; +use std::iter; use std::marker::PhantomData; use std::ops::Range; use std::sync::Arc; @@ -3490,7 +3491,7 @@ impl<'db> PathBounds<'db> { inferable: InferableTypeVars<'db>, ) -> Self { if let Some(path_bounds) = - Self::compute_simple_lower_bound_conjunction(db, builder, node, inferable) + Self::compute_simple_bound_conjunction(db, builder, node, inferable) { return path_bounds; } @@ -3559,14 +3560,13 @@ impl<'db> PathBounds<'db> { PathBounds::Constrained(result.into_boxed_slice()) } - /// Accumulates a conjunction of concrete lower-bound constraints without constructing a + /// Accumulates a conjunction of concrete bound constraints without constructing a /// [`PathAssignments`] or its sequent map. /// - /// There are no relationships to derive between these constraints: each lower bound contains - /// no typevars, and an unconstrained upper bound cannot make the path unsatisfiable. The normal - /// solution-selection logic still validates each accumulated bound against the typevar's - /// declared bound or constraints. - fn compute_simple_lower_bound_conjunction( + /// There are no relationships to derive between these constraints, as the upper and lower + /// bounds do not contain typevars. The normal solution-selection logic still validates each + /// accumulated bound against the typevar's declared bound or constraints. + fn compute_simple_bound_conjunction( db: &'db dyn Db, builder: &ConstraintSetBuilder<'db>, node: NodeId, @@ -3595,16 +3595,18 @@ impl<'db> PathBounds<'db> { return None; } - let lower = constraint.bounds.lower?; - if constraint.bounds.upper.is_some() - || lower.has_typevar(db) - || lower.has_unspecialized_type_var(db) + if iter::chain(constraint.bounds.lower, constraint.bounds.upper) + .any(|bound| bound.has_typevar(db) || bound.has_unspecialized_type_var(db)) { return None; } current = interior.if_true; - constraints.push((constraint.typevar, lower, interior.source_order)); + constraints.push(( + constraint.typevar, + constraint.bounds, + interior.source_order, + )); } } } @@ -3612,8 +3614,14 @@ impl<'db> PathBounds<'db> { let mut mappings: FxHashMap, ConstraintBoundsBuilder<'db>> = FxHashMap::default(); constraints.sort_by_key(|(_, _, source_order)| *source_order); - for (typevar, lower, _) in constraints { - mappings.entry(typevar).or_default().add_lower(db, lower); + for (typevar, constraint, _) in constraints { + let bounds = mappings.entry(typevar).or_default(); + if let Some(lower) = constraint.lower { + bounds.add_lower(db, lower); + } + if let Some(upper) = constraint.upper { + bounds.add_upper(db, upper); + } } let path = mappings @@ -6468,13 +6476,13 @@ impl PathAssignments { // assignments on this path. Assignments will typically have a single history, so it // should™ be fine that we're scanning and filtering that entire list. let (index, _, (_, history)) = self.assignments.get_full(&assignment)?; - let first = std::iter::once(history); + let first = iter::once(history); let rest = self .additional_substitution_histories .iter() .filter(move |(history_index, _)| *history_index == index) .map(|(_, history)| history); - Some(std::iter::chain(first, rest)) + Some(iter::chain(first, rest)) } /// Update our sequent map to ensure that it holds all of the sequents that involve the given @@ -7112,6 +7120,79 @@ mod tests { assert_eq!(storage.pair_sequent_cache.len(), pair_sequents); } + #[test] + fn simple_exact_bound_conjunction_skips_sequent_analysis() { + let db = setup_db(); + let t = create_typevar(&db, "T"); + let u = create_typevar(&db, "U"); + let builder = ConstraintSetBuilder::new(); + let int = KnownClass::Int.to_instance(&db); + let set = + ConstraintSet::constrain_typevar(&db, &builder, t, int, int).and(&db, &builder, || { + ConstraintSet::constrain_typevar(&db, &builder, u, int, int) + }); + let inferable = InferableTypeVars::from_typevars( + &db, + [t.identity(&db), u.identity(&db)].into_iter().collect(), + ); + let (single_sequents, pair_sequents) = { + let storage = builder.storage.borrow(); + ( + storage.single_sequent_cache.len(), + storage.pair_sequent_cache.len(), + ) + }; + + let Solutions::Constrained(solutions) = set.solutions(&db, &builder, inferable) else { + panic!("expected constrained solutions"); + }; + assert_eq!(solutions.len(), 1); + assert_eq!(solutions[0].len(), 2); + assert!(solutions[0].contains(&TypeVarSolution { + bound_typevar: t, + solution: int, + })); + assert!(solutions[0].contains(&TypeVarSolution { + bound_typevar: u, + solution: int, + })); + + let storage = builder.storage.borrow(); + assert_eq!(storage.single_sequent_cache.len(), single_sequents); + assert_eq!(storage.pair_sequent_cache.len(), pair_sequents); + } + + #[test] + fn simple_unsatisfiable_exact_bound_conjunction_skips_sequent_analysis() { + let db = setup_db(); + let t = create_typevar(&db, "T"); + let builder = ConstraintSetBuilder::new(); + let int = KnownClass::Int.to_instance(&db); + let str = KnownClass::Str.to_instance(&db); + let set = + ConstraintSet::constrain_typevar(&db, &builder, t, int, int).and(&db, &builder, || { + ConstraintSet::constrain_typevar(&db, &builder, t, str, str) + }); + let inferable = + InferableTypeVars::from_typevars(&db, std::iter::once(t.identity(&db)).collect()); + let (single_sequents, pair_sequents) = { + let storage = builder.storage.borrow(); + ( + storage.single_sequent_cache.len(), + storage.pair_sequent_cache.len(), + ) + }; + + assert_eq!( + set.solutions(&db, &builder, inferable), + Solutions::Unsatisfiable + ); + + let storage = builder.storage.borrow(); + assert_eq!(storage.single_sequent_cache.len(), single_sequents); + assert_eq!(storage.pair_sequent_cache.len(), pair_sequents); + } + #[test] fn default_solve_leaves_unbounded_typevar_unsolved_without_bounds() { let db = setup_db(); From e97d41b01fbbb3f32af00c5c8e204813a9fadecd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 13 Jul 2026 16:20:39 -0700 Subject: [PATCH 044/222] [ty] Compare overloaded protocol method returns pairwise (#26684) ## Summary When checking whether a protocol method is disjoint from another method, we currently combine each callable's overloaded return types into a union before comparing them. Building those unions performs redundancy checks between recursive protocol specializations; each redundancy check compares the overloaded protocol again. Disjointness distributes over unions, so this PR instead compares every return arm on one side with every return arm on the other directly, to avoid constructing and simplifying an intermediate union. This PR improves check time on a large internal file from 6.62s to 2.51s. --- .../resources/mdtest/protocols.md | 80 +++++++++++++++++++ .../src/types/protocol_class.rs | 47 +++++++---- 2 files changed, 113 insertions(+), 14 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 20248739f5..d7b85275dd 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -1683,6 +1683,86 @@ static_assert(is_assignable_to(AliasedNeverLength, HasLengthTwo)) static_assert(not is_disjoint_from(AliasedNeverLength, HasLengthTwo)) ``` +For overloaded methods, every possible return type on one side must be disjoint from every possible +return type on the other. A `Never` return in either overload set prevents the method from +establishing disjointness. + +```py +from typing import Literal, Protocol, overload +from typing_extensions import Never +from ty_extensions import static_assert +from ty_extensions._internal import is_disjoint_from + +class ReturnsOneOrTwo(Protocol): + @overload + def value(self, flag: Literal[True], /) -> Literal[1]: ... + @overload + def value(self, flag: Literal[False], /) -> Literal[2]: ... + +class ReturnsThreeOrFour: + @overload + def value(self, flag: Literal[True], /) -> Literal[3]: ... + @overload + def value(self, flag: Literal[False], /) -> Literal[4]: ... + def value(self, flag: bool, /) -> Literal[3, 4]: + return 3 if flag else 4 + +class ReturnsTwoOrThree: + @overload + def value(self, flag: Literal[True], /) -> Literal[2]: ... + @overload + def value(self, flag: Literal[False], /) -> Literal[3]: ... + def value(self, flag: bool, /) -> Literal[2, 3]: + return 2 if flag else 3 + +static_assert(is_disjoint_from(ReturnsOneOrTwo, ReturnsThreeOrFour)) +static_assert(not is_disjoint_from(ReturnsOneOrTwo, ReturnsTwoOrThree)) + +class ReturnsOneOrNever(Protocol): + @overload + def value(self, flag: Literal[True], /) -> Literal[1]: ... + @overload + def value(self, flag: Literal[False], /) -> Never: ... + +class ReturnsThreeOrNever: + @overload + def value(self, flag: Literal[True], /) -> Literal[3]: ... + @overload + def value(self, flag: Literal[False], /) -> Never: ... + def value(self, flag: bool, /) -> Literal[3]: + return 3 + +static_assert(not is_disjoint_from(ReturnsOneOrNever, ReturnsThreeOrFour)) +static_assert(not is_disjoint_from(ReturnsOneOrTwo, ReturnsThreeOrNever)) + +type BottomReturn = Never + +class ReturnsThreeOrBottom: + @overload + def value(self, flag: Literal[True], /) -> Literal[3]: ... + @overload + def value(self, flag: Literal[False], /) -> BottomReturn: ... + def value(self, flag: bool, /) -> Literal[3]: + return 3 + +static_assert(not is_disjoint_from(ReturnsOneOrTwo, ReturnsThreeOrBottom)) + +class ReceiverFiltered[T]: + payload: T + + @overload + def value(self: "ReceiverFiltered[bytes]", flag: bool, /) -> bytes: ... + @overload + def value(self: "ReceiverFiltered[str]", flag: bool, /) -> str: ... + def value(self, flag: bool, /) -> str | bytes: + return "" + +def empty_overloads(receiver: ReceiverFiltered[int]) -> None: + reveal_type(receiver.value) # revealed: Overload[] + +static_assert(not is_disjoint_from(ReturnsOneOrTwo, ReceiverFiltered[int])) +``` + ## Intersections of protocols with types that have possibly unbound attributes Note that if a `@final` class has a possibly unbound attribute corresponding to the protocol member, diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 543ba198c7..69c7ce38e0 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -1908,17 +1908,36 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { else { return self.never(); }; - let Some(method_return_type) = non_never_callable_return_type(db, method) else { + if !callable_has_only_non_never_returns(db, method) { return self.never(); - }; + } ty.try_upcast_to_callable_with_policy(db, UpcastPolicy::Sound) .when_some_and(db, self.constraints, |callables| { callables.iter().when_all(db, self.constraints, |callable| { - non_never_callable_return_type(db, *callable).when_some_and( + if !callable_has_only_non_never_returns(db, *callable) { + return self.never(); + } + + // Disjointness distributes over unions. Compare the overload return arms + // directly so that recursive return types do not require canonicalizing an + // intermediate union merely to distribute it again. + method.signatures(db).iter().when_all( db, self.constraints, - |return_type| self.check_type_pair(db, method_return_type, return_type), + |method_signature| { + callable.signatures(db).iter().when_all( + db, + self.constraints, + |callable_signature| { + self.check_type_pair( + db, + method_signature.return_ty, + callable_signature.return_ty, + ) + }, + ) + }, ) }) }) @@ -2133,19 +2152,19 @@ fn protocol_bind_self<'db>( callable.bind_self(db, self_type).into_regular(db) } -/// Return the possible output type of a callable unless any overload returns `Never`. +/// Return `true` if a callable has at least one overload and none return `Never`. /// /// Return-type disjointness is a pragmatic approximation for method members: a callable returning /// `Never` could satisfy otherwise-incompatible signatures, so it must not establish disjointness. -fn non_never_callable_return_type<'db>( - db: &'db dyn Db, - callable: CallableType<'db>, -) -> Option> { - callable - .signatures(db) - .iter() - .all(|signature| !signature.return_ty.resolve_type_alias(db).is_never()) - .then(|| callable.signatures(db).overload_return_type_or_unknown(db)) +fn callable_has_only_non_never_returns<'db>(db: &'db dyn Db, callable: CallableType<'db>) -> bool { + let mut signatures = callable.signatures(db).iter(); + let Some(first) = signatures.next() else { + // An empty signature previously produced `Unknown`, which cannot establish disjointness. + return false; + }; + + !first.return_ty.resolve_type_alias(db).is_never() + && signatures.all(|signature| !signature.return_ty.resolve_type_alias(db).is_never()) } /// Protocol compatibility can only succeed if every required member is present. From 7beebf33aa8ac1a990a01cc9eab4496241ebe19e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 13 Jul 2026 16:58:17 -0700 Subject: [PATCH 045/222] [ty] Support class and static protocol methods (#26574) ## Summary The typing specification permits protocols to declare class and static methods, but we previously represented these members as placeholder attributes. This PR records whether each protocol method is an instance, class, or static method and derives its instance- and class-access signatures accordingly. For example, a static method can satisfy a protocol class method when both access paths expose the required callable: ```python from typing import Protocol from ty_extensions import is_subtype_of, static_assert class Parser(Protocol): @classmethod def parse(cls, data: bytes) -> str: ... class JsonParser: @staticmethod def parse(data: bytes) -> str: return data.decode() # main: fails because class and static protocol members are not modeled as methods. # This branch: passes because both access paths expose `(bytes) -> str`. static_assert(is_subtype_of(JsonParser, Parser)) ``` Receiver-sensitive matching intentionally remains out of scope. For example: ```python # Both main and this branch ignore this receiver-only use of `T` when matching implementations. class ReceiverOnly[T](Protocol): @classmethod def method(cls: type[T]) -> None: ... ``` In more detail: this PR does not use the `cls` annotation to infer `T` from the implementing class, select receiver-specific overloads, or determine protocol variance. The typing specification does not define those interactions, and mypy, Pyright, and Pyrefly do not share a consistent model. Likewise, whether an implementation method generic over `T: str` can satisfy a target method generic over `T: object` is a general callable-relation question, so this PR does not add a protocol-specific rule for it. --------- Co-authored-by: Carl Meyer --- .../resources/mdtest/protocols.md | 288 +++++++++++++++-- .../ty_python_semantic/src/types/generics.rs | 6 +- .../src/types/protocol_class.rs | 292 ++++++++++++------ 3 files changed, 469 insertions(+), 117 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index d7b85275dd..6af552cdff 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2830,6 +2830,14 @@ from ty_extensions._internal import is_subtype_of, is_assignable_to class P(Protocol): def m(self, x: int, /) -> None: ... +class PWithClassMethod(Protocol): + @classmethod + def m(cls, x: int, /) -> None: ... + +class PWithStaticMethod(Protocol): + @staticmethod + def m(x: int, /) -> None: ... + class NominalSubtype: def m(self, y: int) -> None: ... @@ -2870,6 +2878,8 @@ static_assert(is_assignable_to(NominalWithClassMethod, P)) static_assert(is_assignable_to(NominalWithStaticMethodGood, P)) static_assert(is_assignable_to(NominalSubtype | NominalWithClassMethod, P)) static_assert(is_assignable_to(NominalSubtype | NominalWithStaticMethodGood, P)) +static_assert(is_subtype_of(PWithClassMethod, P)) +static_assert(is_subtype_of(PWithStaticMethod, P)) # This staticmethod has an extra parameter when accessed on an instance. static_assert(not is_assignable_to(NominalWithStaticMethod, P)) @@ -3210,6 +3220,90 @@ static_assert(not is_assignable_to(BadReturnType, ShapeProtocolImplicitSelf)) static_assert(not is_assignable_to(BadReturnType, ShapeProtocolExplicitSelf)) ``` +## Module objects with static-method protocol members + +Module objects implement protocols through their public interface. A module-level function can +therefore satisfy an ordinary or static method member with the same signature. + +`factory.py`: + +```py +size: int = 1 + +def make(value: int) -> str: + return str(value) +``` + +`main.py`: + +```py +from typing import Protocol + +import factory + +class FactoryObject(Protocol): + size: int + def make(self, value: int) -> str: ... + +class FactoryModule(Protocol): + size: int + + @staticmethod + def make(value: int) -> str: ... + +factory_object: FactoryObject = factory +factory_module: FactoryModule = factory +``` + +## Class objects with class-method protocol members + +A class object implements a protocol when its directly accessible members have compatible types. The +corresponding member does not also need to exist on the class object's metaclass: + +```py +from typing import Protocol + +class Parser(Protocol): + @classmethod + def parse(cls, value: str) -> int: ... + +class IntParser: + @classmethod + def parse(cls, value: str) -> int: + return int(value) + +parser: Parser = IntParser +``` + +## Class objects and `Self`-returning class-method protocol members + +When a class object is checked against a class-method protocol member, `Self` in the protocol +signature is bound to instances of the class object rather than to the class object itself: + +```py +from typing import Protocol +from typing_extensions import Self +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_assignable_to + +class FactoryProtocol(Protocol): + @classmethod + def make(cls) -> Self: ... + +class Factory: + @classmethod + def make(cls) -> Self: + return cls() + +class BadFactory: + @classmethod + def make(cls) -> int: + return 1 + +static_assert(is_assignable_to(TypeOf[Factory], FactoryProtocol)) +static_assert(not is_assignable_to(TypeOf[BadFactory], FactoryProtocol)) +``` + ## Subtyping of protocols with `@classmethod` or `@staticmethod` members The typing spec states that protocols may have `@classmethod` or `@staticmethod` method members. @@ -3224,7 +3318,9 @@ of `N` or inhabitants of `type[N]`, *and* the signature of `N.x` is equivalent t `P.x` after the descriptor protocol has been invoked on `P.x`: ```py -from typing import Protocol +from collections.abc import Callable +from typing import Protocol, overload +from typing_extensions import Self from ty_extensions import static_assert from ty_extensions._internal import is_subtype_of, is_assignable_to, is_equivalent_to, is_disjoint_from @@ -3263,23 +3359,70 @@ class NStaticMethodBad: def x(cls, val: int) -> str: return "foo" +class NMaybeCallable: + x: Callable[[int], str] | None + +class F: + def __call__(self, val: int) -> str: + return "foo" + +class NObject: + x: object = F() + +class NStaticMethodShadowed(NStaticMethodGood): + def __init__(self) -> None: + self.x: int = 1 + +class PFactory(Protocol): + @classmethod + def create(cls) -> Self: ... + +class Factory: + @classmethod + def create(cls) -> Self: + return cls() + +class BadFactory: + @classmethod + def create(cls) -> int: + return 42 + +class POverloadedFactory(Protocol): + @overload + @classmethod + def create(cls, value: int) -> Self: ... + @overload + @classmethod + def create(cls, value: str) -> Self: ... + +class OverloadedFactory: + @overload + @classmethod + def create(cls, value: int) -> Self: ... + @overload + @classmethod + def create(cls, value: str) -> Self: ... + @classmethod + def create(cls, value: int | str) -> Self: + return cls() + # `PClassMethod.x` and `PStaticMethod.x` evaluate to callable types with equivalent signatures # whether you access them on the protocol class or instances of the protocol. # That means that they are equivalent protocols! static_assert(is_equivalent_to(PClassMethod, PStaticMethod)) -# TODO: these should all pass -static_assert(not is_assignable_to(NNotCallable, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) # error: [static-assert-error] -static_assert(is_disjoint_from(NNotCallable, PClassMethod)) # error: [static-assert-error] -static_assert(is_disjoint_from(NNotCallable, PStaticMethod)) # error: [static-assert-error] +static_assert(not is_assignable_to(NNotCallable, PClassMethod)) +static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) +static_assert(not is_disjoint_from(NNotCallable, PClassMethod)) +static_assert(not is_disjoint_from(NNotCallable, PStaticMethod)) +static_assert(not is_disjoint_from(NMaybeCallable, PStaticMethod)) +static_assert(not is_disjoint_from(NObject, PStaticMethod)) # `NInstanceMethod.x` has the correct type when accessed on an instance of # `NInstanceMethod`, but not when accessed on the class object itself # -# TODO: these should pass -static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [static-assert-error] +static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) +static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # A nominal type with a `@staticmethod` can satisfy a protocol with a `@classmethod` # if the staticmethod duck-types the same as the classmethod member @@ -3288,26 +3431,33 @@ static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [s # with a `@staticmethod` member static_assert(is_assignable_to(NClassMethodGood, PClassMethod)) static_assert(is_assignable_to(NClassMethodGood, PStaticMethod)) -# TODO: these should all pass: -static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) # error: [static-assert-error] -static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodGood | NClassMethodBad, PClassMethod)) # error: [static-assert-error] +static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) +static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) +static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) +static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) +static_assert(not is_assignable_to(NClassMethodGood | NClassMethodBad, PClassMethod)) static_assert(is_assignable_to(NStaticMethodGood, PClassMethod)) static_assert(is_assignable_to(NStaticMethodGood, PStaticMethod)) -# TODO: these should all pass: -static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) # error: [static-assert-error] -static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodGood | NStaticMethodBad, PStaticMethod)) # error: [static-assert-error] +static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) +static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) +static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) +static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) +static_assert(not is_assignable_to(NStaticMethodGood | NStaticMethodBad, PStaticMethod)) + +# An instance attribute can override an inherited static method. +static_assert(not is_subtype_of(NStaticMethodShadowed, PStaticMethod)) + +# `Self` in the classmethod signature is bound to the implementation type. +static_assert(is_subtype_of(Factory, PFactory)) +static_assert(not is_assignable_to(BadFactory, PFactory)) + +# Each overload keeps its own `Self` binding. +static_assert(is_subtype_of(OverloadedFactory, POverloadedFactory)) ``` -Until classmethod protocol members are fully supported, their placeholder representation should not -incorrectly require a mutable instance attribute. In particular, a frozen dataclass can satisfy a -protocol bound through a classmethod: +A classmethod protocol member does not require a mutable instance attribute. In particular, a frozen +dataclass can satisfy a protocol bound through a classmethod: ```py from dataclasses import dataclass @@ -3366,6 +3516,45 @@ static_assert(is_assignable_to(AlsoCorrect, ContextManagerProtocol)) static_assert(not is_assignable_to(MissingDecorator, ContextManagerProtocol)) ``` +A decorator with a precise callable return type preserves the signatures of class and static +protocol methods: + +```py +from collections.abc import Callable +from typing import ParamSpec, Protocol, TypeVar +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +P = ParamSpec("P") +R = TypeVar("R") + +def preserve_signature(function: Callable[P, R]) -> Callable[P, R]: + return function + +class StaticProtocol(Protocol): + @staticmethod + @preserve_signature + def method(value: int) -> str: ... + +class StaticImplementation: + @staticmethod + def method(value: int) -> str: + return str(value) + +class ClassProtocol(Protocol): + @classmethod + @preserve_signature + def method(cls, value: int) -> str: ... + +class ClassImplementation: + @classmethod + def method(cls, value: int) -> str: + return str(value) + +static_assert(is_subtype_of(StaticImplementation, StaticProtocol)) +static_assert(is_subtype_of(ClassImplementation, ClassProtocol)) +``` + ## Equivalence of protocols with method or property members Two protocols `P1` and `P2`, both with a method member `x`, are considered equivalent if the @@ -3832,6 +4021,34 @@ static_assert(not is_assignable_to(TypeOf[doesnt_satisfy_foo], Foo)) static_assert(not is_subtype_of(TypeOf[doesnt_satisfy_foo], Foo)) ``` +Type-variable inference also uses static and class `__call__` members: + +```py +from typing import Protocol, TypeVar + +CallbackT = TypeVar("CallbackT") + +class StaticCallback(Protocol[CallbackT]): + @staticmethod + def __call__(value: CallbackT) -> CallbackT: ... + +class ClassCallback(Protocol[CallbackT]): + @classmethod + def __call__(cls, value: CallbackT) -> CallbackT: ... + +def use_static(callback: StaticCallback[CallbackT]) -> CallbackT: + raise NotImplementedError + +def use_class(callback: ClassCallback[CallbackT]) -> CallbackT: + raise NotImplementedError + +def identity(value: int) -> int: + return value + +reveal_type(use_static(identity)) # revealed: int +reveal_type(use_class(identity)) # revealed: int +``` + Class-literals and generic aliases can also be subtypes of callback protocols: ```py @@ -3870,6 +4087,29 @@ def _(source: NoArgs): target: Variadic[Any] = source # error: [invalid-assignment] ``` +## Class constructors and static callback protocols + +A class object's call signature comes from its constructor. An unrelated `__call__` method on the +class's instances does not replace that constructor signature when matching a static callback +protocol: + +```py +from typing import Protocol + +class Product: + def __init__(self, value: int) -> None: + self.value = value + + def __call__(self, text: str) -> str: + return text + +class Constructor(Protocol): + @staticmethod + def __call__(value: int) -> Product: ... + +constructor: Constructor = Product +``` + ## Generic protocols and union arguments When a union is passed to a parameter annotated as a generic protocol, each union element can diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 243229ad17..397d5727bf 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -3176,9 +3176,9 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { return Ok(()); }; - // The `__call__` method is bound to `self`, so we need to bind it to get the - // callable signature that the actual type needs to match. - let formal_signature = call_method.bind_self(self.db, None).signatures(self.db); + // The protocol interface exposes the callable signature already bound for + // instance access. + let formal_signature = call_method.signatures(self.db); // For callable-signature inference, keep unsatisfiable constraint-set // comparisons non-fatal for now. The hybrid inference/checking pipeline still diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 69c7ce38e0..cd3be7cb67 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -28,7 +28,6 @@ use crate::{ constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, diagnostic::report_undeclared_protocol_member, - todo_type, }, }; use ty_python_core::{definition::Definition, place::ScopedPlaceId, place_table, use_def_map}; @@ -234,7 +233,12 @@ impl<'db> ProtocolInterface<'db> { { let members: BTreeMap<_, _> = members .into_iter() - .map(|(name, callable)| (Name::new(name), ProtocolMemberData::method(callable, None))) + .map(|(name, callable)| { + ( + Name::new(name), + ProtocolMemberData::method(db, callable, None), + ) + }) .collect(); Self::new(db, members) } @@ -316,7 +320,10 @@ impl<'db> ProtocolInterface<'db> { }) } - /// Returns the `__call__` method's callable type if this protocol has a `__call__` method member. + /// Returns the callable signature exposed by instance access to a protocol's `__call__` + /// method. + /// + /// The callable is already in its instance-bound form, so callers must not bind it again. pub(super) fn call_method(self, db: &'db dyn Db) -> Option> { self.member_by_name(db, "__call__").and_then(|member| { if !member.is_method() { @@ -324,7 +331,7 @@ impl<'db> ProtocolInterface<'db> { } match member .capabilities(db) - .class + .instance .read .and_then(|read| read.resolve(db)) .map(ProtocolMemberType::ty) @@ -650,9 +657,27 @@ pub(super) struct ProtocolMemberData<'db> { } impl<'db> ProtocolMemberData<'db> { - fn method(callable: CallableType<'db>, definition: Option>) -> Self { + fn method( + db: &'db dyn Db, + callable: CallableType<'db>, + definition: Option>, + ) -> Self { + let (method_kind, callable) = if callable.is_classmethod_like(db) { + ( + ProtocolMethodKind::Class, + protocol_bind_self(db, callable, None), + ) + } else if callable.is_staticmethod_like(db) { + (ProtocolMethodKind::Static, callable.into_regular(db)) + } else { + (ProtocolMethodKind::Instance, callable) + }; + Self { - kind: ProtocolMemberKind::Method(ProtocolMemberType::new(Type::Callable(callable))), + kind: ProtocolMemberKind::Method( + ProtocolMemberType::with_definition(Type::Callable(callable), definition), + method_kind, + ), qualifiers: TypeQualifiers::default(), definition, } @@ -690,16 +715,16 @@ impl<'db> ProtocolMemberData<'db> { /// keeping them derived prevents the stored member kind and its capabilities from diverging. fn capabilities(&self, db: &'db dyn Db) -> ProtocolMemberCapabilities<'db> { match self.kind { - ProtocolMemberKind::Method(method) => { - let instance_method = match method.ty() { - Type::Callable(callable) => { - method.with_ty(Type::Callable(protocol_bind_self(db, callable, None))) + ProtocolMemberKind::Method(member, kind) => { + let instance_method = match (member.ty(), kind) { + (Type::Callable(callable), ProtocolMethodKind::Instance) => { + member.with_ty(Type::Callable(protocol_bind_self(db, callable, None))) } - _ => method, + _ => member, }; ProtocolMemberCapabilities { instance: ProtocolMemberAccess::new(Some(instance_method), None), - class: ProtocolMemberAccess::new(Some(method), None), + class: ProtocolMemberAccess::new(Some(member), None), } } ProtocolMemberKind::Property { read, write } => ProtocolMemberCapabilities { @@ -709,9 +734,8 @@ impl<'db> ProtocolMemberData<'db> { ProtocolMemberKind::Attribute(member_ty) => { let is_class_var = self.qualifiers.contains(TypeQualifiers::CLASS_VAR); let is_final = self.qualifiers.contains(TypeQualifiers::FINAL); - // A `Todo` records a protocol member form that is not modeled yet. In particular, - // classmethod and staticmethod members currently use the attribute representation; - // do not infer a write requirement from that temporary representation. + // A `Todo` records a protocol member form that is not modeled yet; do not infer a + // write requirement from that temporary representation. let is_todo = member_ty.ty().is_todo(); ProtocolMemberCapabilities { instance: ProtocolMemberAccess::new( @@ -792,8 +816,8 @@ impl<'db> ProtocolMemberData<'db> { impl std::fmt::Display for ProtocolMemberDataDisplay<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.kind { - ProtocolMemberKind::Method(method) => { - write!(f, "MethodMember(`{}`)", method.ty().display(self.db)) + ProtocolMemberKind::Method(member, _) => { + write!(f, "MethodMember(`{}`)", member.ty().display(self.db)) } ProtocolMemberKind::Property { read, write } => { let mut d = f.debug_struct("PropertyMember"); @@ -827,7 +851,7 @@ impl<'db> ProtocolMemberData<'db> { #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] enum ProtocolMemberKind<'db> { - Method(ProtocolMemberType<'db>), + Method(ProtocolMemberType<'db>, ProtocolMethodKind), Property { read: Option>, write: Option>, @@ -835,10 +859,17 @@ enum ProtocolMemberKind<'db> { Attribute(ProtocolMemberType<'db>), } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +enum ProtocolMethodKind { + Instance, + Class, + Static, +} + impl<'db> ProtocolMemberKind<'db> { fn member_types(self) -> impl Iterator> { match self { - Self::Method(method) => [Some(method), None], + Self::Method(member, _) => [Some(member), None], Self::Property { read, write } => [read, write], Self::Attribute(attribute) => [Some(attribute), None], } @@ -848,11 +879,11 @@ impl<'db> ProtocolMemberKind<'db> { fn cycle_normalized(self, db: &'db dyn Db, previous: Self, cycle: &salsa::Cycle) -> Self { match (self, previous) { - (Self::Method(current), Self::Method(previous)) => { + (Self::Method(current, kind), Self::Method(previous, _)) => { let (Type::Callable(current_callable), Type::Callable(previous_callable)) = (current.ty(), previous.ty()) else { - return Self::Method(current.cycle_normalized(db, previous, cycle)); + return Self::Method(current.cycle_normalized(db, previous, cycle), kind); }; debug_assert_eq!(current_callable.kind(db), previous_callable.kind(db)); let signatures = current_callable.signatures(db).cycle_normalized( @@ -860,12 +891,15 @@ impl<'db> ProtocolMemberKind<'db> { previous_callable.signatures(db), cycle, ); - Self::Method(current.with_ty(Type::Callable(CallableType::new( - db, - signatures, - current_callable.kind(db), - current_callable.provenance(db), - )))) + Self::Method( + current.with_ty(Type::Callable(CallableType::new( + db, + signatures, + current_callable.kind(db), + current_callable.provenance(db), + ))), + kind, + ) } ( Self::Property { @@ -894,9 +928,10 @@ impl<'db> ProtocolMemberKind<'db> { nested: bool, ) -> Option { Some(match self { - Self::Method(method) => { - Self::Method(method.recursive_type_normalized_impl(db, div, nested)?) - } + Self::Method(member, kind) => Self::Method( + member.recursive_type_normalized_impl(db, div, nested)?, + kind, + ), Self::Property { read, write } => Self::Property { read: match read { Some(read) => Some(read.recursive_type_normalized_impl(db, div, nested)?), @@ -921,9 +956,10 @@ impl<'db> ProtocolMemberKind<'db> { visitor: &ApplyTypeMappingVisitor<'db>, ) -> Self { match self { - Self::Method(method) => { - Self::Method(method.apply_type_mapping_impl(db, type_mapping, tcx, visitor)) - } + Self::Method(member, kind) => Self::Method( + member.apply_type_mapping_impl(db, type_mapping, tcx, visitor), + kind, + ), Self::Property { read, write } => Self::Property { read: read.map(|read| read.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), write: write @@ -963,7 +999,14 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { } pub(super) fn is_method(&self) -> bool { - matches!(self.data.kind, ProtocolMemberKind::Method(_)) + matches!(self.data.kind, ProtocolMemberKind::Method(..)) + } + + fn is_instance_method(&self) -> bool { + matches!( + self.data.kind, + ProtocolMemberKind::Method(_, ProtocolMethodKind::Instance) + ) } fn is_property(&self) -> bool { @@ -978,6 +1021,42 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { self.data.capabilities(db) } + /// Returns the accesses that a candidate value must provide for this member. + /// + /// A module-level callable can satisfy an ordinary or static method through direct member + /// access. A class object can likewise satisfy a class or static method. The member does not + /// also need to exist on the value's meta-type. + fn implementation_capabilities( + &self, + db: &'db dyn Db, + ty: Type<'db>, + ) -> ProtocolMemberCapabilities<'db> { + let capabilities = self.capabilities(db); + if matches!( + (ty, self.data.kind), + ( + Type::ModuleLiteral(_), + ProtocolMemberKind::Method( + _, + ProtocolMethodKind::Instance | ProtocolMethodKind::Static + ) + ) | ( + Type::ClassLiteral(_), + ProtocolMemberKind::Method( + _, + ProtocolMethodKind::Class | ProtocolMethodKind::Static + ) + ) + ) { + ProtocolMemberCapabilities { + class: ProtocolMemberAccess::NONE, + ..capabilities + } + } else { + capabilities + } + } + fn has_todo_type(&self) -> bool { self.data .kind @@ -1140,6 +1219,8 @@ fn protocol_member_read_type<'db>( member: &ProtocolMember<'_, 'db>, access: ProtocolMemberAccessMode, ) -> Option> { + // A callback protocol describes call syntax. Use the candidate's callable type instead of an + // explicitly resolved `__call__` attribute, which can differ for class objects. if access == ProtocolMemberAccessMode::Instance && member.is_method() && member.name == "__call__" @@ -1147,7 +1228,11 @@ fn protocol_member_read_type<'db>( return Some(ty); } - let place = if access == ProtocolMemberAccessMode::Instance && member.is_method() { + // PEP 544 matches module-level functions directly, without descriptor binding on `ModuleType`. + let place = if access == ProtocolMemberAccessMode::Instance + && member.is_instance_method() + && !matches!(ty, Type::ModuleLiteral(_)) + { ty.invoke_descriptor_protocol( db, ty, @@ -1484,12 +1569,21 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { required_ty: ProtocolMemberType<'db>, access: ProtocolMemberAccessMode, ) -> ConstraintSet<'db, 'c> { - let fallback_ty = ty.literal_fallback_instance(db).unwrap_or(ty); let Some(attribute_type) = protocol_member_read_type(db, ty, receiver_ty, member, access) else { return self.never(); }; + // For a method on a class object, `Self` names instances of that class: a + // `@classmethod` returning `Self` returns `Factory`, not `type[Factory]`. For a + // non-method member, `Self` names the object whose attribute is being checked, so a + // class object must stay a class object. + let non_method_self_binding_ty = ty.literal_fallback_instance(db).unwrap_or(ty); + let method_self_binding_ty = ty + .to_instance(db) + .or_else(|| ty.literal_fallback_instance(db)) + .unwrap_or(ty); + // Checking a class object against a protocol's instance capabilities can expose the // property descriptor itself rather than the value returned by its getter. Compatibility // for properties on class objects is not yet modeled; retain the previous name-only @@ -1511,11 +1605,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .when_some_and(db, self.constraints, |callables| { self.check_callables_vs_callable( db, - &callables.map(|callable| callable.apply_self(db, fallback_ty)), - required_callable.apply_self(db, fallback_ty), + &callables.map(|callable| callable.apply_self(db, method_self_binding_ty)), + required_callable.apply_self(db, method_self_binding_ty), ) }) - } else if member.is_method() { + } else if member.is_instance_method() { let Some(required_ty) = required_ty.resolve(db) else { return self.never(); }; @@ -1529,19 +1623,34 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { if callable.is_function_like(db) { self.check_callable_pair( db, - callable.bind_self(db, Some(fallback_ty)), - protocol_bind_self(db, required_callable, Some(fallback_ty)), + callable.bind_self(db, Some(method_self_binding_ty)), + protocol_bind_self( + db, + required_callable, + Some(method_self_binding_ty), + ), ) } else { self.check_callable_pair(db, *callable, required_callable) } }) }) - } else { - required_ty.bind_self(db, fallback_ty).when_some_and( + } else if member.is_method() { + let Some(required_ty) = required_ty.resolve(db) else { + return self.never(); + }; + let Type::Callable(required_callable) = required_ty.ty() else { + return self.never(); + }; + self.check_type_pair( db, - self.constraints, - |required_ty| { + attribute_type, + Type::Callable(required_callable.apply_self(db, method_self_binding_ty)), + ) + } else { + required_ty + .bind_self(db, non_method_self_binding_ty) + .when_some_and(db, self.constraints, |required_ty| { let result = self.check_type_pair(db, attribute_type, required_ty); if let Some(context) = self.report_context() && result.is_never_satisfied(db) @@ -1552,8 +1661,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }); } result - }, - ) + }) } } @@ -1570,7 +1678,10 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { required: ProtocolMemberAccess<'db>, access: ProtocolMemberAccessMode, ) -> ConstraintSet<'db, 'c> { - if access == ProtocolMemberAccessMode::Class && member.is_method() { + if access == ProtocolMemberAccessMode::Class + && member.is_instance_method() + && required.read.is_some() + { // The instance-side check is authoritative for the signature of a method // implementation. Class access only establishes that the member is present. Callable // types and several callable literal forms do not expose a useful `__call__` member @@ -1636,7 +1747,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ty: Type<'db>, member: &ProtocolMember<'_, 'db>, ) -> ConstraintSet<'db, 'c> { - let capabilities = member.capabilities(db); + let capabilities = member.implementation_capabilities(db, ty); if let Some(context) = self.report_context() { let instance_read_missing = capabilities.instance.read.is_some() && protocol_member_read_type( @@ -1648,7 +1759,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ) .is_none(); let class_read_missing = capabilities.class.read.is_some() - && !(member.is_method() && member.name == "__call__") + && !(member.is_instance_method() && member.name == "__call__") && protocol_member_read_type( db, ty, @@ -1709,18 +1820,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ) -> ConstraintSet<'db, 'c> { let source_capabilities = source_member.capabilities(db); let target_capabilities = target_member.capabilities(db); + + if access == ProtocolMemberAccessMode::Class + && source_member.is_method() + && target_member.is_instance_method() + { + // The instance-side check is authoritative for an ordinary method's signature. Class + // access only establishes that the source member is also present on the class. + return ConstraintSet::from_bool( + self.constraints, + source_capabilities.class.read.is_some(), + ); + } + let (source, target) = match access { ProtocolMemberAccessMode::Instance => { (source_capabilities.instance, target_capabilities.instance) } - ProtocolMemberAccessMode::Class - if source_member.is_method() && target_member.is_method() => - { - // The receiver type of an unbound method is specific to the class that - // defines it. Compare the corresponding bound access types after separately - // establishing that both methods are available through their classes. - (source_capabilities.instance, target_capabilities.instance) - } ProtocolMemberAccessMode::Class => { (source_capabilities.class, target_capabilities.class) } @@ -1912,35 +2028,36 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { return self.never(); } - ty.try_upcast_to_callable_with_policy(db, UpcastPolicy::Sound) - .when_some_and(db, self.constraints, |callables| { - callables.iter().when_all(db, self.constraints, |callable| { - if !callable_has_only_non_never_returns(db, *callable) { - return self.never(); - } + let Some(callables) = ty.try_upcast_to_callable_with_policy(db, UpcastPolicy::Sound) + else { + return self.never(); + }; + + callables.iter().when_all(db, self.constraints, |callable| { + if !callable_has_only_non_never_returns(db, *callable) { + return self.never(); + } - // Disjointness distributes over unions. Compare the overload return arms - // directly so that recursive return types do not require canonicalizing an - // intermediate union merely to distribute it again. - method.signatures(db).iter().when_all( + // Disjointness distributes over unions. Compare the overload return arms + // directly so that recursive return types do not require canonicalizing an + // intermediate union merely to distribute it again. + method + .signatures(db) + .iter() + .when_all(db, self.constraints, |method_signature| { + callable.signatures(db).iter().when_all( db, self.constraints, - |method_signature| { - callable.signatures(db).iter().when_all( + |callable_signature| { + self.check_type_pair( db, - self.constraints, - |callable_signature| { - self.check_type_pair( - db, - method_signature.return_ty, - callable_signature.return_ty, - ) - }, + method_signature.return_ty, + callable_signature.return_ty, ) }, ) }) - }) + }) } } } @@ -2089,21 +2206,16 @@ fn cached_protocol_interface<'db>( definition, ), Type::Callable(callable) - if bound_on_class.is_yes() && callable.is_function_like(db) => + if bound_on_class.is_yes() && callable.is_method_like(db) => { - ProtocolMemberData::method(callable, definition) + ProtocolMemberData::method(db, callable, definition) } Type::FunctionLiteral(function) - if function.is_staticmethod(db) || function.is_classmethod(db) => + if bound_on_class.is_yes() + || function.is_staticmethod(db) + || function.is_classmethod(db) => { - ProtocolMemberData::attribute( - todo_type!("classmethod and staticmethod protocol members"), - qualifiers, - definition, - ) - } - Type::FunctionLiteral(function) if bound_on_class.is_yes() => { - ProtocolMemberData::method(function.into_callable_type(db), definition) + ProtocolMemberData::method(db, function.into_callable_type(db), definition) } _ if bound_on_class.is_yes() && definition From 1b1a83a48eb31f6d4bbb75c4fa121f0103df0fab Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 13 Jul 2026 17:33:56 -0700 Subject: [PATCH 046/222] [ty] Avoid quadratic analysis of repeated statement calls (#26775) ## Summary Prior to this change, each statement-level call immediately applied an `IsNonTerminalCall` narrowing gate to every live place so calls returning `Never` could invalidate later narrowing. Repeated calls therefore rebuilt all live place states after every statement, which is quadratic. We now store the call narrowing gate alongside the pending scope-wide reachability constraint. Reachability continues to use the regular materialization path at every use, while narrowing gates are replayed only before a place changes or merges. A `Never`-returning call therefore still gates narrowing later on the same branch without eagerly rewriting every live place state. Closes https://github.com/astral-sh/ty/issues/3986. --- crates/ruff_benchmark/benches/ty.rs | 26 +++ crates/ty_python_core/src/builder.rs | 15 +- crates/ty_python_core/src/use_def.rs | 195 +++++++++++++++--- .../mdtest/narrow/post_if_statement.md | 20 ++ 4 files changed, 217 insertions(+), 39 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index b213c0aedf..87bce66dbd 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1413,6 +1413,31 @@ fn benchmark_typeis_narrowing(criterion: &mut Criterion) { }); } +/// Regression benchmark for . +/// +/// Non-terminal-call predicates must gate later narrowing. Keeping these scope-wide constraints in +/// an append-only tree avoids eagerly rewriting every live place state after each call. +fn benchmark_repeated_statement_calls(criterion: &mut Criterion) { + setup_rayon(); + + let mut code = String::from("def f() -> None:\n value = 'abc'\n"); + for _ in 0..1_500 { + code.push_str(" value.upper()\n"); + } + + criterion.bench_function("ty_micro[repeated_statement_calls]", |b| { + b.iter_batched_ref( + || setup_micro_case(&code), + |case| { + let Case { db, .. } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); +} + /// Benchmarks solving many union-bearing upper bounds while inferring a generic call. /// /// Each callable argument places a distinct union upper bound on `T` through callable-parameter @@ -1930,6 +1955,7 @@ criterion_group!( benchmark_literal_equality_fallthrough_guarded_any, benchmark_literal_or_pattern_reachability, benchmark_typeis_narrowing, + benchmark_repeated_statement_calls, benchmark_factored_upper_bounds, benchmark_pandas_tdd, benchmark_recursive_typed_dict_union_contextual_inference, diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index af900fa6ab..04122cb8e7 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -4188,15 +4188,14 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .add_atom(predicate_id); if self.in_function_scope() { - self.record_reachability_constraint_id(predicate_id); - - // Also gate narrowing by this constraint: if the call returns - // `Never`, any narrowing in the current branch should be - // invalidated (since this path is unreachable). This enables - // narrowing to be preserved after if-statements where one branch - // calls a `NoReturn` function like `sys.exit()`. + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); self.current_use_def_map_mut() - .record_narrowing_constraint_for_all_places(narrowing_constraint); + .record_non_terminal_call_constraints( + reachability_constraint, + narrowing_constraint, + ); } else { // In non-function scopes, we only record a narrowing constraint // (not a reachability constraint). Recording reachability for diff --git a/crates/ty_python_core/src/use_def.rs b/crates/ty_python_core/src/use_def.rs index 9babfd5b09..6d9e4b6e52 100644 --- a/crates/ty_python_core/src/use_def.rs +++ b/crates/ty_python_core/src/use_def.rs @@ -1382,14 +1382,14 @@ struct PendingReachabilityId; #[derive(Debug)] struct PendingReachabilityConstraint { parent: PendingReachabilityId, - constraint: ScopedReachabilityConstraintId, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, } -/// An append-only tree of scope-wide reachability constraints. +/// An append-only tree of scope-wide reachability constraints and call narrowing gates. /// -/// Each [`PendingPlaceState`] remembers the last node applied to its place state, so snapshots can -/// share place states and defer applying subsequent constraints until the place is observed or -/// changed. +/// Each [`PendingPlaceState`] remembers the last node applied for each constraint kind, so +/// snapshots can share place states and defer applying subsequent constraints until needed. #[derive(Debug)] struct PendingReachability { constraints: IndexVec, @@ -1402,7 +1402,8 @@ impl Default for PendingReachability { let root = constraints.next_index(); constraints.push(PendingReachabilityConstraint { parent: root, - constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, + reachability_constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, + narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, }); Self { constraints, @@ -1412,18 +1413,59 @@ impl Default for PendingReachability { } impl PendingReachability { - fn push(&mut self, constraint: ScopedReachabilityConstraintId) { + fn push( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, + ) { self.current = self.constraints.push(PendingReachabilityConstraint { parent: self.current, - constraint, + reachability_constraint, + narrowing_constraint, }); } - /// Applies the constraints between the place's last materialized node and `target`. + /// Applies both constraint kinds between the place's last materialized nodes and `target`. /// /// The place's node must be an ancestor of `target`. After materialization, the place is /// uniquely owned for mutation and records `target` as its last applied node. fn materialize<'a>( + &self, + pending: &'a mut PendingPlaceState, + target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, + reachability_constraints: &mut ReachabilityConstraintsBuilder, + ) -> &'a mut PlaceState { + self.materialize_reachability(pending, target, reachability_constraints); + + if pending.narrowing != target { + let mut unapplied = SmallVec::<[ScopedNarrowingConstraint; 4]>::new(); + let mut current = target; + while current != pending.narrowing { + let event = &self.constraints[current]; + if event.narrowing_constraint != ScopedNarrowingConstraint::ALWAYS_TRUE { + unapplied.push(event.narrowing_constraint); + } + assert_ne!( + current, event.parent, + "pending narrowing must be an ancestor" + ); + current = event.parent; + } + + if !unapplied.is_empty() { + let state = Rc::make_mut(&mut pending.state); + for constraint in unapplied.into_iter().rev() { + state.record_narrowing_constraint(narrowing_constraints, constraint); + } + } + pending.narrowing = target; + } + + Rc::make_mut(&mut pending.state) + } + + fn materialize_reachability<'a>( &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, @@ -1434,7 +1476,7 @@ impl PendingReachability { let mut current = target; while current != pending.reachability { let event = &self.constraints[current]; - unapplied.push(event.constraint); + unapplied.push(event.reachability_constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1461,14 +1503,34 @@ impl PendingReachability { &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) -> &'a PlaceState { - if pending.reachability != target { - self.materialize(pending, target, reachability_constraints); + if pending.reachability != target || pending.narrowing != target { + self.materialize( + pending, + target, + narrowing_constraints, + reachability_constraints, + ); } &pending.state } + /// Returns the place state needed to resolve a use. + /// + /// A call's narrowing gate is only needed if the place is later changed or merged, so it is + /// not materialized here. + fn materialize_ref_at_use<'a>( + &self, + pending: &'a mut PendingPlaceState, + target: PendingReachabilityId, + reachability_constraints: &mut ReachabilityConstraintsBuilder, + ) -> &'a PlaceState { + self.materialize_reachability(pending, target, reachability_constraints); + &pending.state + } + /// Combines the constraints after `ancestor` through `target` into a single constraint. /// /// `ancestor` must be an ancestor of `target`. @@ -1482,7 +1544,8 @@ impl PendingReachability { let mut current = target; while current != ancestor { let event = &self.constraints[current]; - constraint = reachability_constraints.add_and_constraint(constraint, event.constraint); + constraint = reachability_constraints + .add_and_constraint(constraint, event.reachability_constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1498,6 +1561,7 @@ impl PendingReachability { struct PendingPlaceState { state: Rc, reachability: PendingReachabilityId, + narrowing: PendingReachabilityId, } impl PendingPlaceState { @@ -1505,6 +1569,7 @@ impl PendingPlaceState { Self { state: Rc::new(state), reachability, + narrowing: reachability, } } } @@ -1538,7 +1603,12 @@ impl PendingReachability { let mut branch_states = branch_states.into_iter(); for current in current_states { let Some(mut branch_state) = branch_states.next() else { - let current = self.materialize(current, self.current, reachability_constraints); + let current = self.materialize( + current, + self.current, + narrowing_constraints, + reachability_constraints, + ); current.merge( PlaceState::undefined(branch_reachability), narrowing_constraints, @@ -1551,6 +1621,7 @@ impl PendingReachability { // common case is a truthy/falsy pair whose constraints cancel to `ALWAYS_TRUE`, leaving // the shared state untouched. if current.reachability == branch_state.reachability + && current.narrowing == branch_state.narrowing && Rc::ptr_eq(¤t.state, &branch_state.state) { if self.current == branch { @@ -1575,13 +1646,27 @@ impl PendingReachability { merged_constraint, ); } + // Neither branch changed this place, so their pending narrowing gates cannot be + // correlated with branch-local narrowing and can be discarded. The merged + // reachability constraint above still excludes paths containing terminal calls. current.reachability = self.current; + current.narrowing = self.current; continue; } - self.materialize(&mut branch_state, branch, reachability_constraints); + self.materialize( + &mut branch_state, + branch, + narrowing_constraints, + reachability_constraints, + ); let branch_state = Rc::unwrap_or_clone(branch_state.state); - let current = self.materialize(current, self.current, reachability_constraints); + let current = self.materialize( + current, + self.current, + narrowing_constraints, + reachability_constraints, + ); current.merge( branch_state, narrowing_constraints, @@ -1763,6 +1848,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let definitions_at_definition = DefinitionsAtDefinition { @@ -1856,6 +1942,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings_at_use( @@ -1885,6 +1972,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings( @@ -1931,6 +2019,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -1944,6 +2033,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -1973,6 +2063,7 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .clone(); @@ -1981,6 +2072,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize_ref( &mut self.member_states[member_id], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); associated_member_states.insert(member_id, state.clone()); @@ -2034,6 +2126,7 @@ impl<'db> UseDefMapBuilder<'db> { let symbol_state = self.pending_reachability.materialize( &mut self.symbol_states[symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2062,6 +2155,7 @@ impl<'db> UseDefMapBuilder<'db> { let member_state = self.pending_reachability.materialize( &mut self.member_states[member_id], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2103,6 +2197,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint(&mut self.narrowing_constraints, constraint); @@ -2112,11 +2207,31 @@ impl<'db> UseDefMapBuilder<'db> { pub(super) fn record_reachability_constraint( &mut self, constraint: ScopedReachabilityConstraintId, + ) { + self.record_reachability_constraint_impl( + constraint, + ScopedNarrowingConstraint::ALWAYS_TRUE, + ); + } + + pub(super) fn record_non_terminal_call_constraints( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, + ) { + self.record_reachability_constraint_impl(reachability_constraint, narrowing_constraint); + } + + fn record_reachability_constraint_impl( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, ) { self.reachability = self .reachability_constraints - .add_and_constraint(self.reachability, constraint); - self.pending_reachability.push(constraint); + .add_and_constraint(self.reachability, reachability_constraint); + self.pending_reachability + .push(reachability_constraint, narrowing_constraint); } pub(super) fn record_declaration( @@ -2131,6 +2246,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2169,6 +2285,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); place_state.record_declaration(def_id, self.reachability); @@ -2209,6 +2326,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2226,11 +2344,12 @@ impl<'db> UseDefMapBuilder<'db> { let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let bindings = self - .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) - .bindings() - .clone(); + let place_state = self.pending_reachability.materialize_ref_at_use( + place_state, + pending, + &mut self.reachability_constraints, + ); + let bindings = place_state.bindings().clone(); self.record_use_bindings(bindings, use_id); } @@ -2244,11 +2363,12 @@ impl<'db> UseDefMapBuilder<'db> { for place in places { let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let bindings = self - .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) - .bindings() - .clone(); + let place_state = self.pending_reachability.materialize_ref_at_use( + place_state, + pending, + &mut self.reachability_constraints, + ); + let bindings = place_state.bindings().clone(); let binding_definition_ids = bindings.iter().map(LiveBinding::binding); self.mark_definition_ids_used(binding_definition_ids); @@ -2328,7 +2448,12 @@ impl<'db> UseDefMapBuilder<'db> { ); let bindings = self .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .materialize_ref( + place_state, + pending, + &mut self.narrowing_constraints, + &mut self.reachability_constraints, + ) .bindings(); let is_class_symbol = enclosing_scope.is_class() && enclosing_place.is_symbol(); @@ -2371,6 +2496,7 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[enclosing_symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .bindings() @@ -2432,7 +2558,12 @@ impl<'db> UseDefMapBuilder<'db> { pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); let bindings = self .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .materialize_ref( + place_state, + pending, + &mut self.narrowing_constraints, + &mut self.reachability_constraints, + ) .bindings(); bindings.iter().copied() @@ -2519,7 +2650,9 @@ impl<'db> UseDefMapBuilder<'db> { .iter_mut() .chain(self.member_states.iter_mut()) { - self.pending_reachability.materialize( + // No later state change can require the correlation represented by pending call + // narrowing gates, so only reachability needs to be finalized here. + self.pending_reachability.materialize_reachability( state, pending, &mut self.reachability_constraints, diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index d8073960e2..aa677229aa 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -198,6 +198,26 @@ def _(val: int | None): reveal_type(val) # revealed: int ``` +Narrowing that occurs after the `NoReturn` call must also be discarded with the unreachable branch: + +```py +from typing_extensions import Never + +def fail() -> Never: + raise RuntimeError + +def _(x: int | None, flag: bool): + if flag: + fail() + if x is not None: + return + else: + if x is None: + return + + reveal_type(x) # revealed: int +``` + And for elif branches: ```py From bfa66e6063f4c1dc4b5faee397831386fe95f9c2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 13 Jul 2026 20:39:14 -0700 Subject: [PATCH 047/222] [ty] Revert deferred call-gate optimization (#26792) ## Summary This reverts #26775 after [a post-merge finding](https://github.com/astral-sh/ruff/pull/26775#issuecomment-4964970253) showed that deferred call gates are dropped too early at nested control-flow merges. An unrelated nested `if` can therefore discard a preceding `Never` call gate and allow narrowing from an unreachable path to widen the final type. We restore the previous eager call-gate materialization while we continue the optimization in a separate draft PR. --- crates/ruff_benchmark/benches/ty.rs | 26 --- crates/ty_python_core/src/builder.rs | 15 +- crates/ty_python_core/src/use_def.rs | 195 +++--------------- .../mdtest/narrow/post_if_statement.md | 20 -- 4 files changed, 39 insertions(+), 217 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index 87bce66dbd..b213c0aedf 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1413,31 +1413,6 @@ fn benchmark_typeis_narrowing(criterion: &mut Criterion) { }); } -/// Regression benchmark for . -/// -/// Non-terminal-call predicates must gate later narrowing. Keeping these scope-wide constraints in -/// an append-only tree avoids eagerly rewriting every live place state after each call. -fn benchmark_repeated_statement_calls(criterion: &mut Criterion) { - setup_rayon(); - - let mut code = String::from("def f() -> None:\n value = 'abc'\n"); - for _ in 0..1_500 { - code.push_str(" value.upper()\n"); - } - - criterion.bench_function("ty_micro[repeated_statement_calls]", |b| { - b.iter_batched_ref( - || setup_micro_case(&code), - |case| { - let Case { db, .. } = case; - let result = db.check(); - assert_eq!(result.len(), 0); - }, - BatchSize::SmallInput, - ); - }); -} - /// Benchmarks solving many union-bearing upper bounds while inferring a generic call. /// /// Each callable argument places a distinct union upper bound on `T` through callable-parameter @@ -1955,7 +1930,6 @@ criterion_group!( benchmark_literal_equality_fallthrough_guarded_any, benchmark_literal_or_pattern_reachability, benchmark_typeis_narrowing, - benchmark_repeated_statement_calls, benchmark_factored_upper_bounds, benchmark_pandas_tdd, benchmark_recursive_typed_dict_union_contextual_inference, diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 04122cb8e7..af900fa6ab 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -4188,14 +4188,15 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .add_atom(predicate_id); if self.in_function_scope() { - let reachability_constraint = self - .current_reachability_constraints_mut() - .add_atom(predicate_id); + self.record_reachability_constraint_id(predicate_id); + + // Also gate narrowing by this constraint: if the call returns + // `Never`, any narrowing in the current branch should be + // invalidated (since this path is unreachable). This enables + // narrowing to be preserved after if-statements where one branch + // calls a `NoReturn` function like `sys.exit()`. self.current_use_def_map_mut() - .record_non_terminal_call_constraints( - reachability_constraint, - narrowing_constraint, - ); + .record_narrowing_constraint_for_all_places(narrowing_constraint); } else { // In non-function scopes, we only record a narrowing constraint // (not a reachability constraint). Recording reachability for diff --git a/crates/ty_python_core/src/use_def.rs b/crates/ty_python_core/src/use_def.rs index 6d9e4b6e52..9babfd5b09 100644 --- a/crates/ty_python_core/src/use_def.rs +++ b/crates/ty_python_core/src/use_def.rs @@ -1382,14 +1382,14 @@ struct PendingReachabilityId; #[derive(Debug)] struct PendingReachabilityConstraint { parent: PendingReachabilityId, - reachability_constraint: ScopedReachabilityConstraintId, - narrowing_constraint: ScopedNarrowingConstraint, + constraint: ScopedReachabilityConstraintId, } -/// An append-only tree of scope-wide reachability constraints and call narrowing gates. +/// An append-only tree of scope-wide reachability constraints. /// -/// Each [`PendingPlaceState`] remembers the last node applied for each constraint kind, so -/// snapshots can share place states and defer applying subsequent constraints until needed. +/// Each [`PendingPlaceState`] remembers the last node applied to its place state, so snapshots can +/// share place states and defer applying subsequent constraints until the place is observed or +/// changed. #[derive(Debug)] struct PendingReachability { constraints: IndexVec, @@ -1402,8 +1402,7 @@ impl Default for PendingReachability { let root = constraints.next_index(); constraints.push(PendingReachabilityConstraint { parent: root, - reachability_constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, - narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, + constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, }); Self { constraints, @@ -1413,59 +1412,18 @@ impl Default for PendingReachability { } impl PendingReachability { - fn push( - &mut self, - reachability_constraint: ScopedReachabilityConstraintId, - narrowing_constraint: ScopedNarrowingConstraint, - ) { + fn push(&mut self, constraint: ScopedReachabilityConstraintId) { self.current = self.constraints.push(PendingReachabilityConstraint { parent: self.current, - reachability_constraint, - narrowing_constraint, + constraint, }); } - /// Applies both constraint kinds between the place's last materialized nodes and `target`. + /// Applies the constraints between the place's last materialized node and `target`. /// /// The place's node must be an ancestor of `target`. After materialization, the place is /// uniquely owned for mutation and records `target` as its last applied node. fn materialize<'a>( - &self, - pending: &'a mut PendingPlaceState, - target: PendingReachabilityId, - narrowing_constraints: &mut NarrowingConstraintsBuilder, - reachability_constraints: &mut ReachabilityConstraintsBuilder, - ) -> &'a mut PlaceState { - self.materialize_reachability(pending, target, reachability_constraints); - - if pending.narrowing != target { - let mut unapplied = SmallVec::<[ScopedNarrowingConstraint; 4]>::new(); - let mut current = target; - while current != pending.narrowing { - let event = &self.constraints[current]; - if event.narrowing_constraint != ScopedNarrowingConstraint::ALWAYS_TRUE { - unapplied.push(event.narrowing_constraint); - } - assert_ne!( - current, event.parent, - "pending narrowing must be an ancestor" - ); - current = event.parent; - } - - if !unapplied.is_empty() { - let state = Rc::make_mut(&mut pending.state); - for constraint in unapplied.into_iter().rev() { - state.record_narrowing_constraint(narrowing_constraints, constraint); - } - } - pending.narrowing = target; - } - - Rc::make_mut(&mut pending.state) - } - - fn materialize_reachability<'a>( &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, @@ -1476,7 +1434,7 @@ impl PendingReachability { let mut current = target; while current != pending.reachability { let event = &self.constraints[current]; - unapplied.push(event.reachability_constraint); + unapplied.push(event.constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1503,34 +1461,14 @@ impl PendingReachability { &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, - narrowing_constraints: &mut NarrowingConstraintsBuilder, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) -> &'a PlaceState { - if pending.reachability != target || pending.narrowing != target { - self.materialize( - pending, - target, - narrowing_constraints, - reachability_constraints, - ); + if pending.reachability != target { + self.materialize(pending, target, reachability_constraints); } &pending.state } - /// Returns the place state needed to resolve a use. - /// - /// A call's narrowing gate is only needed if the place is later changed or merged, so it is - /// not materialized here. - fn materialize_ref_at_use<'a>( - &self, - pending: &'a mut PendingPlaceState, - target: PendingReachabilityId, - reachability_constraints: &mut ReachabilityConstraintsBuilder, - ) -> &'a PlaceState { - self.materialize_reachability(pending, target, reachability_constraints); - &pending.state - } - /// Combines the constraints after `ancestor` through `target` into a single constraint. /// /// `ancestor` must be an ancestor of `target`. @@ -1544,8 +1482,7 @@ impl PendingReachability { let mut current = target; while current != ancestor { let event = &self.constraints[current]; - constraint = reachability_constraints - .add_and_constraint(constraint, event.reachability_constraint); + constraint = reachability_constraints.add_and_constraint(constraint, event.constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1561,7 +1498,6 @@ impl PendingReachability { struct PendingPlaceState { state: Rc, reachability: PendingReachabilityId, - narrowing: PendingReachabilityId, } impl PendingPlaceState { @@ -1569,7 +1505,6 @@ impl PendingPlaceState { Self { state: Rc::new(state), reachability, - narrowing: reachability, } } } @@ -1603,12 +1538,7 @@ impl PendingReachability { let mut branch_states = branch_states.into_iter(); for current in current_states { let Some(mut branch_state) = branch_states.next() else { - let current = self.materialize( - current, - self.current, - narrowing_constraints, - reachability_constraints, - ); + let current = self.materialize(current, self.current, reachability_constraints); current.merge( PlaceState::undefined(branch_reachability), narrowing_constraints, @@ -1621,7 +1551,6 @@ impl PendingReachability { // common case is a truthy/falsy pair whose constraints cancel to `ALWAYS_TRUE`, leaving // the shared state untouched. if current.reachability == branch_state.reachability - && current.narrowing == branch_state.narrowing && Rc::ptr_eq(¤t.state, &branch_state.state) { if self.current == branch { @@ -1646,27 +1575,13 @@ impl PendingReachability { merged_constraint, ); } - // Neither branch changed this place, so their pending narrowing gates cannot be - // correlated with branch-local narrowing and can be discarded. The merged - // reachability constraint above still excludes paths containing terminal calls. current.reachability = self.current; - current.narrowing = self.current; continue; } - self.materialize( - &mut branch_state, - branch, - narrowing_constraints, - reachability_constraints, - ); + self.materialize(&mut branch_state, branch, reachability_constraints); let branch_state = Rc::unwrap_or_clone(branch_state.state); - let current = self.materialize( - current, - self.current, - narrowing_constraints, - reachability_constraints, - ); + let current = self.materialize(current, self.current, reachability_constraints); current.merge( branch_state, narrowing_constraints, @@ -1848,7 +1763,6 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let definitions_at_definition = DefinitionsAtDefinition { @@ -1942,7 +1856,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings_at_use( @@ -1972,7 +1885,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings( @@ -2019,7 +1931,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -2033,7 +1944,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -2063,7 +1973,6 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[symbol], pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .clone(); @@ -2072,7 +1981,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize_ref( &mut self.member_states[member_id], pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); associated_member_states.insert(member_id, state.clone()); @@ -2126,7 +2034,6 @@ impl<'db> UseDefMapBuilder<'db> { let symbol_state = self.pending_reachability.materialize( &mut self.symbol_states[symbol], pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2155,7 +2062,6 @@ impl<'db> UseDefMapBuilder<'db> { let member_state = self.pending_reachability.materialize( &mut self.member_states[member_id], pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2197,7 +2103,6 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint(&mut self.narrowing_constraints, constraint); @@ -2207,31 +2112,11 @@ impl<'db> UseDefMapBuilder<'db> { pub(super) fn record_reachability_constraint( &mut self, constraint: ScopedReachabilityConstraintId, - ) { - self.record_reachability_constraint_impl( - constraint, - ScopedNarrowingConstraint::ALWAYS_TRUE, - ); - } - - pub(super) fn record_non_terminal_call_constraints( - &mut self, - reachability_constraint: ScopedReachabilityConstraintId, - narrowing_constraint: ScopedNarrowingConstraint, - ) { - self.record_reachability_constraint_impl(reachability_constraint, narrowing_constraint); - } - - fn record_reachability_constraint_impl( - &mut self, - reachability_constraint: ScopedReachabilityConstraintId, - narrowing_constraint: ScopedNarrowingConstraint, ) { self.reachability = self .reachability_constraints - .add_and_constraint(self.reachability, reachability_constraint); - self.pending_reachability - .push(reachability_constraint, narrowing_constraint); + .add_and_constraint(self.reachability, constraint); + self.pending_reachability.push(constraint); } pub(super) fn record_declaration( @@ -2246,7 +2131,6 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2285,7 +2169,6 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); place_state.record_declaration(def_id, self.reachability); @@ -2326,7 +2209,6 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2344,12 +2226,11 @@ impl<'db> UseDefMapBuilder<'db> { let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let place_state = self.pending_reachability.materialize_ref_at_use( - place_state, - pending, - &mut self.reachability_constraints, - ); - let bindings = place_state.bindings().clone(); + let bindings = self + .pending_reachability + .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .bindings() + .clone(); self.record_use_bindings(bindings, use_id); } @@ -2363,12 +2244,11 @@ impl<'db> UseDefMapBuilder<'db> { for place in places { let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let place_state = self.pending_reachability.materialize_ref_at_use( - place_state, - pending, - &mut self.reachability_constraints, - ); - let bindings = place_state.bindings().clone(); + let bindings = self + .pending_reachability + .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .bindings() + .clone(); let binding_definition_ids = bindings.iter().map(LiveBinding::binding); self.mark_definition_ids_used(binding_definition_ids); @@ -2448,12 +2328,7 @@ impl<'db> UseDefMapBuilder<'db> { ); let bindings = self .pending_reachability - .materialize_ref( - place_state, - pending, - &mut self.narrowing_constraints, - &mut self.reachability_constraints, - ) + .materialize_ref(place_state, pending, &mut self.reachability_constraints) .bindings(); let is_class_symbol = enclosing_scope.is_class() && enclosing_place.is_symbol(); @@ -2496,7 +2371,6 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[enclosing_symbol], pending, - &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .bindings() @@ -2558,12 +2432,7 @@ impl<'db> UseDefMapBuilder<'db> { pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); let bindings = self .pending_reachability - .materialize_ref( - place_state, - pending, - &mut self.narrowing_constraints, - &mut self.reachability_constraints, - ) + .materialize_ref(place_state, pending, &mut self.reachability_constraints) .bindings(); bindings.iter().copied() @@ -2650,9 +2519,7 @@ impl<'db> UseDefMapBuilder<'db> { .iter_mut() .chain(self.member_states.iter_mut()) { - // No later state change can require the correlation represented by pending call - // narrowing gates, so only reachability needs to be finalized here. - self.pending_reachability.materialize_reachability( + self.pending_reachability.materialize( state, pending, &mut self.reachability_constraints, diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index aa677229aa..d8073960e2 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -198,26 +198,6 @@ def _(val: int | None): reveal_type(val) # revealed: int ``` -Narrowing that occurs after the `NoReturn` call must also be discarded with the unreachable branch: - -```py -from typing_extensions import Never - -def fail() -> Never: - raise RuntimeError - -def _(x: int | None, flag: bool): - if flag: - fail() - if x is not None: - return - else: - if x is None: - return - - reveal_type(x) # revealed: int -``` - And for elif branches: ```py From fdd9f06b116664819e57d56903e4c2ad645475b1 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Tue, 14 Jul 2026 14:02:48 +0530 Subject: [PATCH 048/222] [ty] Expand transparent callable workaround with overloads and `Awaitable[T]` (#26761) ## Summary Extend the transparent callable decorator workaround to inspect the uniquely selected overload and recognize matching return type variables wrapped in `Awaitable` without treating `Awaitable[R]` and `R` as equivalent. This is part of the work tracked in [astral-sh/ty#2278](https://github.com/astral-sh/ty/issues/2278) and builds on [astral-sh/ruff#25030](https://github.com/astral-sh/ruff/pull/25030) and [astral-sh/ruff#25806](https://github.com/astral-sh/ruff/pull/25806). ## Test plan Added mdtests covering selection of a transparent overload, matching `Awaitable[T]` return types including rejection of an `Awaitable[T]`-to-`T` mismatch, and the combination of overload selection with `Awaitable[T]`. --- .../mdtest/generics/pep695/paramspec.md | 101 ++++++++++++++++ .../src/types/infer/builder.rs | 113 +++++++++++------- 2 files changed, 171 insertions(+), 43 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index 3463a7fec9..cab9664d3d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -1038,6 +1038,107 @@ reveal_type(lazy_frame.collect(background=False)) # revealed: DataFrame reveal_type(lazy_frame.collect(background=True)) # revealed: InProcessQuery ``` +The transparent decorator can be one overload among several. The workaround only inspects the +overload selected by the decorator call. + +```py +from typing import Any, Callable, overload, reveal_type + +@overload +def select_transparent(function: Callable[P, R], /) -> Callable[P, R]: ... +@overload +def select_transparent(*, value: int | None = None) -> Callable[[Callable[P, R]], Callable[P, R]]: ... +def select_transparent(function: Callable[P, R] | None = None, *, value: int | None = None) -> Any: + raise NotImplementedError + +@overload +def selected(value: int) -> int: ... +@overload +def selected(value: str) -> str: ... +@select_transparent +def selected(value: int | str) -> int | str: + raise NotImplementedError + +reveal_type(selected) # revealed: Overload[(value: int) -> int, (value: str) -> str] +reveal_type(selected(1)) # revealed: int +reveal_type(selected("one")) # revealed: str +``` + +A transparent decorator can preserve a return type variable wrapped in `Awaitable`. + +```py +from collections.abc import Awaitable, Callable +from typing import overload + +def awaitable_transparent(function: Callable[P, Awaitable[R]], /) -> Callable[P, Awaitable[R]]: + raise NotImplementedError + +@overload +async def awaitable(value: int) -> int: ... +@overload +async def awaitable(value: str) -> str: ... +@awaitable_transparent +async def awaitable(value: int | str) -> int | str: + raise NotImplementedError + +reveal_type( + awaitable # revealed: Overload[(value: int) -> CoroutineType[Any, Any, int], (value: str) -> CoroutineType[Any, Any, str]] +) + +async def check_awaitable() -> None: + reveal_type(await awaitable(1)) # revealed: int + reveal_type(await awaitable("one")) # revealed: str + +def unwrap_awaitable(function: Callable[P, Awaitable[R]], /) -> Callable[P, R]: + raise NotImplementedError + +@overload +async def unwrapped(value: int) -> int: ... +@overload +async def unwrapped(value: str) -> str: ... +@unwrap_awaitable +async def unwrapped(value: int | str) -> int | str: + raise NotImplementedError + +reveal_type(unwrapped(1)) # revealed: int | str +``` + +The selected decorator overload can use an `Awaitable` return type. + +```py +from collections.abc import Awaitable, Callable +from typing import overload + +@overload +def select_awaitable_transparent(function: Callable[P, Awaitable[R]], /) -> Callable[P, Awaitable[R]]: ... +@overload +def select_awaitable_transparent( + *, value: int | None = None +) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: ... +def select_awaitable_transparent( + function: Callable[P, Awaitable[R]] | None = None, + *, + value: int | None = None, +) -> Any: + raise NotImplementedError + +@overload +async def selected_awaitable(value: int) -> int: ... +@overload +async def selected_awaitable(value: str) -> str: ... +@select_awaitable_transparent +async def selected_awaitable(value: int | str) -> int | str: + raise NotImplementedError + +reveal_type( + selected_awaitable # revealed: Overload[(value: int) -> CoroutineType[Any, Any, int], (value: str) -> CoroutineType[Any, Any, str]] +) + +async def check_selected_awaitable() -> None: + reveal_type(await selected_awaitable(1)) # revealed: int + reveal_type(await selected_awaitable("one")) # revealed: str +``` + ### Overloads #### Return type filtering diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index cbc0d8e6e2..db04854a28 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -362,46 +362,74 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { /// An expression cache shared across builders during multi-inference. type ExpressionCache<'db> = FxHashMap<(ExpressionNodeKey, TypeContext<'db>), Type<'db>>; -fn callable_paramspec_and_return_typevar<'db>( - db: &'db dyn Db, - ty: Type<'db>, -) -> Option<(BoundTypeVarInstance<'db>, BoundTypeVarInstance<'db>)> { - let callable = ty.resolve_type_alias(db).as_callable()?; - if callable.kind(db) != CallableTypeKind::Regular { - return None; - } - let [signature] = callable.signatures(db).overloads.as_slice() else { - return None; - }; - let paramspec = signature.parameters().as_paramspec()?; - Some((paramspec, signature.return_ty.as_typevar()?)) -} - fn transparent_callable_decorator_result<'db>( db: &'db dyn Db, - decorator_ty: Type<'db>, + bindings: &Bindings<'db>, decorated_ty: Type<'db>, ) -> Option> { + enum TransparentCallableReturn<'db> { + TypeVar(BoundTypeVarInstance<'db>), + Awaitable(BoundTypeVarInstance<'db>), + } + + impl<'db> TransparentCallableReturn<'db> { + fn matches(self, db: &'db dyn Db, other: Self) -> bool { + match (self, other) { + (Self::TypeVar(left), Self::TypeVar(right)) + | (Self::Awaitable(left), Self::Awaitable(right)) => { + left.is_same_typevar_as(db, right) + } + _ => false, + } + } + } + + fn callable_paramspec_and_return<'db>( + db: &'db dyn Db, + ty: Type<'db>, + ) -> Option<(BoundTypeVarInstance<'db>, TransparentCallableReturn<'db>)> { + let callable = ty.resolve_type_alias(db).as_callable()?; + if callable.kind(db) != CallableTypeKind::Regular { + return None; + } + let [signature] = callable.signatures(db).overloads.as_slice() else { + return None; + }; + let paramspec = signature.parameters().as_paramspec()?; + let return_typevar = if let Some(typevar) = signature.return_ty.as_typevar() { + TransparentCallableReturn::TypeVar(typevar) + } else { + let specialization = signature + .return_ty + .known_specialization(db, KnownClass::Awaitable)?; + let [inner] = specialization.types(db) else { + return None; + }; + TransparentCallableReturn::Awaitable(inner.as_typevar()?) + }; + Some((paramspec, return_typevar)) + } + if !matches!(decorated_ty, Type::FunctionLiteral(_) | Type::Callable(_)) { return None; } - let decorator_callable = decorator_ty - .try_upcast_to_callable(db) - .and_then(CallableTypes::exactly_one)?; - let decorator_signatures = decorator_callable.signatures(db); - let [decorator_signature] = decorator_signatures.overloads.as_slice() else { - return None; - }; + let binding = bindings.single_element()?; + let (_, overload) = binding.matching_overloads().exactly_one().ok()?; + let decorator_signature = &overload.signature; + let bound_signature = binding + .bound_type + .map(|bound_type| decorator_signature.bind_self(db, Some(bound_type))); + let decorator_signature = bound_signature.as_ref().unwrap_or(decorator_signature); let [parameter] = decorator_signature.parameters().as_slice() else { return None; }; - let (parameter_callable_paramspec, parameter_callable_return_typevar) = - callable_paramspec_and_return_typevar(db, parameter.annotated_type())?; - let (return_callable_paramspec, return_callable_typevar) = - callable_paramspec_and_return_typevar(db, decorator_signature.return_ty)?; + let (parameter_callable_paramspec, parameter_callable_return) = + callable_paramspec_and_return(db, parameter.annotated_type())?; + let (return_callable_paramspec, return_callable_return) = + callable_paramspec_and_return(db, decorator_signature.return_ty)?; if !parameter_callable_paramspec.is_same_typevar_as(db, return_callable_paramspec) - || !parameter_callable_return_typevar.is_same_typevar_as(db, return_callable_typevar) + || !parameter_callable_return.matches(db, return_callable_return) { return None; } @@ -4763,11 +4791,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let decorated_ty = self.get_or_infer_expression(decorated_expression, TypeContext::default()); let call_arguments = CallArguments::positional([decorated_ty]); - if decorator_ty.try_call(self.db(), &call_arguments).is_err() { + let Ok(bindings) = decorator_ty.try_call(self.db(), &call_arguments) else { return return_ty; - } + }; - transparent_callable_decorator_result(self.db(), decorator_ty, decorated_ty) + transparent_callable_decorator_result(self.db(), &bindings, decorated_ty) .unwrap_or(return_ty) } @@ -4860,21 +4888,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let call_arguments = CallArguments::positional([decorated_ty]); - let mut decorator_call_succeeded = true; - let return_ty = decorator_ty - .try_call(self.db(), &call_arguments) - .map(|bindings| bindings.return_type(self.db())) - .unwrap_or_else(|CallError(_, bindings)| { - decorator_call_succeeded = false; - bindings.report_diagnostics(&self.context, decorator_node.into()); - bindings.return_type(self.db()) - }); + let (return_ty, decorator_bindings) = + match decorator_ty.try_call(self.db(), &call_arguments) { + Ok(bindings) => (bindings.return_type(self.db()), Some(bindings)), + Err(CallError(_, bindings)) => { + bindings.report_diagnostics(&self.context, decorator_node.into()); + (bindings.return_type(self.db()), None) + } + }; // TODO: Remove this special case once the new constraint solver can preserve - // per-overload ParamSpec/return correlations for `Callable[P, R] -> Callable[P, R]`. - if decorator_call_succeeded + // per-overload ParamSpec/return correlations for transparent callable decorators. + if let Some(decorator_bindings) = decorator_bindings.as_ref() && let Some(result) = - transparent_callable_decorator_result(self.db(), decorator_ty, decorated_ty) + transparent_callable_decorator_result(self.db(), decorator_bindings, decorated_ty) { return result; } From 9ece127cc87220d05aff684a9b390d52152a6041 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Jul 2026 15:19:19 +0200 Subject: [PATCH 049/222] [ty] Rewrite inlay hint test to avoid `isinstance` dependency (#26800) ## Summary Rewrite inlay hint tests for `Top` materializations to use `Top[..]` explicitly, instead of relying on `isinstance` narrowing behavior which is about to change. --- crates/ty_ide/src/inlay_hints.rs | 52 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index f0b8f1f7a2..72f16fc88f 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -7148,20 +7148,24 @@ Source with applied edits: } #[test] - fn hover_narrowed_type_with_top_materialization() { + fn hover_type_with_top_materialization() { let mut test = inlay_hint_test( r#" - def f(xyxy: object): - if isinstance(xyxy, list): - x = xyxy + from typing import Any + from ty_extensions import Top + + def f(xyxy: Top[list[Any]]): + x = xyxy "#, ); assert_snapshot!(test.inlay_hints(), @" - def f(xyxy: object): - if isinstance(xyxy, list): - x[: Top[list[Unknown]]] = xyxy + from typing import Any + from ty_extensions import Top + + def f(xyxy: Top[list[Any]]): + x[: Top[list[Any]]] = xyxy --------------------------------------------- info[inlay-hint-location]: Inlay Hint Target @@ -7171,10 +7175,10 @@ Source with applied edits: | ^^^ | info: Source - --> main2.py:LL:13 + --> main2.py:LL:9 | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^ + LL | x[: Top[list[Any]]] = xyxy + | ^^^ | info[inlay-hint-location]: Inlay Hint Target @@ -7184,36 +7188,32 @@ Source with applied edits: | ^^^^ | info: Source - --> main2.py:LL:17 + --> main2.py:LL:13 | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^^ + LL | x[: Top[list[Any]]] = xyxy + | ^^^^ | info[inlay-hint-location]: Inlay Hint Target - --> stdlib/ty_extensions/__init__.pyi:LL:1 + --> stdlib/typing.pyi:LL:7 | - LL | Unknown: _SpecialForm - | ^^^^^^^ + LL | class Any: + | ^^^ | info: Source - --> main2.py:LL:22 + --> main2.py:LL:18 | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^^^^^ + LL | x[: Top[list[Any]]] = xyxy + | ^^^ | --------------------------------------------- info[inlay-hint-edit]: Inlay hint edits --> main.py:1:1 | - 1 + from ty_extensions import Top - 2 + from ty_extensions import Unknown - 3 | - 4 | def f(xyxy: object): - 5 | if isinstance(xyxy, list): - - x = xyxy - 6 + x: Top[list[Unknown]] = xyxy + 5 | def f(xyxy: Top[list[Any]]): + - x = xyxy + 6 + x: Top[list[Any]] = xyxy | "); } From 9036c134635bbcc32e897b37b39a0894c44785a3 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Tue, 14 Jul 2026 10:26:33 -0400 Subject: [PATCH 050/222] Move incremental ignore-file checking down into `System` (#26771) Previously, this checking was done on top of our `System` abstraction. But we want the `ignore` crate to own this, which means it has to either be within `System` or we need to do some other tomfoolery to make wasm targets continue to work. The API surface area of incremental ignore file checking is pretty small, so I chose to push it down into `System`. It slots in nicely with the existing `WalkDirectoryBuilder` abstraction. Indeed, this should work with how the `ignore` crate will build incremental matchers (from a `WalkBuilder`). --- Cargo.lock | 1 - crates/ruff_db/src/system/memory_fs.rs | 41 ++++- crates/ruff_db/src/system/os.rs | 38 ++-- .../db => ruff_db/src/system/os}/ignore.rs | 169 ++++++++++-------- crates/ruff_db/src/system/walk_directory.rs | 39 ++++ crates/ty_project/Cargo.toml | 1 - crates/ty_project/src/db.rs | 1 - crates/ty_project/src/db/changes.rs | 12 +- crates/ty_project/src/walk.rs | 12 +- 9 files changed, 203 insertions(+), 111 deletions(-) rename crates/{ty_project/src/db => ruff_db/src/system/os}/ignore.rs (73%) diff --git a/Cargo.lock b/Cargo.lock index 9d53a24ceb..93e129f30d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4772,7 +4772,6 @@ dependencies = [ "crossbeam", "get-size2", "globset", - "ignore", "insta", "notify", "ordermap", diff --git a/crates/ruff_db/src/system/memory_fs.rs b/crates/ruff_db/src/system/memory_fs.rs index b8c469ca6e..3b3da5c324 100644 --- a/crates/ruff_db/src/system/memory_fs.rs +++ b/crates/ruff_db/src/system/memory_fs.rs @@ -13,8 +13,8 @@ use crate::system::{ }; use super::walk_directory::{ - DirectoryWalker, WalkDirectoryBuilder, WalkDirectoryConfiguration, WalkDirectoryVisitor, - WalkDirectoryVisitorBuilder, WalkState, + DirectoryWalker, IgnoreIncremental, Ignored, WalkDirectoryBuilder, WalkDirectoryConfiguration, + WalkDirectoryVisitor, WalkDirectoryVisitorBuilder, WalkState, }; /// File system that stores all content in memory. @@ -564,6 +564,24 @@ impl Iterator for ReadDirectory { impl FusedIterator for ReadDirectory {} +struct MemoryIgnoreIncremental { + ignore_hidden: bool, +} + +impl IgnoreIncremental for MemoryIgnoreIncremental { + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { + // This matches the semantics of the in-memory recursive + // directory traversal. That is, the only thing we care + // about filtering is hidden files. We let everything else + // through. + if self.ignore_hidden && !is_directory && is_hidden(path) { + Ignored::Yes + } else { + Ignored::Uncertain + } + } +} + /// Recursively walks a directory in the memory file system. #[derive(Debug)] struct MemoryWalker { @@ -592,12 +610,7 @@ impl MemoryWalker { } state - } else if ignore_hidden - && entry - .path - .file_name() - .is_some_and(|name| name.starts_with('.')) - { + } else if ignore_hidden && is_hidden(&entry.path) { WalkState::Skip } else { visitor.visit(Ok(entry)) @@ -702,6 +715,14 @@ impl DirectoryWalker for MemoryWalker { } } } + + fn incremental_matcher( + &self, + configuration: WalkDirectoryConfiguration, + ) -> Box { + let WalkDirectoryConfiguration { ignore_hidden, .. } = configuration; + Box::new(MemoryIgnoreIncremental { ignore_hidden }) + } } #[derive(Debug)] @@ -713,6 +734,10 @@ enum WalkerState { Nested { path: SystemPathBuf, depth: usize }, } +fn is_hidden(path: &SystemPath) -> bool { + path.file_name().is_some_and(|name| name.starts_with('.')) +} + #[cfg(test)] mod tests { use std::io::ErrorKind; diff --git a/crates/ruff_db/src/system/os.rs b/crates/ruff_db/src/system/os.rs index 337e017d13..6732ed22bb 100644 --- a/crates/ruff_db/src/system/os.rs +++ b/crates/ruff_db/src/system/os.rs @@ -1,7 +1,10 @@ #![allow(clippy::disallowed_methods)] +mod ignore; + +use self::ignore::IgnoreFiles; use super::walk_directory::{ - self, DirectoryWalker, WalkDirectoryBuilder, WalkDirectoryConfiguration, + self, DirectoryWalker, IgnoreIncremental, WalkDirectoryBuilder, WalkDirectoryConfiguration, WalkDirectoryVisitorBuilder, WalkState, }; use crate::max_parallelism; @@ -173,7 +176,7 @@ impl System for OsSystem { /// Creates a builder to recursively walk `path`. /// - /// The walker ignores files according to [`ignore::WalkBuilder::standard_filters`] + /// The walker ignores files according to [`::ignore::WalkBuilder::standard_filters`] /// when setting [`WalkDirectoryBuilder::standard_filters`] to true. fn walk_directory(&self, path: &SystemPath) -> WalkDirectoryBuilder { WalkDirectoryBuilder::new( @@ -268,7 +271,7 @@ impl DirectoryWalker for OsDirectoryWalker { return; }; - let mut builder = ignore::WalkBuilder::new(first.as_std_path()); + let mut builder = ::ignore::WalkBuilder::new(first.as_std_path()); builder.current_dir(self.cwd.as_std_path()); builder.standard_filters(standard_filters); @@ -315,7 +318,7 @@ impl DirectoryWalker for OsDirectoryWalker { })); // Skip the entire directory because all the paths won't be UTF-8 paths. - ignore::WalkState::Skip + ::ignore::WalkState::Skip } } } @@ -326,22 +329,33 @@ impl DirectoryWalker for OsDirectoryWalker { // (which, should not be reported here but the `ignore` crate doesn't distinguish between ignore and IO errors). // Let's log the error to at least make it visible. tracing::warn!("Failed to traverse directory: {error}."); - ignore::WalkState::Continue + ::ignore::WalkState::Continue } }, } }) }); } + + fn incremental_matcher( + &self, + configuration: WalkDirectoryConfiguration, + ) -> Box { + // N.B. The current work-around `IgnoreFiles` implementation doesn't + // support any configuration right now. This will be fixed once we + // switch to the `ignore` crate's implementation. --AG + let WalkDirectoryConfiguration { paths, .. } = configuration; + Box::new(IgnoreFiles::new(paths)) + } } #[cold] fn ignore_to_walk_directory_error( - error: ignore::Error, + error: ::ignore::Error, path: Option, depth: Option, -) -> std::result::Result { - use ignore::Error; +) -> std::result::Result { + use ::ignore::Error; match error { Error::WithPath { path, err } => ignore_to_walk_directory_error(*err, Some(path), depth), @@ -399,12 +413,12 @@ impl From for FileType { } } -impl From for ignore::WalkState { +impl From for ::ignore::WalkState { fn from(value: WalkState) -> Self { match value { - WalkState::Continue => ignore::WalkState::Continue, - WalkState::Skip => ignore::WalkState::Skip, - WalkState::Quit => ignore::WalkState::Quit, + WalkState::Continue => ::ignore::WalkState::Continue, + WalkState::Skip => ::ignore::WalkState::Skip, + WalkState::Quit => ::ignore::WalkState::Quit, } } } diff --git a/crates/ty_project/src/db/ignore.rs b/crates/ruff_db/src/system/os/ignore.rs similarity index 73% rename from crates/ty_project/src/db/ignore.rs rename to crates/ruff_db/src/system/os/ignore.rs index b0cb9a75a1..664bfbb19f 100644 --- a/crates/ty_project/src/db/ignore.rs +++ b/crates/ruff_db/src/system/os/ignore.rs @@ -21,65 +21,29 @@ //! case where a file or directory is already ignored by an ignore file at the //! project walk root. -use ignore::gitignore; -use ruff_db::system::{System, SystemPath, SystemPathBuf}; +use ::ignore::gitignore; use rustc_hash::FxHashMap; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub(super) enum Ignored { - /// A root ignore file proves that the project walker would skip this path. - Yes, +use crate::system::walk_directory::{IgnoreIncremental, Ignored}; +use crate::system::{SystemPath, SystemPathBuf}; - /// The file might be ignored, but we need to use the ignore walker to know for sure. - Uncertain, -} - -impl Ignored { - pub(super) const fn is_uncertain(self) -> bool { - matches!(self, Self::Uncertain) - } -} - -pub(super) struct IgnoreFiles<'a> { - walk_roots: &'a [SystemPathBuf], - system: Box, +pub(super) struct IgnoreFiles { + walk_roots: Box<[SystemPathBuf]>, roots: FxHashMap, } -impl<'a> IgnoreFiles<'a> { - pub(super) fn new(system: Box, walk_roots: &'a [SystemPathBuf]) -> Self { +impl IgnoreFiles { + pub(super) fn new(walk_roots: Vec) -> Self { Self { - walk_roots, - system, + walk_roots: walk_roots.into_boxed_slice(), roots: FxHashMap::default(), } } - /// Returns `Yes` only when the matching walk root can prune `path` - /// from its own ignore files. In all other cases, return uncertain. - pub(super) fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { - // A nested explicit walk root gets its own depth-0 walk, so an ancestor - // root cannot prove that the nested root's descendants are ignored. - let Some(root) = self - .walk_roots - .iter() - .filter(|root| path.starts_with(root)) - .max_by_key(|root| root.as_str().len()) - else { - return Ignored::Uncertain; - }; - - if self.root_ignore_prunes_path(root, path, is_directory) { - Ignored::Yes - } else { - Ignored::Uncertain - } - } - /// Answers the question whether the ignore file in the `root` directory /// ignores `path`. fn root_ignore_prunes_path( - &mut self, + roots: &mut FxHashMap, root: &SystemPath, path: &SystemPath, is_directory: bool, @@ -97,14 +61,40 @@ impl<'a> IgnoreFiles<'a> { let first_child_is_directory = !first_child_is_target || is_directory; - self.root_ignore_files(root) - .is_ignored(&first_child, first_child_is_directory) + Self::root_ignore_files(roots, root).is_ignored(&first_child, first_child_is_directory) } - fn root_ignore_files(&mut self, root: &SystemPath) -> &RootIgnoreFiles { - self.roots + fn root_ignore_files<'a>( + roots: &'a mut FxHashMap, + root: &SystemPath, + ) -> &'a RootIgnoreFiles { + roots .entry(root.to_path_buf()) - .or_insert_with(|| RootIgnoreFiles::read(self.system.as_ref(), root)) + .or_insert_with(|| RootIgnoreFiles::read(root)) + } +} + +impl IgnoreIncremental for IgnoreFiles { + /// Returns `Yes` only when the matching walk root can prune `path` + /// from its own ignore files. In all other cases, return uncertain. + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { + let Self { walk_roots, roots } = self; + + // A nested explicit walk root gets its own depth-0 walk, so an ancestor + // root cannot prove that the nested root's descendants are ignored. + let Some(root) = walk_roots + .iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.as_str().len()) + else { + return Ignored::Uncertain; + }; + + if Self::root_ignore_prunes_path(roots, root, path, is_directory) { + Ignored::Yes + } else { + Ignored::Uncertain + } } } @@ -115,22 +105,26 @@ struct RootIgnoreFiles { } impl RootIgnoreFiles { - fn read(system: &dyn System, root: &SystemPath) -> Self { - let canonical_root = system.canonicalize_path(root).ok(); + fn read(root: &SystemPath) -> Self { + let canonical_root = root.as_utf8_path().canonicalize_utf8().ok().map(|path| { + SystemPathBuf::from_utf8_path_buf(path) + .simplified() + .to_path_buf() + }); let gitignore = if let Some(canonical_root) = canonical_root.as_deref() - && in_git_repository(system, canonical_root) + && in_git_repository(canonical_root) // A parent `.ignore` allowlist takes precedence over a matching // `.gitignore` at the walk root. Let the walker resolve that case. - && !has_parent_ignore_file(system, canonical_root) + && !has_parent_ignore_file(canonical_root) { - IgnoreFile::read(system, root, ".gitignore") + IgnoreFile::read(root, ".gitignore") } else { None }; Self { - ignore: IgnoreFile::read(system, root, ".ignore"), + ignore: IgnoreFile::read(root, ".ignore"), gitignore, } } @@ -155,9 +149,9 @@ enum IgnoreFile { } impl IgnoreFile { - fn read(system: &dyn System, root: &SystemPath, file_name: &str) -> Option { + fn read(root: &SystemPath, file_name: &str) -> Option { let ignore_path = root.join(file_name); - let contents = match system.read_to_string(&ignore_path) { + let contents = match std::fs::read_to_string(ignore_path.as_std_path()) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, Err(_) => return Some(Self::Error), @@ -176,9 +170,9 @@ impl IgnoreFile { }; Ok(match matcher.matched(path.as_std_path(), is_directory) { - ignore::Match::None => None, - ignore::Match::Ignore(_) => Some(true), - ignore::Match::Whitelist(_) => Some(false), + ::ignore::Match::None => None, + ::ignore::Match::Ignore(_) => Some(true), + ::ignore::Match::Whitelist(_) => Some(false), }) } } @@ -203,41 +197,51 @@ fn build_matcher( builder.build().ok() } -fn in_git_repository(system: &dyn System, canonical_root: &SystemPath) -> bool { +fn in_git_repository(canonical_root: &SystemPath) -> bool { canonical_root.ancestors().any(|directory| { - system.path_exists(&directory.join(".git")) || system.path_exists(&directory.join(".jj")) + directory.join(".git").as_std_path().exists() + || directory.join(".jj").as_std_path().exists() }) } -fn has_parent_ignore_file(system: &dyn System, canonical_root: &SystemPath) -> bool { +fn has_parent_ignore_file(canonical_root: &SystemPath) -> bool { canonical_root .parent() .into_iter() .flat_map(SystemPath::ancestors) - .any(|directory| system.path_exists(&directory.join(".ignore"))) + .any(|directory| directory.join(".ignore").as_std_path().exists()) } #[cfg(test)] mod tests { - use ruff_db::system::{InMemorySystem, System, SystemPath, SystemPathBuf}; + use tempfile::TempDir; - use super::{IgnoreFiles, Ignored}; + use crate::system::walk_directory::Ignored; + use crate::system::{OsSystem, System, SystemPath, SystemPathBuf}; struct TestProject { - system: InMemorySystem, + _temp_dir: TempDir, + system: OsSystem, root: SystemPathBuf, } impl TestProject { fn new() -> Self { - Self::with_root("/project") + Self::with_root("project") } fn with_root(root: &str) -> Self { - let system = InMemorySystem::new(root.into()); - let root = system.current_directory().to_path_buf(); - - Self { system, root } + let temp_dir = TempDir::new().unwrap(); + let temp_dir_path = SystemPath::from_std_path(temp_dir.path()).unwrap(); + let root = temp_dir_path.join(root); + std::fs::create_dir_all(root.as_std_path()).unwrap(); + let system = OsSystem::new(&root); + + Self { + _temp_dir: temp_dir, + system, + root, + } } fn path(&self, relative_path: &str) -> SystemPathBuf { @@ -245,11 +249,14 @@ mod tests { } fn write_files<'a>(&self, files: impl IntoIterator) { - self.system.fs().write_files_all(files).unwrap(); + for (path, contents) in files { + std::fs::create_dir_all(path.parent().unwrap().as_std_path()).unwrap(); + std::fs::write(path.as_std_path(), contents).unwrap(); + } } fn create_directory(&self, path: impl AsRef) { - self.system.fs().create_directory_all(path).unwrap(); + std::fs::create_dir_all(path.as_ref().as_std_path()).unwrap(); } fn is_ignored(&self, path: &SystemPath) -> Ignored { @@ -257,7 +264,14 @@ mod tests { } fn is_ignored_from(&self, walk_roots: &[SystemPathBuf], path: &SystemPath) -> Ignored { - IgnoreFiles::new(self.system.dyn_clone(), walk_roots).is_ignored(path, false) + let (first, additional) = walk_roots.split_first().unwrap(); + let mut builder = self.system.walk_directory(first); + + for root in additional { + builder = builder.add(root); + } + + builder.incremental_matcher().is_ignored(path, false) } } @@ -276,6 +290,7 @@ mod tests { #[test] fn root_gitignore_file_requires_repository() { let project = TestProject::new(); + let path = project.path("build/keep.py"); project.write_files([(project.path(".gitignore"), "build/\n")]); @@ -306,7 +321,7 @@ mod tests { #[test] fn parent_ignore_file_disables_root_gitignore_pruning() { - let project = TestProject::with_root("/workspace/project"); + let project = TestProject::with_root("workspace/project"); let path = project.path("build/keep.py"); project.write_files([ (project.path(".git/HEAD"), "ref: refs/heads/main\n"), diff --git a/crates/ruff_db/src/system/walk_directory.rs b/crates/ruff_db/src/system/walk_directory.rs index 5796321c72..5653f212a7 100644 --- a/crates/ruff_db/src/system/walk_directory.rs +++ b/crates/ruff_db/src/system/walk_directory.rs @@ -4,6 +4,29 @@ use std::path::PathBuf; use super::{FileType, SystemPath}; +/// Whether a path is known to be ignored by a directory walker. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Ignored { + /// An ignore file proves that the directory walker would skip this path. + Yes, + + /// The path might be ignored, but a directory walk is required to know for sure. + Uncertain, +} + +impl Ignored { + /// Returns `true` if a directory walk is required to determine whether the path is ignored. + pub const fn is_uncertain(self) -> bool { + matches!(self, Self::Uncertain) + } +} + +/// A matcher for determining whether paths are ignored during incremental directory walking. +pub trait IgnoreIncremental { + /// Returns whether the directory walker is known to ignore `path`. + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored; +} + /// A builder for constructing a directory recursive traversal. pub struct WalkDirectoryBuilder { /// The implementation that does the directory walking. @@ -65,6 +88,16 @@ impl WalkDirectoryBuilder { self } + /// Creates a matcher for determining whether paths are ignored during incremental walking. + pub fn incremental_matcher(self) -> Box { + let configuration = WalkDirectoryConfiguration { + paths: self.paths, + ignore_hidden: self.ignore_hidden, + standard_filters: self.standard_filters, + }; + self.walker.incremental_matcher(configuration) + } + /// Runs the directory traversal and calls the passed `builder` to create visitors /// that do the visiting. The walker may run multiple threads to visit the directories. pub fn run<'s, F>(self, builder: F) @@ -94,6 +127,12 @@ pub trait DirectoryWalker { builder: &mut dyn WalkDirectoryVisitorBuilder, configuration: WalkDirectoryConfiguration, ); + + /// Creates a matcher for determining whether paths are ignored during incremental walking. + fn incremental_matcher( + &self, + configuration: WalkDirectoryConfiguration, + ) -> Box; } /// Creates a visitor for each thread that does the visiting. diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index d0648ebd40..005e836499 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -40,7 +40,6 @@ compact_str = { workspace = true, features = ["serde"] } crossbeam = { workspace = true } get-size2 = { workspace = true, features = ["ordermap"] } globset = { workspace = true } -ignore = { workspace = true } notify = { workspace = true } ordermap = { workspace = true, features = ["serde"] } pep440_rs = { workspace = true, features = ["version-ranges"] } diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 127562e796..43d517f400 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -22,7 +22,6 @@ use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{AnalysisSettings, Db as SemanticDb}; mod changes; -mod ignore; #[salsa::db] pub trait Db: SemanticDb { diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index 4f4e1a0d5c..ef418b7064 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -3,8 +3,7 @@ use crate::watch::{ChangeEvent, CreatedKind, DeletedKind}; use crate::{ProjectMetadata, ProjectReloadResult}; use std::collections::BTreeSet; -use super::ignore::IgnoreFiles; -use crate::walk::ProjectFilesWalker; +use crate::walk::{ProjectFilesWalker, create_walker_builder}; use ruff_db::Db as _; use ruff_db::files::{File, Files, system_path_to_file}; use ruff_db::system::{SystemPath, SystemPathBuf}; @@ -55,12 +54,15 @@ impl ProjectDatabase { let mut removed_paths = BTreeSet::default(); let mut reload_project = false; let mut reload_project_files = false; + // TODO: This should be removed once the incremental checker is ported + // over to the `ignore` crate, since the `ignore` crate will respect + // the settings provided in `create_walker`. ---AG let respect_ignore_files = project.settings(self).src().respect_ignore_files; let ignore_walk_roots = respect_ignore_files.then(|| project.included_paths_or_root(self).to_vec()); - let mut ignore_files = ignore_walk_roots - .as_deref() - .map(|walk_roots| IgnoreFiles::new(self.system().dyn_clone(), walk_roots)); + let mut ignore_files = ignore_walk_roots.as_deref().and_then(|walk_roots| { + Some(create_walker_builder(self, walk_roots)?.incremental_matcher()) + }); for change in changes { tracing::debug!("Handling file watcher change event: {:?}", change); diff --git a/crates/ty_project/src/walk.rs b/crates/ty_project/src/walk.rs index 4b5c4e5a5d..6efea29574 100644 --- a/crates/ty_project/src/walk.rs +++ b/crates/ty_project/src/walk.rs @@ -152,14 +152,14 @@ impl ProjectFilesWalker { return (Vec::new(), Vec::new()); } - create_walker( + create_walker_builder( db, root_paths.iter().filter(|root| { should_visit_incremental_path(root.as_path(), incremental_paths) }), ) } else { - create_walker(db, root_paths) + create_walker_builder(db, root_paths) }; let Some(walker) = walker else { @@ -307,24 +307,24 @@ impl ProjectFilesWalker { } } -fn create_walker(db: &dyn Db, paths: I) -> Option +pub(crate) fn create_walker_builder(db: &dyn Db, paths: I) -> Option where I: IntoIterator, T: AsRef, { let mut paths = paths.into_iter(); - let mut walker = db + let mut builder = db .system() .walk_directory(paths.next()?.as_ref()) .standard_filters(db.project().settings(db).src().respect_ignore_files) .ignore_hidden(false); for path in paths { - walker = walker.add(path); + builder = builder.add(path); } - Some(walker) + Some(builder) } fn should_visit_incremental_path( From ee4cfb17f62155dc2a71c0a381223f08e126367f Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Tue, 14 Jul 2026 10:27:00 -0400 Subject: [PATCH 051/222] [ty] Apply type mappings to constraint sets (#26801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds an `apply_type_mapping_impl` method to `ConstraintSet`. This cleans up the logic in https://github.com/astral-sh/ruff/pull/26788 and pulls it out into a standalone PR. Applying a type mapping to a constraint set is nuanced, since we have to apply it to both the lower/upper bounds of each constraint, and to the typevar being checked by the constraint. That last part is especially fun, since the result might not be a typevar anymore. Luckily, we already had all of the pieces in place to do this rigorously: if the remapping constraint subject is still a typevar, we can create a new constraint and simple BDD node for it. If not, we can use the existing `when_constraint_set_assignable_to` to evaluate when the remapped constraint `remapped[L] ≤ remapped[T] ≤ remapped[U]` holds. The result of that is a constraint set, which we can add to the result. Co-authored-by: Charlie Marsh --- .../src/types/constraints.rs | 152 +++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index ce7272e8db..13b9a3e69f 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -109,8 +109,8 @@ use crate::types::visitor::{ TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, }; use crate::types::{ - BoundTypeVarInstance, IntersectionType, Type, TypeVarBoundOrConstraints, TypeVarVariance, - UnionType, + ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Type, TypeContext, + TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, }; use crate::{Db, FxIndexMap, FxIndexSet, FxOrderSet}; @@ -580,6 +580,107 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Self::from_node(builder, self.node.exists(db, builder, to_remove)) } + /// Applies a type mapping to every constraint in this constraint set. + #[cfg_attr(not(test), expect(dead_code, reason = "used by a stacked follow-up"))] + pub(crate) fn apply_type_mapping_impl( + self, + db: &'db dyn Db, + type_mapping: &TypeMapping<'_, 'db>, + tcx: TypeContext<'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> Self { + fn rebuild_node( + builder: &ConstraintSetBuilder<'_>, + old_node: NodeId, + mapped_constraints: &FxHashMap, + mapped_nodes: &mut FxHashMap, + ) -> NodeId { + if old_node.is_terminal() { + return old_node; + } + if let Some(mapped) = mapped_nodes.get(&old_node) { + return *mapped; + } + + let old_interior = builder.interior_node_data(old_node); + let condition = mapped_constraints[&old_interior.constraint] + .with_adjusted_source_order(builder, old_interior.source_order.saturating_sub(1)); + let if_true = rebuild_node( + builder, + old_interior.if_true, + mapped_constraints, + mapped_nodes, + ); + let if_uncertain = rebuild_node( + builder, + old_interior.if_uncertain, + mapped_constraints, + mapped_nodes, + ); + let if_false = rebuild_node( + builder, + old_interior.if_false, + mapped_constraints, + mapped_nodes, + ); + let mapped = condition.ite_uncertain(builder, if_true, if_uncertain, if_false); + mapped_nodes.insert(old_node, mapped); + mapped + } + + let builder = self.builder; + let mut mapped_constraints = FxHashMap::default(); + self.node + .for_each_unique_constraint(builder, &mut |constraint_id, _| { + if mapped_constraints.contains_key(&constraint_id) { + return; + } + + let constraint = builder.constraint_data(constraint_id); + let subject = Type::TypeVar(constraint.typevar).apply_type_mapping_impl( + db, + type_mapping, + tcx, + visitor, + ); + let lower = constraint + .bounds + .lower + .map(|lower| lower.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); + let upper = constraint + .bounds + .upper + .map(|upper| upper.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); + + let mapped = if let Type::TypeVar(typevar) = subject { + Constraint::new_node_with_bounds(db, builder, typevar, lower, upper) + } else { + let lower_holds = lower.map_or(ALWAYS_TRUE, |lower| { + lower + .when_constraint_set_assignable_to(db, subject, builder) + .node + }); + let upper_holds = upper.map_or(ALWAYS_TRUE, |upper| { + subject + .when_constraint_set_assignable_to(db, upper, builder) + .node + }); + lower_holds.and_with_offset(builder, upper_holds) + }; + mapped_constraints.insert(constraint_id, mapped); + }); + + Self::from_node( + builder, + rebuild_node( + builder, + self.node, + &mapped_constraints, + &mut FxHashMap::default(), + ), + ) + } + /// Computes solutions for each BDD path, using a caller-provided hook to select solutions. /// /// The `choose` hook is called for each typevar on each BDD path with the typevar's variance @@ -7030,6 +7131,7 @@ mod tests { use pretty_assertions::assert_eq; use crate::db::tests::setup_db; + use crate::types::generics::ApplySpecialization; use crate::types::{BoundTypeVarInstance, KnownClass, TypeVarVariance}; use ruff_python_ast::name::Name; @@ -7051,6 +7153,52 @@ mod tests { class.to_instance(db) } + #[test] + fn type_mapping_updates_constraint_bounds() { + // (list[U] ≤ T ≤ list[U])[U ↦ int] = (list[int] ≤ T ≤ list[int]) + let db = setup_db(); + let t = create_typevar(&db, "T"); + let u = create_typevar(&db, "U"); + let builder = ConstraintSetBuilder::new(); + let list_of_u = KnownClass::List.to_specialized_instance(&db, &[Type::TypeVar(u)]); + let set = ConstraintSet::constrain_typevar(&db, &builder, t, list_of_u, list_of_u); + + let int = KnownClass::Int.to_instance(&db); + let mapped = set.apply_type_mapping_impl( + &db, + &TypeMapping::ApplySpecialization(ApplySpecialization::Single(u, int)), + TypeContext::default(), + &ApplyTypeMappingVisitor::default(), + ); + let list_of_int = KnownClass::List.to_specialized_instance(&db, &[int]); + let expected = ConstraintSet::constrain_typevar(&db, &builder, t, list_of_int, list_of_int); + + assert!(mapped.iff(&db, &builder, expected).is_always_satisfied(&db)); + } + + #[test] + fn type_mapping_evaluates_mapped_subjects() { + // ((T = int) ∧ ¬(T = str))[T ↦ int] = true + let db = setup_db(); + let t = create_typevar(&db, "T"); + let builder = ConstraintSetBuilder::new(); + let set = create_constraint(&db, &builder, t, KnownClass::Int).and(&db, &builder, || { + create_constraint(&db, &builder, t, KnownClass::Str).negate(&db, &builder) + }); + + let mapped = set.apply_type_mapping_impl( + &db, + &TypeMapping::ApplySpecialization(ApplySpecialization::Single( + t, + KnownClass::Int.to_instance(&db), + )), + TypeContext::default(), + &ApplyTypeMappingVisitor::default(), + ); + + assert!(mapped.is_always_satisfied(&db)); + } + #[test] fn upper_bound_prunes_duplicates_and_redundant_supertypes() { let db = setup_db(); From c957c9e338f76c48d2cd72c3d410e8ceb70d7874 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 14 Jul 2026 18:09:15 +0200 Subject: [PATCH 052/222] [ty] Hoist repeated Salsa field reads (#26769) --- crates/ty_ide/src/inlay_hints.rs | 5 ++-- crates/ty_ide/src/references.rs | 11 +++++---- crates/ty_ide/src/semantic_tokens.rs | 20 ++++++++-------- crates/ty_module_resolver/src/list.rs | 18 ++++++-------- crates/ty_module_resolver/src/resolve.rs | 5 ++-- crates/ty_python_semantic/src/place.rs | 5 ++-- .../src/types/infer/builder.rs | 6 ++--- .../types/infer/builder/binary_expressions.rs | 5 ++-- .../builder/post_inference/static_class.rs | 12 +++++----- .../src/types/infer/builder/subscript.rs | 5 ++-- .../ty_python_semantic/src/types/overrides.rs | 5 ++-- .../ty_python_semantic/src/types/relation.rs | 24 +++++++++++-------- .../src/types/set_theoretic.rs | 5 ++-- .../src/types/set_theoretic/builder.rs | 9 +++---- .../ty_python_semantic/src/types/typevar.rs | 19 ++++++++------- 15 files changed, 83 insertions(+), 71 deletions(-) diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 72f16fc88f..242e89a913 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -89,7 +89,8 @@ impl InlayHint { } // Don't try to import symbols in scope - if definition.file(db) == file { + let definition_file = definition.file(db); + if definition_file == file { return None; } @@ -100,7 +101,7 @@ impl InlayHint { .as_deref() .unwrap_or(&details.label[start..end]); - let module = file_to_module(db, definition.file(db))?; + let module = file_to_module(db, definition_file)?; if should_skip_import(db, module, *ty) { return None; diff --git a/crates/ty_ide/src/references.rs b/crates/ty_ide/src/references.rs index 185444ca25..7d42fbfa1c 100644 --- a/crates/ty_ide/src/references.rs +++ b/crates/ty_ide/src/references.rs @@ -745,8 +745,8 @@ impl<'a> LocalReferencesFinder<'a> { // The target's nearest enclosing class must be the one declaring the `__slots__`, so an // attribute of a nested class doesn't rename an outer class's slot. - let Some(owning_class_scope) = nearest_enclosing_class_scope(definition.file_scope(db)) - else { + let definition_scope = definition.file_scope(db); + let Some(owning_class_scope) = nearest_enclosing_class_scope(definition_scope) else { return false; }; match index.scope(owning_class_scope).node() { @@ -757,8 +757,9 @@ impl<'a> LocalReferencesFinder<'a> { // Accept only a member access (`self.value = ...`) or a class-body attribute declaration // (`value: int` or `value: int = ...` in a stub), so a parameter or local sharing the // name is not treated as the slot. - let is_class_attribute_declaration = definition.file_scope(db) == owning_class_scope - && definition.place(db).is_symbol() + let place = definition.place(db); + let is_class_attribute_declaration = definition_scope == owning_class_scope + && place.is_symbol() && matches!( definition.kind(db), DefinitionKind::AnnotatedAssignment(assignment) @@ -766,7 +767,7 @@ impl<'a> LocalReferencesFinder<'a> { file.is_stub(db) && value.is_ellipsis_literal_expr() }) ); - definition.place(db).is_member() || is_class_attribute_declaration + place.is_member() || is_class_attribute_declaration }) } diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index f5c6a48b7a..c02d5826d0 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -299,7 +299,8 @@ impl<'db> SemanticTokenVisitor<'db> { ) -> Option<(SemanticTokenType, SemanticTokenModifier)> { let mut modifiers = SemanticTokenModifier::empty(); let db = self.model.db(); - let model = SemanticModel::new(db, definition.file(db)); + let file = definition.file(db); + let model = SemanticModel::new(db, file); if model.is_type_alias_definition(definition) { return Some((SemanticTokenType::Class, modifiers)); @@ -319,7 +320,7 @@ impl<'db> SemanticTokenVisitor<'db> { Some((SemanticTokenType::TypeParameter, modifiers)) } DefinitionKind::Parameter(ParameterDefinitionNodeKind::Parameter(parameter)) => { - let parsed = parsed_module(db, definition.file(db)); + let parsed = parsed_module(db, file); let ty = parameter.node(&parsed.load(db)).inferred_type(&model); if let Some(ty) = ty { @@ -355,22 +356,21 @@ impl<'db> SemanticTokenVisitor<'db> { // (e.g., imported classes as Class, imported functions as Function, etc.) None } - _ => { + kind => { // For other definition kinds (assignments, etc.), apply constant naming convention if Self::is_constant_name(name_str) { modifiers |= SemanticTokenModifier::READONLY; } - let parsed = parsed_module(db, definition.file(db)); - let parsed = parsed.load(db); - let value = match definition.kind(db) { - DefinitionKind::Assignment(assignment) => Some(assignment.value(&parsed)), + let value_ty = match kind { + DefinitionKind::Assignment(assignment) => { + let parsed = parsed_module(db, file).load(db); + assignment.value(&parsed).inferred_type(&model) + } _ => None, }; - if let Some(value) = value - && let Some(value_ty) = value.inferred_type(&model) - { + if let Some(value_ty) = value_ty { if matches!(value_ty, Type::KnownInstance(KnownInstanceType::TypeVar(_))) { modifiers.remove(SemanticTokenModifier::READONLY); return Some((SemanticTokenType::TypeParameter, modifiers)); diff --git a/crates/ty_module_resolver/src/list.rs b/crates/ty_module_resolver/src/list.rs index 7451c6e56a..ebb7727e3a 100644 --- a/crates/ty_module_resolver/src/list.rs +++ b/crates/ty_module_resolver/src/list.rs @@ -74,9 +74,10 @@ fn list_modules_in<'db>( db: &'db dyn Db, search_path: SearchPathIngredient<'db>, ) -> Vec> { - tracing::debug!("Listing modules in search path '{}'", search_path.path(db)); - let mut lister = Lister::new(db, search_path.path(db)); - match search_path.path(db).as_path() { + let path = search_path.path(db); + tracing::debug!("Listing modules in search path '{}'", path); + let mut lister = Lister::new(db, path); + match path.as_path() { SystemOrVendoredPathRef::System(system_search_path) => { let Ok(listing) = directory_listing(db, system_search_path) else { return vec![]; @@ -293,17 +294,12 @@ impl<'db> Lister<'db> { // the same directory, the former takes precedent. // (This case can only occur when both have a search // path.) - if existing.kind(self.db) == ModuleKind::Module - && module.kind(self.db) == ModuleKind::Package - { - entry.insert(listed); - return; - } // Or if we have two file modules and the new one // is a stub, then the stub takes priority. if existing.kind(self.db) == ModuleKind::Module - && module.kind(self.db) == ModuleKind::Module - && path.is_stub_file() + && let module_kind = module.kind(self.db) + && (module_kind == ModuleKind::Package + || module_kind == ModuleKind::Module && path.is_stub_file()) { entry.insert(listed); return; diff --git a/crates/ty_module_resolver/src/resolve.rs b/crates/ty_module_resolver/src/resolve.rs index a4589a44c1..034749c5aa 100644 --- a/crates/ty_module_resolver/src/resolve.rs +++ b/crates/ty_module_resolver/src/resolve.rs @@ -327,7 +327,8 @@ fn file_to_module_impl<'db, 'a>( let module = resolve_module(db, file, &module_name)?; let module_file = module.file(db)?; - if file.path(db) == module_file.path(db) { + let file_path = file.path(db); + if file_path == module_file.path(db) { return Some(module); } else if file.source_type(db) == PySourceType::Python && module_file.source_type(db) == PySourceType::Stub @@ -337,7 +338,7 @@ fn file_to_module_impl<'db, 'a>( // like relative imports). So here we try `resolve_real_module().file` to cover both cases. let module = resolve_real_module(db, file, &module_name)?; let module_file = module.file(db)?; - if file.path(db) == module_file.path(db) { + if file_path == module_file.path(db) { return Some(module); } } diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index e2285e54d5..e447cb82e8 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1615,7 +1615,8 @@ fn place_from_bindings_impl<'db>( // We need to "look through" loop header definitions to do boundness analysis. The // actual type is computed by `infer_loop_header_definition` via `binding_type` below, // like all other bindings, so that it can participate in fixpoint iteration. - if binding.kind(db).is_loop_header() { + let binding_kind = binding.kind(db); + if binding_kind.is_loop_header() { let loop_header = loop_header_reachability(db, binding); deleted_reachability = deleted_reachability.or(loop_header.deleted_reachability); // If all the bindings in the loop are in statically false branches, it might be @@ -1625,7 +1626,7 @@ fn place_from_bindings_impl<'db>( if loop_header.reachable_bindings.is_empty() { return None; } - } else if matches!(binding.kind(db), DefinitionKind::NestedBindings(_)) { + } else if matches!(binding_kind, DefinitionKind::NestedBindings(_)) { // Nested bindings definitions similar to loop header definitions, synthetic // bindings with special shadowing behavior. They can also coexist with `UNBOUND`. } else { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index db04854a28..e271cd6c8d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2813,13 +2813,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } Type::Intersection(intersection) => { - if intersection.positive(db).iter().any(|element_ty| { + let positive = intersection.positive(db); + if positive.iter().any(|element_ty| { self.validate_attribute_deletion(target, *element_ty, attribute, false) }) { true } else { - if emit_diagnostics && let Some(element_ty) = intersection.positive(db).first() - { + if emit_diagnostics && let Some(element_ty) = positive.first() { self.validate_attribute_deletion(target, *element_ty, attribute, true); } false diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index 864c1edf17..82cf6d2734 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs @@ -714,11 +714,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let ty = if n.as_i64() < 1 { Type::string_literal(db, "") } else if let Ok(n) = usize::try_from(n.as_i64()) - && n.checked_mul(s.value(db).len()).is_some_and(|new_length| { + && let value = s.value(db) + && n.checked_mul(value.len()).is_some_and(|new_length| { new_length <= Self::MAX_STRING_LITERAL_SIZE }) { - let new_literal = s.value(db).repeat(n); + let new_literal = value.repeat(n); Type::string_literal(db, &*new_literal) } else { Type::literal_string() diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 8b5f6bbb3d..05411543d2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -1050,22 +1050,22 @@ fn check_class_namespace_against_metaclass_members<'db>( { let body_scope = metaclass.body_scope(db); let metaclass_index = semantic_index(db, body_scope.file(db)); - let body_scope = body_scope.file_scope_id(db); - let metaclass_table = metaclass_index.place_table(body_scope); - let metaclass_use_def = metaclass_index.use_def_map(body_scope); + let body_scope_id = body_scope.file_scope_id(db); + let metaclass_table = metaclass_index.place_table(body_scope_id); + let metaclass_use_def = metaclass_index.use_def_map(body_scope_id); for (symbol_id, _) in metaclass_use_def.all_end_of_scope_symbol_declarations() { metaclass_instance_members.insert(metaclass_table.symbol(symbol_id).name().clone()); } - for function_scope in attribute_scopes(db, metaclass.body_scope(db)) { + for function_scope in attribute_scopes(db, body_scope) { for member in metaclass_index.place_table(function_scope).members() { if let Some(name) = member.as_instance_attribute() { // A method-scope member may only be declared, as in `cls.attr: int`. // Only an assignment such as `cls.attr: int = 1` writes a value onto the // newly created class object, potentially overwriting a class-body value. - let is_assigned = attribute_assignments(db, metaclass.body_scope(db), name) - .any(|(bindings, _)| { + let is_assigned = + attribute_assignments(db, body_scope, name).any(|(bindings, _)| { bindings .into_iter() .any(|binding| binding.binding.definition().is_some()) diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 1ce083963a..359f5985aa 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1786,8 +1786,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::Intersection(intersection) => { // Check if any positive element supports deletion + let positive = intersection.positive(db); let mut any_valid = false; - for element_ty in intersection.positive(db) { + for element_ty in positive { if self.can_delete_subscript(*element_ty, slice_ty) { any_valid = true; break; @@ -1795,7 +1796,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // If none are valid, emit a diagnostic for the first failing element - if !any_valid && let Some(element_ty) = intersection.positive(db).first() { + if !any_valid && let Some(element_ty) = positive.first() { self.validate_subscript_deletion_impl( target, full_object_ty.or(Some(object_ty)), diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index b52e3f9860..326e53f7b6 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -894,11 +894,12 @@ fn symbol_definition<'db>( scope: ScopeId<'db>, symbol: ScopedSymbolId, ) -> Option> { - use_def_map(db, scope) + let use_def_map = use_def_map(db, scope); + use_def_map .end_of_scope_symbol_declarations(symbol) .find_map(|declaration| declaration.declaration.definition()) .or_else(|| { - use_def_map(db, scope) + use_def_map .end_of_scope_symbol_bindings(symbol) .find_map(|binding| binding.binding.definition()) }) diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 0b87719ee1..8c33214f47 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1103,6 +1103,8 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { }) }; + let bound_or_constraints; + match (source, target) { // Everything is a subtype of `object`. (_, Type::NominalInstance(target)) if target.is_object() => self.always(), @@ -1427,11 +1429,12 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // the union of its constraints. An unbound, unconstrained, fully static typevar has an // implicit upper bound of `object` (which is handled above). (Type::TypeVar(bound_typevar), _) - if !bound_typevar.is_inferable(db, self.inferable) - && bound_typevar.typevar(db).bound_or_constraints(db).is_some() => + if !bound_typevar.is_inferable(db, self.inferable) && { + bound_or_constraints = bound_typevar.typevar(db).bound_or_constraints(db); + bound_or_constraints.is_some() + } => { - match bound_typevar.typevar(db).bound_or_constraints(db) { - None => unreachable!(), + match bound_or_constraints { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { self.check_type_pair(db, bound, target) } @@ -1442,6 +1445,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { |constraint| self.check_type_pair(db, *constraint, target), ) } + None => unreachable!(), } } @@ -3079,18 +3083,18 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { // A `BoundMethod` type includes instances of the same method bound to a // subtype/subclass of the self type. (Type::BoundMethod(a), Type::BoundMethod(b)) => { - if a.function(db).name(db) != b.function(db).name(db) { + let a_function = a.function(db); + let b_function = b.function(db); + if a_function.name(db) != b_function.name(db) { // We typically ask about `BoundMethod` disjointness when we're looking at a // method call on an intersection type like `A & B`. In that case, the same // method name would show up on both sides of this check. However for // completeness, if we're ever comparing `BoundMethod` types with different // method names, then they're clearly disjoint. self.always() - } else if a.function(db) != b.function(db) - && a.function(db) - .has_known_decorator(db, FunctionDecorators::FINAL) - && b.function(db) - .has_known_decorator(db, FunctionDecorators::FINAL) + } else if a_function != b_function + && a_function.has_known_decorator(db, FunctionDecorators::FINAL) + && b_function.has_known_decorator(db, FunctionDecorators::FINAL) { // If *both* methods are `@final` (and they're not literally the same // definition), they must be disjoint. diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index a029f4756e..285055b042 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -902,10 +902,11 @@ impl<'db> IntersectionType<'db> { self, db: &'db dyn Db, ) -> impl Iterator> { - if self.positive(db).is_empty() { + let positive = self.positive(db); + if positive.is_empty() { Either::Left(std::iter::once(Type::object())) } else { - Either::Right(self.positive(db).iter().copied()) + Either::Right(positive.iter().copied()) } } diff --git a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs index 970cb69460..f672fa73bb 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -67,8 +67,9 @@ fn split_truthiness_guarded_intersection<'db>( let falsy = Type::AlwaysTruthy.negate(db); let truthy = Type::AlwaysFalsy.negate(db); - let has_not_truthy = intersection.negative(db).contains(&Type::AlwaysTruthy); - let has_not_falsy = intersection.negative(db).contains(&Type::AlwaysFalsy); + let negative = intersection.negative(db); + let has_not_truthy = negative.contains(&Type::AlwaysTruthy); + let has_not_falsy = negative.contains(&Type::AlwaysFalsy); let guard = match (has_not_truthy, has_not_falsy) { (true, false) => falsy, (false, true) => truthy, @@ -79,7 +80,7 @@ fn split_truthiness_guarded_intersection<'db>( for positive in intersection.positive(db) { core = core.add_positive(*positive); } - for negative in intersection.negative(db) { + for negative in negative { if (guard == falsy && *negative == Type::AlwaysTruthy) || (guard == truthy && *negative == Type::AlwaysFalsy) { @@ -274,7 +275,7 @@ fn normalize_enum_complement_unions<'db>(db: &'db dyn Db, types: &mut Vec BoundTypeVarInstance<'db> { .top_materialization(db), }); + let typevar = self.typevar(db); let typevar = TypeVarInstance::new( db, - self.typevar(db).identity(db), + typevar.identity(db), Some(TypeVarBoundOrConstraintsEvaluation::Eager(upper_bound)), - self.typevar(db).explicit_variance(db), + typevar.explicit_variance(db), None, // `P.args` and `P.kwargs` cannot have defaults even though `P` can ); @@ -981,13 +982,14 @@ impl<'db> BoundTypeVarInstance<'db> { self.kind(db) ); + let typevar = self.typevar(db); Self::new( db, TypeVarInstance::new( db, - self.typevar(db).identity(db), + typevar.identity(db), None, // Remove the upper bound set by `with_paramspec_attr` - self.typevar(db).explicit_variance(db), + typevar.explicit_variance(db), None, // `P.args` and `P.kwargs` cannot have defaults even though `P` can ), self.binding_context(db), @@ -1056,13 +1058,14 @@ impl<'db> BoundTypeVarInstance<'db> { db: &'db dyn Db, f: impl FnOnce(Option>) -> Option>, ) -> Self { - let bound_or_constraints = f(self.typevar(db).bound_or_constraints(db)); + let typevar = self.typevar(db); + let bound_or_constraints = f(typevar.bound_or_constraints(db)); let typevar = TypeVarInstance::new( db, - self.typevar(db).identity(db), + typevar.identity(db), bound_or_constraints.map(TypeVarBoundOrConstraintsEvaluation::Eager), - self.typevar(db).explicit_variance(db), - self.typevar(db)._default(db), + typevar.explicit_variance(db), + typevar._default(db), ); Self::new( From 53b073390ec2353c578988d10a6b192704d00ccf Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:13:23 -0400 Subject: [PATCH 053/222] Add TOML support to mdtest (#26802) Summary -- This PR was split off from #26772 to add support for testing TOML lint rules to mdtest. We use the toml_parser crate to extract comments and now call `lint_pyproject_toml` in ruff_mdtest. Because we already use `toml` blocks for test configuration and because Ruff's current TOML lints need to know the filename, I opted to require a filename for linted TOML blocks. For example, ````markdown ```toml lint.select = ["F401"] ``` ```` remains a configuration block, but ````markdown `ruff.toml`: ```toml lint.select = ["F401"] ``` ```` will be linted. Test Plan -- Added one test for RUF200 and unit tests for the parsing itself. --- Cargo.lock | 2 + Cargo.toml | 1 + crates/mdtest/Cargo.toml | 1 + crates/mdtest/src/assertion.rs | 113 +++++++++++++----- crates/mdtest/src/lib.rs | 10 +- crates/mdtest/src/matcher.rs | 49 +++++--- crates/mdtest/src/parser.rs | 57 ++++++--- .../mdtest/ruff/invalid-pyproject-toml.md | 22 ++++ crates/ruff_mdtest/Cargo.toml | 1 + crates/ruff_mdtest/src/lib.rs | 38 ++++-- crates/ty_test/src/lib.rs | 2 +- 11 files changed, 215 insertions(+), 81 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md diff --git a/Cargo.lock b/Cargo.lock index 93e129f30d..2db3ed6959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,6 +2104,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "toml_parser", "tracing", ] @@ -3434,6 +3435,7 @@ dependencies = [ "ruff_linter", "ruff_python_ast", "ruff_ranged_value", + "ruff_source_file", "ruff_workspace", "salsa", ] diff --git a/Cargo.toml b/Cargo.toml index eae906f542..6f6474b834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -189,6 +189,7 @@ thiserror = { version = "2.0.0" } thin-vec = { version = "0.2.14" } tikv-jemallocator = { version = "0.6.0" } toml = { version = "1.0.0" } +toml_parser = { version = "1.0.0" } tracing = { version = "0.1.40" } tracing-flame = { version = "0.2.0" } tracing-indicatif = { version = "0.3.11" } diff --git a/crates/mdtest/Cargo.toml b/crates/mdtest/Cargo.toml index 14b57d3d78..75e1bbbcbd 100644 --- a/crates/mdtest/Cargo.toml +++ b/crates/mdtest/Cargo.toml @@ -38,6 +38,7 @@ similar = { workspace = true } smallvec = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } +toml_parser = { workspace = true } tracing = { workspace = true } [lints] diff --git a/crates/mdtest/src/assertion.rs b/crates/mdtest/src/assertion.rs index be3318cb69..2c4dd279d4 100644 --- a/crates/mdtest/src/assertion.rs +++ b/crates/mdtest/src/assertion.rs @@ -1,4 +1,4 @@ -//! Parse type and type-error assertions in Python comment form. +//! Parse inline diagnostic assertions from comments. //! //! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`. //! In the latter case, the `8` is a column number, and `"message text"` asserts that the full @@ -35,15 +35,21 @@ //! ``` use ruff_db::parsed::ParsedModuleRef; -use ruff_python_ast::token::Token; use ruff_python_trivia::{CommentRanges, Cursor}; use ruff_source_file::{LineIndex, OneIndexed}; use ruff_text_size::{Ranged, TextRange, TextSize}; use smallvec::SmallVec; use std::str::FromStr; +use toml_parser::lexer::TokenKind; use crate::RunOptions; +#[derive(Clone, Copy)] +pub(crate) enum AssertionSource<'a> { + Python(&'a ParsedModuleRef), + Toml, +} + /// Diagnostic assertion comments in a single embedded file. #[derive(Debug)] pub(crate) struct InlineFileAssertions<'s> { @@ -53,15 +59,49 @@ pub(crate) struct InlineFileAssertions<'s> { impl<'s> InlineFileAssertions<'s> { pub(crate) fn from_file( source: &'s str, - parsed: &ParsedModuleRef, + assertion_source: AssertionSource<'_>, file_index: &LineIndex, ) -> Self { - let mut by_line = Vec::new(); - let mut file_assertions = UnparsedAssertionsIter { - tokens: parsed.tokens().iter(), - source, + match assertion_source { + AssertionSource::Python(parsed) => Self::from_comment_ranges( + source, + parsed + .tokens() + .iter() + .filter(|token| token.kind().is_comment()) + .map(Ranged::range), + file_index, + ), + AssertionSource::Toml => Self::from_comment_ranges( + source, + toml_parser::Source::new(source) + .lex() + .filter(|token| token.kind() == TokenKind::Comment) + .map(|token| { + let span = token.span(); + TextRange::new( + TextSize::try_from(span.start()).unwrap(), + TextSize::try_from(span.end()).unwrap(), + ) + }), + file_index, + ), } - .peekable(); + } + + fn from_comment_ranges( + source: &'s str, + comment_ranges: impl Iterator, + file_index: &LineIndex, + ) -> Self { + let mut by_line = Vec::new(); + let mut file_assertions = comment_ranges + .filter_map(|range| { + let comment_text = &source[range]; + UnparsedAssertion::from_comment(comment_text) + .map(|assertion| AssertionWithRange(assertion, range)) + }) + .peekable(); while let Some(ranged_assertion) = file_assertions.next() { let mut collector = AssertionVec::new(); @@ -150,29 +190,6 @@ impl<'s> IntoIterator for InlineFileAssertions<'s> { } } -struct UnparsedAssertionsIter<'a, 's> { - source: &'s str, - tokens: std::slice::Iter<'a, Token>, -} - -impl<'s> Iterator for UnparsedAssertionsIter<'_, 's> { - type Item = AssertionWithRange<'s>; - - fn next(&mut self) -> Option { - loop { - let token = self.tokens.next()?; - if !token.kind().is_comment() { - continue; - } - - let comment_text = &self.source[token.range()]; - if let Some(assertion) = UnparsedAssertion::from_comment(comment_text) { - return Some(AssertionWithRange(assertion, token.range())); - } - } - } -} - /// An [`UnparsedAssertion`] with the [`TextRange`] of its original inline comment. #[derive(Debug)] struct AssertionWithRange<'a>(UnparsedAssertion<'a>, TextRange); @@ -530,7 +547,18 @@ mod tests { db.write_file("/src/test.py", source).unwrap(); let file = system_path_to_file(&db, "/src/test.py").unwrap(); let parsed = parsed_module(&db, file).load(&db); - InlineFileAssertions::from_file(source, &parsed, &line_index(&db, file)) + InlineFileAssertions::from_file( + source, + AssertionSource::Python(&parsed), + &line_index(&db, file), + ) + } + + fn get_toml_assertions(source: &str) -> InlineFileAssertions<'_> { + let mut db = TestDb::setup(); + db.write_file("/src/ruff.toml", source).unwrap(); + let file = system_path_to_file(&db, "/src/ruff.toml").unwrap(); + InlineFileAssertions::from_file(source, AssertionSource::Toml, &line_index(&db, file)) } fn into_vec(assertions: InlineFileAssertions<'_>) -> Vec> { @@ -581,6 +609,27 @@ mod tests { assert_eq!(format!("{assert}"), "error: "); } + #[test] + fn toml_comments() { + let source = dedent( + r##" + first = "# error: [not-a-comment]" + second = "value" # error: [rule-codes-in-selectors] + "##, + ); + let assertions = get_toml_assertions(&source); + + let [line] = &into_vec(assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + let [assertion] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + assert_eq!(format!("{assertion}"), "error: [rule-codes-in-selectors]"); + } + #[test] fn prior_line() { let source = dedent( diff --git a/crates/mdtest/src/lib.rs b/crates/mdtest/src/lib.rs index a76b97d51a..a1d345dd39 100644 --- a/crates/mdtest/src/lib.rs +++ b/crates/mdtest/src/lib.rs @@ -538,7 +538,15 @@ pub fn create_diagnostic_snapshot<'d, C>( writeln!(snapshot, "---").unwrap(); writeln!(snapshot).unwrap(); - writeln!(snapshot, "# Python source files").unwrap(); + let source_heading = if test + .files() + .all(|file| matches!(file.lang, "py" | "python" | "pyi" | "ipynb")) + { + "Python source files" + } else { + "Source files" + }; + writeln!(snapshot, "# {source_heading}").unwrap(); writeln!(snapshot).unwrap(); for file in test.files() { writeln!(snapshot, "## {}", file.relative_path()).unwrap(); diff --git a/crates/mdtest/src/matcher.rs b/crates/mdtest/src/matcher.rs index ff4e31a763..b7fd536058 100644 --- a/crates/mdtest/src/matcher.rs +++ b/crates/mdtest/src/matcher.rs @@ -17,7 +17,9 @@ use ruff_source_file::{LineIndex, OneIndexed}; use smallvec::SmallVec; use crate::RunOptions; -use crate::assertion::{InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion}; +use crate::assertion::{ + AssertionSource, InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion, +}; use crate::diagnostic::SortedDiagnostics; #[derive(Debug, Default)] @@ -97,25 +99,34 @@ pub fn match_file( // Parse assertions from comments in the file, and get diagnostics from the file; both // ordered by line number. let source = source_text(db, file); - let parsed = parsed_module(db, file).load(db); let line_index = line_index(db, file); - let assertions = InlineFileAssertions::from_file(&source, &parsed, &line_index); - - // Sort diagnostics according to the line number of the starting offset of the token in which the diagnostic appears. - // - // This can be different to the line number of the starting offset of the diagnostic range! - // For example, if the diagnostic is a syntax error inside a stringized annotation, - // the syntax error's range will likely point to a sub-range of the string literal, - // which will make the error unmatchable by mdtest unless we look at the token in which - // the diagnostic occurs (the string-literal) and use the token start as the basis for - // the line number. - let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { - let token_start = parsed - .tokens() - .token_range(diagnostic_range.start()) - .start(); - line_index.line_index(token_start) - }); + let (assertions, diagnostics) = if file.path(db).extension() == Some("toml") { + let assertions = + InlineFileAssertions::from_file(source.as_str(), AssertionSource::Toml, &line_index); + let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { + line_index.line_index(diagnostic_range.start()) + }); + (assertions, diagnostics) + } else { + let parsed = parsed_module(db, file).load(db); + let assertions = InlineFileAssertions::from_file( + source.as_str(), + AssertionSource::Python(&parsed), + &line_index, + ); + + // Sort diagnostics according to the line number of the starting offset of the token in + // which the diagnostic appears. This can differ from the line containing the start of the + // diagnostic range, for example for syntax errors inside stringized annotations. + let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { + let token_start = parsed + .tokens() + .token_range(diagnostic_range.start()) + .start(); + line_index.line_index(token_start) + }); + (assertions, diagnostics) + }; let mut line_diagnostics = diagnostics.iter_lines(); diff --git a/crates/mdtest/src/parser.rs b/crates/mdtest/src/parser.rs index a9b9fff9e4..28c4438c8b 100644 --- a/crates/mdtest/src/parser.rs +++ b/crates/mdtest/src/parser.rs @@ -373,14 +373,13 @@ impl EmbeddedFilePath<'_> { /// A single file embedded in a [`Section`] as a fenced code block. /// /// Currently must be a Python file (`py` language), a type stub (`pyi`), a Jupyter notebook -/// (`ipynb`) or a [typeshed `VERSIONS`] file. +/// (`ipynb`), an explicitly named TOML file, or a [typeshed `VERSIONS`] file. /// -/// TOML configuration blocks are also supported, but are not stored as `EmbeddedFile`s. In the -/// future we plan to support `pth` files as well. +/// Unnamed TOML blocks configure the test and are not stored as `EmbeddedFile`s. In the future we +/// plan to support `pth` files as well. /// -/// A Python embedded file makes its containing [`Section`] into a [`MarkdownTest`], and will be -/// type-checked and searched for inline-comment assertions to match against the diagnostics from -/// type checking. +/// A checkable embedded file makes its containing [`Section`] into a [`MarkdownTest`] and is +/// searched for inline-comment assertions to match against diagnostics. /// /// [typeshed `VERSIONS`]: https://github.com/python/typeshed/blob/c546278aae47de0b2b664973da4edb613400f6ce/stdlib/VERSIONS#L1-L18 #[derive(Debug)] @@ -390,7 +389,7 @@ pub struct EmbeddedFile<'s> { pub lang: &'s str, pub code: Cow<'s, str>, /// The checkable code blocks - pub python_code_blocks: Vec>, + pub code_blocks: Vec>, } impl EmbeddedFile<'_> { @@ -406,7 +405,7 @@ impl EmbeddedFile<'_> { let start_offset = existing_code.text_len(); existing_code.push_str(new_code); - self.python_code_blocks.push(CodeBlock { + self.code_blocks.push(CodeBlock { backticks: backtick_offsets, embedded_start_offset: start_offset, inline_snapshot_block: None, @@ -430,7 +429,7 @@ impl EmbeddedFile<'_> { } pub(crate) fn is_checkable(&self) -> bool { - matches!(self.lang, "py" | "python" | "pyi" | "ipynb") + matches!(self.lang, "py" | "python" | "pyi" | "ipynb" | "toml") } } @@ -808,7 +807,7 @@ where let section = self.stack.top(); let test_name = self.sections[section].title; - if lang == "toml" { + if lang == "toml" && self.explicit_path.is_none() { return self.process_config_block(code); } @@ -879,7 +878,7 @@ where section, lang, code: Cow::Borrowed(code), - python_code_blocks: vec![CodeBlock { + code_blocks: vec![CodeBlock { backticks: backtick_offsets, embedded_start_offset: TextSize::new(0), inline_snapshot_block: None, @@ -919,7 +918,7 @@ where fn current_section_has_merged_snippets(&self) -> bool { self.current_section_files .values() - .any(|id| self.files[*id].python_code_blocks.len() > 1) + .any(|id| self.files[*id].code_blocks.len() > 1) } fn process_config_block(&mut self, code: &str) -> anyhow::Result<()> { @@ -948,7 +947,7 @@ where let backtick_start = line_number(offsets.start(), self.source); bail!( - "`snapshot` code block on line {backtick_start} must follow a Python code block, but section has no files." + "`snapshot` code block on line {backtick_start} must follow a checkable code block, but section has no files." ); }; @@ -958,12 +957,12 @@ where let backtick_start = line_number(offsets.start(), self.source); bail!( - "`snapshot` code block on line {backtick_start} must follow a `python` code block in the same section but it follows a `{}` block.", + "`snapshot` code block on line {backtick_start} must follow a checkable code block in the same section but it follows a `{}` block.", file.lang ); } - let code_block = file.python_code_blocks.last_mut().unwrap(); + let code_block = file.code_blocks.last_mut().unwrap(); if let Some(existing_block) = &code_block.inline_snapshot_block { let code_block_start = line_number(code_block.embedded_start_offset(), self.source); @@ -971,7 +970,7 @@ where let existing_start = line_number(existing_block.range.start(), self.source); bail!( - "Python code block on line `{code_block_start}` has more than one `snapshot` block: first on line {existing_start} and another on line {backtick_start}.", + "Code block on line `{code_block_start}` has more than one `snapshot` block: first on line {existing_start} and another on line {backtick_start}.", ); } @@ -1243,6 +1242,32 @@ mod tests { assert_eq!(file.code, "{}"); } + #[test] + fn explicitly_named_toml_file() { + let source = dedent( + r#" + `ruff.toml`: + + ```toml + lint.select = ["F401"] + ``` + "#, + ); + let mf = parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::>()[..] else { + panic!("expected one test"); + }; + + let [file] = test.files().collect::>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, EmbeddedFilePath::Explicit("ruff.toml")); + assert_eq!(file.lang, "toml"); + assert_eq!(file.code, r#"lint.select = ["F401"]"#); + } + #[test] fn multiple_tests() { let source = dedent( diff --git a/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md new file mode 100644 index 0000000000..47fcd4206b --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md @@ -0,0 +1,22 @@ +# `invalid-pyproject-toml` (`RUF200`) + +```toml +[lint] +select = ["RUF200"] +``` + +`pyproject.toml`: + +```toml +[project] +name = 1 # snapshot: invalid-pyproject-toml +``` + +```snapshot +error[RUF200]: Failed to parse pyproject.toml: invalid type: integer `1`, expected a string + --> src/pyproject.toml:2:8 + | +2 | name = 1 # snapshot: invalid-pyproject-toml + | ^ + | +``` diff --git a/crates/ruff_mdtest/Cargo.toml b/crates/ruff_mdtest/Cargo.toml index 9209374fc5..cbf24ad06a 100644 --- a/crates/ruff_mdtest/Cargo.toml +++ b/crates/ruff_mdtest/Cargo.toml @@ -20,6 +20,7 @@ ruff_db = { workspace = true, features = ["os", "testing"] } ruff_linter = { workspace = true, features = ["testing"] } ruff_python_ast = { workspace = true } ruff_ranged_value = { workspace = true } +ruff_source_file = { workspace = true } ruff_workspace = { workspace = true } anyhow = { workspace = true } diff --git a/crates/ruff_mdtest/src/lib.rs b/crates/ruff_mdtest/src/lib.rs index 6f874962b3..1fbe0b1512 100644 --- a/crates/ruff_mdtest/src/lib.rs +++ b/crates/ruff_mdtest/src/lib.rs @@ -10,9 +10,12 @@ use ruff_db::diagnostic::{Annotation, Diagnostic, Span}; use ruff_db::files::{File, system_path_to_file}; use ruff_db::source::source_text; use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf}; +use ruff_linter::pyproject_toml::lint_pyproject_toml; use ruff_linter::source_kind::SourceKind; use ruff_linter::test::test_contents; +use ruff_python_ast::SourceType; use ruff_ranged_value::{ValueSource, ValueSourceGuard}; +use ruff_source_file::SourceFileBuilder; use ruff_workspace::configuration::Configuration; use ruff_workspace::options::Options; @@ -68,8 +71,8 @@ fn run_test( } assert!( - matches!(embedded.lang, "py" | "pyi" | "python" | "ipynb"), - "Supported file types are: py (or python), pyi, ipynb, and ignore" + matches!(embedded.lang, "py" | "pyi" | "python" | "ipynb" | "toml"), + "Supported file types are: py (or python), pyi, ipynb, toml, and ignore" ); let full_path = embedded.full_path(&project_root); @@ -80,7 +83,7 @@ fn run_test( Some(TestFile { file, - code_blocks: embedded.python_code_blocks.clone(), + code_blocks: embedded.code_blocks.clone(), }) }) .collect(); @@ -107,20 +110,31 @@ fn run_test( let mdtest_result = attempt_test( |file| { let source = source_text(db, file); - let source_kind = if let Some(notebook) = source.as_notebook() { - SourceKind::ipy_notebook(notebook.clone()) - } else { - SourceKind::Python { - code: source.as_str().to_string(), - is_stub: file.is_stub(db), - } - }; let path = file .path(db) .as_system_path() .expect("mdtest files are on the system") .as_std_path(); - test_contents(&source_kind, path, &settings.linter).0 + match SourceType::from(path) { + SourceType::Python(_) => { + let source_kind = if let Some(notebook) = source.as_notebook() { + SourceKind::ipy_notebook(notebook.clone()) + } else { + SourceKind::Python { + code: source.as_str().to_string(), + is_stub: file.is_stub(db), + } + }; + test_contents(&source_kind, path, &settings.linter).0 + } + SourceType::Toml(source_type) if source_type.is_pyproject() => { + let source_file = + SourceFileBuilder::new(path.to_string_lossy(), source.as_str()) + .finish(); + lint_pyproject_toml(&source_file, &settings.linter) + } + SourceType::Toml(_) | SourceType::Markdown => Vec::new(), + } }, test_file, ); diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index a5a60304fa..d8d06f6eef 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -230,7 +230,7 @@ fn run_test( Some(TestFile { file, - code_blocks: embedded.python_code_blocks.clone(), + code_blocks: embedded.code_blocks.clone(), }) }) .collect(); From c87ba51052f6e4fdd346c291f7ed3b6318aea442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Tue, 14 Jul 2026 09:34:34 -0700 Subject: [PATCH 054/222] [ty] Consolidate tab stop offset calculations (#26707) ## Summary Following up on [prior review feedback](https://github.com/astral-sh/ruff/pull/26679#discussion_r3557123788), this introduces a shared helper (`ruff_python_trivia::tab_offset`) that we use to consolidate the calculation for advancing a column to the next tab stop. ## Test Plan See included tests for the new helper. Otherwise, since this is a pure refactor, we rely on existing test coverage. --- .../ruff_linter/src/checkers/logical_lines.rs | 3 +- crates/ruff_linter/src/line_width.rs | 3 +- .../src/string/docstring.rs | 9 +++--- .../src/lexer/indentation.rs | 4 ++- crates/ruff_python_trivia/src/whitespace.rs | 28 +++++++++++++++++-- .../ty_ide/src/docstring/document/syntax.rs | 4 +-- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/ruff_linter/src/checkers/logical_lines.rs b/crates/ruff_linter/src/checkers/logical_lines.rs index 695fd294aa..a3f8649e8f 100644 --- a/crates/ruff_linter/src/checkers/logical_lines.rs +++ b/crates/ruff_linter/src/checkers/logical_lines.rs @@ -1,6 +1,7 @@ use ruff_python_ast::token::{TokenKind, Tokens}; use ruff_python_codegen::Stylist; use ruff_python_index::Indexer; +use ruff_python_trivia::tab_offset; use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; @@ -26,7 +27,7 @@ pub(crate) fn expand_indent(line: &str, indent_width: IndentWidth) -> usize { let tab_size = indent_width.as_usize(); for c in line.bytes() { match c { - b'\t' => indent = (indent / tab_size) * tab_size + tab_size, + b'\t' => indent += tab_offset(indent, tab_size), b' ' => indent += 1, _ => break, } diff --git a/crates/ruff_linter/src/line_width.rs b/crates/ruff_linter/src/line_width.rs index cf621c85a5..0c6eb95f70 100644 --- a/crates/ruff_linter/src/line_width.rs +++ b/crates/ruff_linter/src/line_width.rs @@ -9,6 +9,7 @@ use unicode_width::UnicodeWidthChar; use ruff_cache::{CacheKey, CacheKeyHasher}; use ruff_macros::CacheKey; +use ruff_python_trivia::tab_offset; use ruff_text_size::TextSize; /// The length of a line of text that is considered too long. @@ -201,7 +202,7 @@ impl LineWidthBuilder { for c in chars { match c { '\t' => { - let tab_offset = tab_size - (self.column % tab_size); + let tab_offset = tab_offset(self.column, tab_size); self.width += tab_offset; self.column += tab_offset; } diff --git a/crates/ruff_python_formatter/src/string/docstring.rs b/crates/ruff_python_formatter/src/string/docstring.rs index 5347317a23..032f94d14a 100644 --- a/crates/ruff_python_formatter/src/string/docstring.rs +++ b/crates/ruff_python_formatter/src/string/docstring.rs @@ -15,7 +15,7 @@ use ruff_python_parser::ParseOptions; use ruff_python_trivia::TriviaRanges; use { ruff_formatter::{FormatOptions, IndentStyle, LineWidth, Printed, write}, - ruff_python_trivia::{PythonWhitespace, is_python_whitespace}, + ruff_python_trivia::{PythonWhitespace, is_python_whitespace, tab_offset}, ruff_text_size::{Ranged, TextLen, TextRange, TextSize}, }; @@ -1693,7 +1693,7 @@ impl Indentation { for char in iter { if char == '\t' { // Pad to the next multiple of tab_width - width += Self::TAB_INDENT_WIDTH - (width.rem_euclid(Self::TAB_INDENT_WIDTH)); + width += tab_offset(width, Self::TAB_INDENT_WIDTH); len += '\t'.text_len(); } else if char.is_whitespace() { width += char.len_utf8(); @@ -1720,7 +1720,7 @@ impl Indentation { Self::TabSpaces { tabs, spaces } => tabs * Self::TAB_INDENT_WIDTH + spaces, Self::SpacesTabs { spaces, tabs } => { let mut indent = spaces; - indent += Self::TAB_INDENT_WIDTH - indent.rem_euclid(Self::TAB_INDENT_WIDTH); + indent += tab_offset(indent, Self::TAB_INDENT_WIDTH); indent + (tabs - 1) * Self::TAB_INDENT_WIDTH } Self::Mixed { width, .. } => width, @@ -1827,8 +1827,7 @@ impl Indentation { } if char == '\t' { // Pad to the next multiple of tab_width - seen_indent_len += - Self::TAB_INDENT_WIDTH - (seen_indent_len.rem_euclid(Self::TAB_INDENT_WIDTH)); + seen_indent_len += tab_offset(seen_indent_len, Self::TAB_INDENT_WIDTH); trimmed = &trimmed[1..]; } else if char.is_whitespace() { seen_indent_len += char.len_utf8(); diff --git a/crates/ruff_python_parser/src/lexer/indentation.rs b/crates/ruff_python_parser/src/lexer/indentation.rs index 7125f3a224..c2193c9e7b 100644 --- a/crates/ruff_python_parser/src/lexer/indentation.rs +++ b/crates/ruff_python_parser/src/lexer/indentation.rs @@ -2,6 +2,8 @@ use static_assertions::assert_eq_size; use std::cmp::Ordering; use std::fmt::Debug; +use ruff_python_trivia::tab_offset_u32; + /// The column index of an indentation. /// /// A space increments the column by one. A tab adds up to 2 (if tab size is 2) indices, but just one @@ -63,7 +65,7 @@ impl Indentation { // * Adds `TAB_SIZE` if `column` is a multiple of `TAB_SIZE` // * Rounds `column` up to the next multiple of `TAB_SIZE` otherwise. // https://github.com/python/cpython/blob/2cf99026d6320f38937257da1ab014fc873a11a6/Parser/tokenizer.c#L1818 - column: Column((self.column.0 / Self::TAB_SIZE + 1) * Self::TAB_SIZE), + column: Column(self.column.0 + tab_offset_u32(self.column.0, Self::TAB_SIZE)), } } diff --git a/crates/ruff_python_trivia/src/whitespace.rs b/crates/ruff_python_trivia/src/whitespace.rs index 4e0f84b435..e36f570caa 100644 --- a/crates/ruff_python_trivia/src/whitespace.rs +++ b/crates/ruff_python_trivia/src/whitespace.rs @@ -17,7 +17,7 @@ pub fn expand_tabs(source: &str) -> Cow<'_, str> { for character in source.chars() { match character { '\t' => { - let spaces = TAB_SIZE - column % TAB_SIZE; + let spaces = tab_offset(column, TAB_SIZE); expanded.extend(std::iter::repeat_n(' ', spaces)); column += spaces; } @@ -35,6 +35,16 @@ pub fn expand_tabs(source: &str) -> Cow<'_, str> { Cow::Owned(expanded) } +/// Returns the number of columns from `column` to the next tab stop. +pub const fn tab_offset(column: usize, tab_size: usize) -> usize { + tab_size - column % tab_size +} + +/// Returns the number of columns from `column` to the next tab stop using `u32` values. +pub const fn tab_offset_u32(column: u32, tab_size: u32) -> u32 { + tab_size - column % tab_size +} + /// Extract the leading indentation from a line. pub fn indentation_at_offset(offset: TextSize, source: &str) -> Option<&str> { let line_start = source.line_start(offset); @@ -117,7 +127,7 @@ impl PythonWhitespace for str { mod tests { use std::borrow::Cow; - use super::expand_tabs; + use super::{expand_tabs, tab_offset, tab_offset_u32}; #[test] fn tab_expansion_borrows_unchanged_text() { @@ -131,4 +141,18 @@ mod tests { assert!(matches!(&expanded, Cow::Owned(_))); assert_eq!(expanded, " value"); } + + #[test] + fn tab_offset_advances_to_next_stop() { + assert_eq!(tab_offset(0, 8), 8); + assert_eq!(tab_offset(2, 8), 6); + assert_eq!(tab_offset(8, 8), 8); + } + + #[test] + fn u32_tab_offset_advances_to_next_stop() { + assert_eq!(tab_offset_u32(0, 8), 8); + assert_eq!(tab_offset_u32(2, 8), 6); + assert_eq!(tab_offset_u32(8, 8), 8); + } } diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index 78908e3bbb..bcc86a6f53 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -1,4 +1,4 @@ -use ruff_python_trivia::leading_indentation; +use ruff_python_trivia::{leading_indentation, tab_offset_u32}; use ruff_source_file::UniversalNewlines; use ruff_text_size::{TextRange, TextSize}; @@ -239,7 +239,7 @@ pub(super) fn indentation(line: &str) -> TextSize { leading_indentation(line) .bytes() .fold(0u32, |column, byte| match byte { - b'\t' => (column / 8 + 1) * 8, + b'\t' => column + tab_offset_u32(column, 8), _ => column + 1, }), ) From b860ddc336ddd3211efc3f7c2c5e6bd8c94cc752 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 09:57:37 -0700 Subject: [PATCH 055/222] [ty] Add universal constraint quantification (#26694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ConstraintSet` already supports existentially abstracting a set of inferable type variables. This adds the universal counterpart, `ConstraintSet::for_all`, using the Boolean dual `¬∃¬` so it reuses the cached, single-pass existential abstraction added in #26622. The operation is intentionally low-level: declared type-variable bounds and constraints must be encoded as implications in the input, and callers must first project atomic constraints that relate removed variables to variables retained in the result. The stacked receiver-constraint and signature-relation follow-ups culminate in #26695, which uses this operation when comparing generic signatures. --- .../mdtest/type_properties/constraints.md | 50 +++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 11 ++++ .../ty_python_semantic/src/types/call/bind.rs | 50 +++++++++++++++---- .../src/types/constraints.rs | 33 ++++++++++++ .../ty_python_semantic/src/types/display.rs | 3 ++ crates/ty_python_semantic/src/types/method.rs | 23 +++++++++ .../ty_python_semantic/src/types/relation.rs | 1 + .../ty_vendored/ty_extensions/_internal.pyi | 5 ++ 8 files changed, 167 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md index e25e2a9ff0..c4e8cd5c9e 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md @@ -861,6 +861,56 @@ def same_typevar[T](): static_assert(constraints == expected) ``` +## Universal quantification + +Universal quantification removes the listed typevars from a constraint set. Any constraints that do +not involve those typevars must remain in the result, including constraints in an uncertain branch. + +```py +from typing import Never +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def preserves_uncertain_disjunct[T, U]() -> None: + t_int = ConstraintSet.range(int, T, int) + u_str = ConstraintSet.range(str, U, str) + quantified = (t_int | u_str).for_all(tuple[U]) + static_assert(quantified == t_int) + +def removes_multiple_typevars[T, U]() -> None: + t_int = ConstraintSet.range(int, T, int) + u_str = ConstraintSet.range(str, U, str) + quantified = (t_int | u_str).for_all(tuple[T, U]) + static_assert(quantified == ConstraintSet.never()) + +def no_typevars_is_identity[T]() -> None: + constraints = ConstraintSet.range(Never, T, int) + static_assert(constraints.for_all(tuple[()]) == constraints) +``` + +The order of existential and universal quantifiers matters. For each target truth assignment there +is some matching source truth assignment, but no single source truth assignment matches every target +truth assignment. + +```py +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def quantifier_order[S, T]() -> None: + source_is_int = ConstraintSet.range(int, S, int) + target_is_int = ConstraintSet.range(int, T, int) + equal = source_is_int.satisfies(target_is_int) & target_is_int.satisfies(source_is_int) + + # ∀T.∃S.equal(S, T) = ∀T.¬∀S.¬equal(S, T) + forall_target_exists_source = (~((~equal).for_all(tuple[S]))).for_all(tuple[T]) + static_assert(forall_target_exists_source == ConstraintSet.always()) + + # ∃S.∀T.equal(S, T) = ¬∀S.¬∀T.equal(S, T) + forall_target = equal.for_all(tuple[T]) + exists_source_forall_target = ~((~forall_target).for_all(tuple[S])) + static_assert(exists_source_forall_target == ConstraintSet.never()) +``` + ## Displaying constraints The `with_detailed_display` method can be used to print out the boolean formula that a constraint diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2433d43ff0..af437963e2 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3978,6 +3978,14 @@ impl<'db> Type<'db> { )) .into() } + Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) + if name == "for_all" => + { + Place::bound(Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetForAll(tracked), + )) + .into() + } Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) if name == "satisfied_by_all_typevars" => { @@ -6382,6 +6390,7 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) ) @@ -6751,6 +6760,7 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) ) @@ -7007,6 +7017,7 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index f65828e395..6b6cf02e1d 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -150,6 +150,21 @@ fn freshen_generic_contexts_in_type<'db>( }) } +fn inferable_typevars_from_tuple<'db>( + db: &'db dyn Db, + instance: &NominalInstanceType<'db>, +) -> Option> { + let typevars: Option> = instance + .tuple_spec(db)? + .fixed_elements() + .map(|ty| { + ty.as_typevar() + .map(|bound_typevar| bound_typevar.identity(db)) + }) + .collect(); + typevars.map(|typevars| InferableTypeVars::from_typevars(db, typevars)) +} + /// Priority levels for call errors in intersection types. /// Higher values indicate more specific errors that should take precedence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -2658,6 +2673,31 @@ impl<'db> Bindings<'db> { )); } + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetForAll(tracked)) => { + let [Some(typevars)] = overload.parameter_types() else { + continue; + }; + let Type::NominalInstance(instance) = typevars.project_type_form(db) else { + continue; + }; + let Some(typevars) = inferable_typevars_from_tuple(db, &instance) else { + continue; + }; + + let constraints = ConstraintSetBuilder::new(); + let result = constraints.into_owned(|constraints| { + constraints.load(db, tracked.constraints(db)).for_all( + db, + constraints, + typevars, + ) + }); + let tracked = InternedConstraintSet::new(db, result); + overload.set_return_type(Type::KnownInstance( + KnownInstanceType::ConstraintSet(tracked), + )); + } + Type::KnownBoundMethod( KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(tracked), ) => { @@ -2666,15 +2706,7 @@ impl<'db> Bindings<'db> { // Caller explicitly passed None, so no typevars are inferable. return Some(InferableTypeVars::None); } - let typevars: Option> = instance - .tuple_spec(db)? - .fixed_elements() - .map(|ty| { - ty.as_typevar() - .map(|bound_typevar| bound_typevar.identity(db)) - }) - .collect(); - typevars.map(|typevars| InferableTypeVars::from_typevars(db, typevars)) + inferable_typevars_from_tuple(db, instance) }; let inferable = match overload.parameter_types() { diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 13b9a3e69f..47032d95bd 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -681,6 +681,39 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { ) } + /// Universally abstracts constraints involving the given type variables from this TDD. + /// + /// This is the Boolean dual of [`Self::reduce_inferable`]. Declared type variable bounds and + /// constraints are not applied implicitly, and must be encoded as implications in the input + /// constraint set. + /// + /// # Preconditions + /// + /// An atomic constraint must not relate a removed type variable to one that remains in the + /// result. Callers that need type-level quantification must project those relationships before + /// calling this method. + pub(crate) fn for_all( + self, + db: &'db dyn Db, + builder: &'c ConstraintSetBuilder<'db>, + to_remove: InferableTypeVars<'db>, + ) -> Self { + self.verify_builder(builder); + if to_remove == InferableTypeVars::None { + return self; + } + + // Universal and existential quantification are duals. Reusing existential abstraction + // also keeps this operation on its cached, single-pass implementation. + Self::from_node( + builder, + self.node + .negate(builder) + .exists(db, builder, to_remove) + .negate(builder), + ) + } + /// Computes solutions for each BDD path, using a caller-provided hook to select solutions. /// /// The `choose` hook is called for each typevar on each BDD path with the typevar's variance diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 3f7a678ea9..31def01cfe 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -1210,6 +1210,9 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'db> { KnownBoundMethodType::ConstraintSetSatisfies(_) => { return f.write_str("bound method `ConstraintSet.satisfies`"); } + KnownBoundMethodType::ConstraintSetForAll(_) => { + return f.write_str("bound method `ConstraintSet.for_all`"); + } KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) => { return f .write_str("bound method `ConstraintSet.satisfied_by_all_typevars`"); diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 1ca0b41ea1..1e50496b97 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -178,6 +178,7 @@ pub enum KnownBoundMethodType<'db> { ConstraintSetNever, ConstraintSetImpliesSubtypeOf(InternedConstraintSet<'db>), ConstraintSetSatisfies(InternedConstraintSet<'db>), + ConstraintSetForAll(InternedConstraintSet<'db>), ConstraintSetSatisfiedByAllTypeVars(InternedConstraintSet<'db>), ConstraintSetWithDetailedDisplay(InternedConstraintSet<'db>), } @@ -214,6 +215,7 @@ pub(super) fn walk_method_wrapper_type<'db, V: visitor::TypeVisitor<'db> + ?Size | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => {} } @@ -258,6 +260,7 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => Some(self), } @@ -277,6 +280,7 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => { KnownClass::ConstraintSet @@ -434,6 +438,19 @@ impl<'db> KnownBoundMethodType<'db> { ))) } + KnownBoundMethodType::ConstraintSetForAll(_) => { + Either::Right(std::iter::once(Signature::new( + Parameters::standard([Parameter::positional_only(Some(Name::new_static( + "typevars", + ))) + .with_annotated_type(TypeFormType::from_type_expression( + db, + Type::homogeneous_tuple(db, Type::object()), + ))]), + KnownClass::ConstraintSet.to_instance(db), + ))) + } + KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) => { Either::Right(std::iter::once(Signature::new( Parameters::standard([Parameter::keyword_only(Name::new_static("inferable")) @@ -515,6 +532,10 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { KnownBoundMethodType::ConstraintSetSatisfies(_), KnownBoundMethodType::ConstraintSetSatisfies(_), ) + | ( + KnownBoundMethodType::ConstraintSetForAll(_), + KnownBoundMethodType::ConstraintSetForAll(_), + ) | ( KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), @@ -536,6 +557,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), KnownBoundMethodType::FunctionTypeDunderGet(_) @@ -549,6 +571,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) => self.never(), diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 8c33214f47..1bd4332114 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -262,6 +262,7 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetNever | KnownBoundMethodType::ConstraintSetImpliesSubtypeOf(_) | KnownBoundMethodType::ConstraintSetSatisfies(_) + | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) diff --git a/crates/ty_vendored/ty_extensions/_internal.pyi b/crates/ty_vendored/ty_extensions/_internal.pyi index 6260e0dc86..de43623d53 100644 --- a/crates/ty_vendored/ty_extensions/_internal.pyi +++ b/crates/ty_vendored/ty_extensions/_internal.pyi @@ -114,6 +114,11 @@ class ConstraintSet: `other`. """ + def for_all(self, typevars: TypeForm[tuple[object, ...]]) -> Self: + """ + Universally abstracts the given type variables from this constraint set. + """ + def satisfied_by_all_typevars( self, *, inferable: TypeForm[tuple[object, ...]] | None = None ) -> bool: From 25b10f9d01a15357ff5c8b54461244fa6664a552 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 10:40:42 -0700 Subject: [PATCH 056/222] Avoid redundant lexer token bookkeeping (#26765) ## Summary `Lexer::next_token` previously marked the token start before every token, and the common lexing path marked it again after optional whitespace handling even when the cursor had not moved. We now track token starts only on paths that actually lex a token: interpolated-string and indentation paths mark their ranges explicitly, while the common path marks the start immediately before consuming the token. Whitespace-at-EOF errors use an explicit start offset instead of depending on the current token range. --- crates/ruff_python_parser/src/lexer.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index 911214f5be..85ae48535b 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -144,7 +144,7 @@ impl<'src> Lexer<'src> { /// Lex the next token. pub fn next_token(&mut self) -> TokenKind { - self.cursor.start_token(); + // `lex_token` marks the start on the path that lexes each token. self.current_flags = TokenFlags::empty(); self.current_kind = self.lex_token(); // For `Unknown` token, the `push_error` method updates the current range. @@ -157,6 +157,7 @@ impl<'src> Lexer<'src> { fn lex_token(&mut self) -> TokenKind { if let Some(interpolated_string) = self.interpolated_strings.current() { if !interpolated_string.is_in_interpolation(self.nesting) { + self.cursor.start_token(); if let Some(token) = self.lex_interpolated_string_middle_or_end() { if token.is_interpolated_string_end() { self.interpolated_strings.pop(); @@ -167,6 +168,7 @@ impl<'src> Lexer<'src> { } // Return dedent tokens until the current indentation level matches the indentation of the next token. else if let Some(indentation) = self.pending_indentation.take() { + self.cursor.start_token(); match self.indentations.current().try_compare(indentation) { Ok(Ordering::Greater) => { self.pending_indentation = Some(indentation); @@ -189,6 +191,8 @@ impl<'src> Lexer<'src> { } if self.state.is_after_newline() { + // Indent and dedent tokens include leading whitespace in their ranges. + self.cursor.start_token(); if let Some(indentation) = self.eat_indentation() { return indentation; } @@ -198,7 +202,7 @@ impl<'src> Lexer<'src> { } } - // The lexer might've skipped whitespaces, so update the start offset + // Whitespace between tokens is not part of the next token's range. self.cursor.start_token(); if let Some(c) = self.cursor.bump() { @@ -347,6 +351,12 @@ impl<'src> Lexer<'src> { } fn skip_whitespace(&mut self) -> Result<(), LexicalError> { + let whitespace_start = if matches!(self.cursor.first(), ' ' | '\t' | '\\' | '\x0C') { + self.offset() + } else { + return Ok(()); + }; + loop { match self.cursor.first() { ' ' => { @@ -366,7 +376,10 @@ impl<'src> Lexer<'src> { )); } if self.cursor.is_eof() { - return Err(LexicalError::new(LexicalErrorType::Eof, self.token_range())); + return Err(LexicalError::new( + LexicalErrorType::Eof, + TextRange::new(whitespace_start, self.offset()), + )); } } // Form feed From 6e29838d4bd65a2ece1d93d44ce85d8ee0105117 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 10:47:07 -0700 Subject: [PATCH 057/222] Avoid redundant pending-indentation writes (#26774) ## Summary The lexer checks `pending_indentation` for every token. `Option::take` writes `None` even on the common path where no indentation is pending, so we now read the field first and clear it only when a value is present. This avoids a redundant write in the hot lexer loop while preserving the existing indentation behavior. This PR is stacked on #26765 so that CodSpeed can measure the pending-indentation optimization independently. --------- Co-authored-by: Micha Reiser --- crates/ruff_python_parser/src/lexer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index 85ae48535b..b0848d5352 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -167,7 +167,10 @@ impl<'src> Lexer<'src> { } } // Return dedent tokens until the current indentation level matches the indentation of the next token. - else if let Some(indentation) = self.pending_indentation.take() { + // Avoid `Option::take` here: this check runs for every token, and `take` writes `None` + // even when there is no pending indentation. + else if let Some(indentation) = self.pending_indentation { + self.pending_indentation = None; self.cursor.start_token(); match self.indentations.current().try_compare(indentation) { Ok(Ordering::Greater) => { From 20b113c9b322e4c44bba8c864968a574391190a0 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 14 Jul 2026 20:28:51 +0200 Subject: [PATCH 058/222] [ty] Add support for ignored snippets in mdtests (#26796) ## Summary We now test all of our rule descriptions using mdtests. For Pydantic-specific rules, we can't do that, since we don't want to bundle Pydantic or add a mock-version of Pydantic to the mdtests (https://github.com/astral-sh/ruff/pull/26762#discussion_r3573467129). In order to still get syntax highlighting in our docs, we therefore add support for the following `data-mdtest="ignore"` attribute that can be used to skip mdtest validation: ````markdown ```python {data-mdtest="ignore"} from pydantic import BaseModel # [...] ``` ```` --- crates/mdtest/src/parser.rs | 54 ++++++++++++++++++- crates/ty/docs/rules.md | 46 ++++++++-------- .../pydantic-discarded-extra-argument.md | 2 +- crates/ty_python_semantic/src/lint.rs | 2 + .../src/types/diagnostic.rs | 4 ++ ty.schema.json | 2 +- 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/crates/mdtest/src/parser.rs b/crates/mdtest/src/parser.rs index 28c4438c8b..0fedd213b6 100644 --- a/crates/mdtest/src/parser.rs +++ b/crates/mdtest/src/parser.rs @@ -690,12 +690,29 @@ where self.skip_non_newline_whitespace(); + let metadata = self.consume_until(|c| c == '\n').unwrap_or_default().trim(); + if !self.cursor.eat_char('\n') { bail!( "Trailing code-block metadata is not supported. Only the code block language can be specified." ); } + let ignore = if metadata.is_empty() { + false + } else if let Some(attributes) = metadata + .strip_prefix('{') + .and_then(|metadata| metadata.strip_suffix('}')) + { + attributes + .split_ascii_whitespace() + .any(|attribute| attribute == r#"data-mdtest="ignore""#) + } else { + bail!( + "Trailing code-block metadata must use the `{{...}}` attribute-list syntax." + ); + }; + if let Some(position) = memchr::memmem::find(self.cursor.as_bytes(), CODE_BLOCK_END) { @@ -711,6 +728,7 @@ where self.process_code_block( lang, code, + ignore, BacktickOffsets(TextRange::new( backtick_offset_start, backtick_offset_end, @@ -801,12 +819,17 @@ where &mut self, lang: &'s str, code: &'s str, + ignore: bool, backtick_offsets: BacktickOffsets, ) -> anyhow::Result<()> { // We never pop the implicit root section. let section = self.stack.top(); let test_name = self.sections[section].title; + if ignore { + return Ok(()); + } + if lang == "toml" && self.explicit_path.is_none() { return self.process_config_block(code); } @@ -1678,6 +1701,33 @@ mod tests { assert_eq!(file.code, "x = 1"); } + #[test] + fn ignores_python_blocks_with_ignore_metadata() { + let source = dedent( + r#" + # Example + + ```python {.example data-mdtest="ignore" title="Ignored example"} + x: int = "wrong, but not checked" + ``` + + ```py + x = 1 + ``` + "#, + ); + + let mf = parse("file.md", &source).unwrap(); + let [test] = &mf.tests().collect::>()[..] else { + panic!("expected one test"); + }; + let [file] = test.files().collect::>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.code, "x = 1"); + } + #[test] fn mismatching_lang() { let source = dedent( @@ -2181,7 +2231,7 @@ mod tests { } #[test] - fn config_no_longer_allowed() { + fn unbraced_metadata_not_allowed() { let source = dedent( " ```py foo=bar @@ -2192,7 +2242,7 @@ mod tests { let err = parse("file.md", &source).expect_err("Should fail to parse"); assert_eq!( err.to_string(), - "Trailing code-block metadata is not supported. Only the code block language can be specified." + "Trailing code-block metadata must use the `{...}` attribute-list syntax." ); } diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 380c593097..bd9e003dd3 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -1178,7 +1178,7 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1236,7 +1236,7 @@ C.instance_only_var = 56 # error Default level: error · Added in 0.0.33 · Related issues · -View source +View source @@ -1676,7 +1676,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1920,7 +1920,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -2107,7 +2107,7 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2740,7 +2740,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -3215,7 +3215,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -3251,7 +3251,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -3294,7 +3294,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -3653,7 +3653,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -3930,7 +3930,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4173,7 +4173,7 @@ name or an incorrect assumption about the model's schema. **Example** -```ignore +```python {data-mdtest="ignore"} from pydantic import BaseModel @@ -4229,7 +4229,7 @@ def test() -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4264,7 +4264,7 @@ cast(int, f()) # error Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -4302,7 +4302,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -4346,7 +4346,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4737,7 +4737,7 @@ f(x=1, y=2) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4770,7 +4770,7 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -4845,7 +4845,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4874,7 +4874,7 @@ import foo # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5045,7 +5045,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5078,7 +5078,7 @@ A() + A() # error Default level: warn · Added in 0.0.21 · Related issues · -View source +View source @@ -5277,7 +5277,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md index 60fc667364..28fc08187f 100644 --- a/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md +++ b/crates/ty_python_semantic/resources/lint_docs/pydantic-discarded-extra-argument.md @@ -10,7 +10,7 @@ name or an incorrect assumption about the model's schema. ## Example -```ignore +```python {data-mdtest="ignore"} from pydantic import BaseModel diff --git a/crates/ty_python_semantic/src/lint.rs b/crates/ty_python_semantic/src/lint.rs index 2467945338..f182f422af 100644 --- a/crates/ty_python_semantic/src/lint.rs +++ b/crates/ty_python_semantic/src/lint.rs @@ -247,6 +247,7 @@ impl LintStatus { macro_rules! declare_lint { ( $(#[expect($($expect:tt)*)])? + $(#[allow($($allow:tt)*)])? $(#[doc = $doc:expr])+ $vis: vis static $name: ident = { summary: $summary: literal, @@ -256,6 +257,7 @@ macro_rules! declare_lint { } ) => { $(#[expect($($expect)*)])? + $(#[allow($($allow)*)])? $( #[doc = $doc] )+ $vis static $name: $crate::lint::LintMetadata = $crate::lint::LintMetadata { name: ruff_db::diagnostic::LintName::of(ruff_macros::kebab_case!($name)), diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 0e8d3f52fc..d903bf34cf 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -1056,6 +1056,10 @@ declare_lint! { } declare_lint! { + #[allow( + rustdoc::invalid_codeblock_attributes, + reason = "`data-mdtest` is an mdtest-specific code-block attribute" + )] #[doc = include_str!("../../resources/lint_docs/pydantic-discarded-extra-argument.md")] pub(crate) static PYDANTIC_DISCARDED_EXTRA_ARGUMENT = { summary: "detects extra constructor arguments that Pydantic silently discards", diff --git a/ty.schema.json b/ty.schema.json index 76453078b7..530c80cad3 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1289,7 +1289,7 @@ }, "pydantic-discarded-extra-argument": { "title": "detects extra constructor arguments that Pydantic silently discards", - "description": "## What it does\n\nChecks for extra keyword arguments that Pydantic silently discards when a model uses\n`extra=\"ignore\"`, either implicitly or explicitly.\n\n## Why is this bad?\n\nA discarded argument has no effect on the constructed model, but it may indicate a misspelled field\nname or an incorrect assumption about the model's schema.\n\n## Example\n\n```ignore\nfrom pydantic import BaseModel\n\n\nclass User(BaseModel):\n name: str\n admin: bool = False\n\n\nuser = User(name=\"Alice\", admni=True) # error: [pydantic-discarded-extra-argument]\n```\n\nIf the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument,\nor explicitly configure the model with `extra=\"allow\"`.", + "description": "## What it does\n\nChecks for extra keyword arguments that Pydantic silently discards when a model uses\n`extra=\"ignore\"`, either implicitly or explicitly.\n\n## Why is this bad?\n\nA discarded argument has no effect on the constructed model, but it may indicate a misspelled field\nname or an incorrect assumption about the model's schema.\n\n## Example\n\n```python {data-mdtest=\"ignore\"}\nfrom pydantic import BaseModel\n\n\nclass User(BaseModel):\n name: str\n admin: bool = False\n\n\nuser = User(name=\"Alice\", admni=True) # error: [pydantic-discarded-extra-argument]\n```\n\nIf the field name has been misspelled, fix the typo. Otherwise, consider removing the extra argument,\nor explicitly configure the model with `extra=\"allow\"`.", "default": "warn", "oneOf": [ { From a05cc071ae553e4885b866b8f4f726908685c2a0 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Tue, 14 Jul 2026 15:09:03 -0400 Subject: [PATCH 059/222] [ty] Use a simpler fuel to bound sequent map growth (#26674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "nested substitution" logic was an attempt to prevent runaway growth in our sequent map, especially involving mutually recursive constraints. For instance, given the constraint `Unknown & tuple[T] ≤ T`, our sequent map would repeated substitute it into itself: - `Unknown & tuple[T] ≤ T` - `Unknown & tuple[Unknown & tuple[T]] ≤ T` - `Unknown & tuple[Unknown & tuple[Unknown & tuple[T]]] ≤ T` - ... This logic worked, but could only detect certain kinds of runaway growth. While working on https://github.com/astral-sh/ruff/pull/25559, there were several performance regressions that stemmed from other patterns. (Not all of them were true unbounded growth like the example above; some were just producing constraints that were too complex to be useful, and which were making our BDDs too large to process efficiently.) Instead of playing whack-a-mole, and making the sequent growth logic more and more complex, this PR replaces it with a much simper "fuel" heuristic. We cap the number of times we're willing to derive a constraint from other derived constraints. If we (empirically) choose a large enough fuel budget, we should be able to produce all of the meaningful multi-step inferences that we need for real-world code. --- .../regression/derived_constraint_cycles.md | 66 +++ .../src/types/constraints.rs | 508 ++++++++++-------- 2 files changed, 355 insertions(+), 219 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md index 63d69a1608..f1fa5ed3c8 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md @@ -83,4 +83,70 @@ def nested_running_min(iterable): ) ``` +## Recursive constraint expansion for an optional tuple element + +Inferring this assignment produces both a base lower bound and a recursive lower bound for `T`: + +- `Unknown & None ≤ T` +- `Unknown & tuple[T] ≤ T` + +Combining them produces the finite consequence `Unknown & tuple[Unknown & None] ≤ T`. Previously, we +repeatedly fed each new consequence back into the recursive bound, adding another tuple layer each +time: + +- `Unknown & tuple[Unknown & None] ≤ T` +- `Unknown & tuple[Unknown & tuple[Unknown & None]] ≤ T` +- `Unknown & tuple[Unknown & tuple[Unknown & tuple[...]]] ≤ T` + +The constraints therefore grew without bound instead of reaching a fixed point. + +```py +def wrap[U](value: U) -> tuple[U]: + return (value,) + +def f[T](sentinel): + items: list[tuple[T] | None] = [None] + if items[0] is sentinel: + items[0] = wrap(sentinel) +``` + +## Recursive structural growth without nested typevars + +A substitution can remove the last nested typevar from a derived constraint while still producing an +increasingly deep family of concrete bounds. Structural growth must continue to consume fuel after +that substitution. + +```py +from typing import Iterable, Protocol, TypeAlias, TypeVar + +V_co = TypeVar("V_co", covariant=True) + +class Compatible(Protocol): + def convert(self) -> object: ... + +OptionSequence: TypeAlias = Iterable[V_co] | Compatible + +def convert(obj: OptionSequence[V_co]) -> list[V_co]: + if isinstance(obj, float): + return [obj] # ty: ignore[invalid-return-type] + return [] +``` + +## Propagating an existing deep concrete bound + +Structural fuel is charged only for depth introduced by a derivation. Propagating a deeply nested +concrete bound through a typevar therefore remains cheap. + +```py +from typing import Never +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +type Deep = tuple[tuple[tuple[tuple[tuple[tuple[tuple[tuple[tuple[tuple[int]]]]]]]]]] + +def check_deep_bound[T, U](): + constraints = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, Deep) + static_assert(constraints.implies_subtype_of(T, Deep)) +``` + [ty#24660]: https://github.com/astral-sh/ruff/pull/24660 diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 47032d95bd..81b6774805 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -86,7 +86,7 @@ //! //! [duboc]: https://gldubc.github.io/#thesis -use std::cell::{Ref, RefCell}; +use std::cell::{Cell, Ref, RefCell}; use std::cmp::Ordering; use std::fmt::{Debug, Display}; use std::iter; @@ -95,7 +95,7 @@ use std::ops::Range; use std::sync::Arc; use indexmap::map::Entry; -use itertools::{EitherOrBoth, Itertools}; +use itertools::Itertools; use ruff_index::{Idx, IndexVec, newtype_index}; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; @@ -106,7 +106,8 @@ use crate::types::generics::InferableTypeVars; use crate::types::typevar::{BoundTypeVarIdentity, walk_bound_type_var_type}; use crate::types::variance::VarianceInferable; use crate::types::visitor::{ - TypeCollector, TypeVisitor, any_over_type, walk_type_with_recursion_guard, + TypeCollector, TypeKind, TypeVisitor, any_over_type, walk_non_atomic_type, + walk_type_with_recursion_guard, }; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Type, TypeContext, @@ -830,6 +831,9 @@ struct ConstraintSetStorage<'db> { constraint_cache: FxHashMap, ConstraintId>, typevar_cache: FxHashMap, TypeVarId>, node_cache: FxHashMap, + /// Avoid repeatedly walking deep constraint bounds without imposing Salsa-query overhead on + /// the many shallow bounds that are cheap to walk once. + constraint_bound_depth_cache: FxHashMap, constraint_implication_cache: FxHashMap<(ConstraintId, ConstraintId), bool>, /// Only caches completed top-level results. Recursive results depend on active path /// assignments and must not use this cache. @@ -1172,6 +1176,52 @@ impl<'db> ConstraintSetBuilder<'db> { storage.constraints[constraint] } + fn cached_constraint_bound_depth( + &self, + db: &'db dyn Db, + constraint: ConstraintId, + ) -> (u16, u16) { + if let Some(depth) = self + .storage + .borrow() + .constraint_bound_depth_cache + .get(&constraint) + { + return *depth; + } + + let depth = self.constraint_data(constraint).bound_depth(db); + self.storage + .borrow_mut() + .constraint_bound_depth_cache + .insert(constraint, depth); + depth + } + + /// Returns how much sequent fuel is needed to derive this constraint. + /// + /// This cost is driven by two factors. + /// + /// First, nested types containing typevars can produce increasingly complex families of + /// derived constraints. Charge more fuel for those constraints so that each additional level + /// of typevar depth shortens the remaining derivation chain. + /// + /// Second, even without considering typevars, the lower and upper bounds can become more + /// structurally complex. We consider a type to be more complex if it has deeper nesting of + /// type constructors. Each sequent is charged the _increase_ in that complexity between its + /// antecedents and its consequent. (Measuring growth rather than absolute depth avoids + /// penalizing a complex concrete bound that is merely propagated unchanged.) + fn sequent_fuel_cost( + &self, + db: &'db dyn Db, + constraint: ConstraintId, + antecedent_constructor_depth: u16, + ) -> u16 { + let (constructor_depth, typevar_depth) = self.cached_constraint_bound_depth(db, constraint); + let constructor_growth = constructor_depth.saturating_sub(antecedent_constructor_depth); + typevar_depth.max(constructor_growth).saturating_add(1) + } + fn cached_constraint_implies( &self, db: &'db dyn Db, @@ -1268,94 +1318,6 @@ pub struct TypeVarId; #[derive(get_size2::GetSize)] pub struct ConstraintId; -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, get_size2::GetSize)] -enum NestedSubstitutionSide { - Lower, - Upper, -} - -/// Identifies one nested-typevar substitution shape. -/// -/// We key this by the typevar of the constrained constraint (which stays the same across an -/// entire chain of derivations against a single root constraint), the typevar that we substitute -/// _for_, and the side. Each derived path assignment records the substitution shapes in its own -/// derivation history. This lets independent derivations apply the same substitution while -/// preventing one derivation chain from repeatedly unfolding the same recursive pattern. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, get_size2::GetSize)] -struct NestedSubstitution { - constrained_typevar: TypeVarId, - substituted_typevar: TypeVarId, - side: NestedSubstitutionSide, -} - -/// The nested substitution shapes used to derive one path assignment. -/// -/// The substitutions are kept sorted so that histories with the same substitutions compare and -/// hash equally regardless of the order in which the antecedents were combined. -#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] -struct NestedSubstitutionHistory(SmallVec<[NestedSubstitution; 4]>); - -impl NestedSubstitutionHistory { - /// Merges two derivation histories and records `nested_substitution`. Returns `None` if either - /// history has already applied that substitution. - fn merged( - &self, - other: &Self, - nested_substitution: Option, - ) -> Option { - if let Some(substitution) = nested_substitution - && (self.contains(substitution) || other.contains(substitution)) - { - return None; - } - - Some(Self( - self.0 - .iter() - .merge(other.0.iter()) - .merge(nested_substitution.iter()) - .dedup() - .copied() - .collect(), - )) - } - - fn contains(&self, substitution: NestedSubstitution) -> bool { - self.0.binary_search(&substitution).is_ok() - } - - fn is_subset_of(&self, other: &Self) -> bool { - self.0.len() <= other.0.len() - && self - .0 - .iter() - .merge_join_by(other.0.iter(), Ord::cmp) - .all(|item| !matches!(item, EitherOrBoth::Left(_))) - } -} - -/// A constraint derived from the sequent map, optionally annotated with the nested substitution -/// step that produced it. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] -struct DerivedConstraint { - constraint: ConstraintId, - nested_substitution: Option, -} - -fn nested_substitution<'db>( - db: &'db dyn Db, - builder: &ConstraintSetBuilder<'db>, - constrained_typevar: BoundTypeVarInstance<'db>, - substituted_typevar: BoundTypeVarInstance<'db>, - side: NestedSubstitutionSide, -) -> NestedSubstitution { - NestedSubstitution { - constrained_typevar: builder.typevar_id(db, constrained_typevar), - substituted_typevar: builder.typevar_id(db, substituted_typevar), - side, - } -} - /// An individual constraint in a constraint set. This restricts a single typevar to be within a /// lower and upper bound. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] @@ -1565,7 +1527,101 @@ impl ConstraintId { } } +/// Returns the maximum constructor depth of `ty` and the maximum nesting depth of any typevar that +/// it contains. +/// +/// Atomic types and bare typevars have constructor depth zero. The typevar depth is `0` if `ty` +/// does not contain any typevars. +fn max_constructor_and_typevar_depth<'db>(db: &'db dyn Db, ty: Type<'db>) -> (u16, u16) { + fn max_constructor_and_typevar_depth_impl<'db>( + db: &'db dyn Db, + ty: Type<'db>, + _dummy: (), + ) -> (u16, u16) { + struct TypeDepthVisitor<'db> { + active: RefCell>>, + current_depth: Cell, + max_constructor_depth: Cell, + max_typevar_depth: Cell, + } + + impl<'db> TypeVisitor<'db> for TypeDepthVisitor<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if ty.is_type_var() { + self.max_typevar_depth + .set(self.max_typevar_depth.get().max(self.current_depth.get())); + return; + } + + let TypeKind::NonAtomic(non_atomic) = TypeKind::from(ty) else { + return; + }; + if !self.active.borrow_mut().insert(ty) { + return; + } + + let current_depth = self.current_depth.get(); + let nested_depth = current_depth.saturating_add(1); + self.current_depth.set(nested_depth); + self.max_constructor_depth + .set(self.max_constructor_depth.get().max(nested_depth)); + walk_non_atomic_type(db, non_atomic, self); + self.current_depth.set(current_depth); + self.active.borrow_mut().remove(&ty); + } + } + + let visitor = TypeDepthVisitor { + active: RefCell::default(), + current_depth: Cell::default(), + max_constructor_depth: Cell::default(), + max_typevar_depth: Cell::default(), + }; + visitor.visit_type(db, ty); + ( + visitor.max_constructor_depth.get(), + visitor.max_typevar_depth.get(), + ) + } + + max_constructor_and_typevar_depth_impl(db, ty, ()) +} + impl<'db> Constraint<'db> { + fn bound_depth(self, db: &'db dyn Db) -> (u16, u16) { + let both_bounds = iter::chain(self.bounds.lower, self.bounds.upper); + both_bounds.fold((0, 0), |(constructor_depth, typevar_depth), bound| { + let (bound_constructor_depth, bound_typevar_depth) = + max_constructor_and_typevar_depth(db, bound); + ( + constructor_depth.max(bound_constructor_depth), + typevar_depth.max(bound_typevar_depth), + ) + }) + } + + /// Returns whether this constraint is produced by dropping exactly one bound from + /// `antecedent`, without changing its typevar or retained bound. + fn is_bound_projection_of(self, db: &'db dyn Db, antecedent: Self) -> bool { + if !self.typevar.is_same_typevar_as(db, antecedent.typevar) { + return false; + } + + let keeps_lower = self.bounds.lower.is_some() + && self.bounds.lower == antecedent.bounds.lower + && self.bounds.upper.is_none() + && antecedent.bounds.upper.is_some(); + let keeps_upper = self.bounds.upper.is_some() + && self.bounds.upper == antecedent.bounds.upper + && self.bounds.lower.is_none() + && antecedent.bounds.lower.is_some(); + keeps_lower || keeps_upper + } + /// Returns a new range constraint. /// /// Panics if `lower` and `upper` are not both fully static. @@ -5239,7 +5295,7 @@ struct SequentMap { /// Sequents of the form `C₁ ∧ C₂ → false` pair_impossibilities: FxHashSet<(ConstraintId, ConstraintId)>, /// Sequents of the form `C₁ ∧ C₂ → D` - pair_implications: FxIndexMap<(ConstraintId, ConstraintId), FxIndexSet>, + pair_implications: FxIndexMap<(ConstraintId, ConstraintId), FxIndexSet>, /// Sequents of the form `C → D` single_implications: FxIndexMap>, } @@ -5383,18 +5439,6 @@ impl SequentMap { ante1: ConstraintId, ante2: ConstraintId, post: ConstraintId, - ) { - self.add_pair_implication_with_provenance(db, builder, ante1, ante2, post, None); - } - - fn add_pair_implication_with_provenance<'db>( - &mut self, - db: &'db dyn Db, - builder: &ConstraintSetBuilder<'db>, - ante1: ConstraintId, - ante2: ConstraintId, - post: ConstraintId, - nested_substitution: Option, ) { // If the post constraint is unsatisfiable, then the antecedents contradict each other. let post_data = builder.constraint_data(post); @@ -5411,15 +5455,11 @@ impl SequentMap { if ante1.implies(db, builder, post) || ante2.implies(db, builder, post) { return; } - let derived = DerivedConstraint { - constraint: post, - nested_substitution, - }; if self .pair_implications .entry(Self::pair_key(ante1, ante2)) .or_default() - .insert(derived) + .insert(post) { tracing::trace!( target: "ty_python_semantic::types::constraints::SequentMap", @@ -5948,19 +5988,12 @@ impl SequentMap { constrained_data.bounds.lower, Some(new_upper), ); - self.add_pair_implication_with_provenance( + self.add_pair_implication( db, builder, bound_constraint, constrained_constraint, post, - Some(nested_substitution( - db, - builder, - constrained_typevar, - bound_typevar, - NestedSubstitutionSide::Upper, - )), ); } } @@ -6016,19 +6049,12 @@ impl SequentMap { Some(new_lower), constrained_data.bounds.upper, ); - self.add_pair_implication_with_provenance( + self.add_pair_implication( db, builder, bound_constraint, constrained_constraint, post, - Some(nested_substitution( - db, - builder, - constrained_typevar, - bound_typevar, - NestedSubstitutionSide::Lower, - )), ); } } @@ -6116,19 +6142,12 @@ impl SequentMap { constrained_data.bounds.lower, Some(new_upper), ); - self.add_pair_implication_with_provenance( + self.add_pair_implication( db, builder, bound_constraint, constrained_constraint, post, - Some(nested_substitution( - db, - builder, - constrained_typevar, - nested_typevar, - NestedSubstitutionSide::Upper, - )), ); } } @@ -6161,19 +6180,12 @@ impl SequentMap { Some(new_lower), constrained_data.bounds.upper, ); - self.add_pair_implication_with_provenance( + self.add_pair_implication( db, builder, bound_constraint, constrained_constraint, post, - Some(nested_substitution( - db, - builder, - constrained_typevar, - nested_typevar, - NestedSubstitutionSide::Lower, - )), ); } } @@ -6425,7 +6437,7 @@ impl SequentMap { "{} ∧ {} → {}", ante1.display(self.db, self.builder), ante2.display(self.db, self.builder), - post.constraint.display(self.db, self.builder), + post.display(self.db, self.builder), )?; } } @@ -6460,17 +6472,42 @@ impl SequentMap { /// The collection of constraints that we know to be true or false at a certain point when /// traversing a BDD. +/// +/// An important part of this traversal is that not all of those constraints come directly from the +/// BDD, since constraints are not independent. In particular, there can be "implications", which +/// record e.g. when two constraints both being true imply another: +/// `A ≤ list[B] ∧ B ≤ int → A ≤ list[int]`. If we see `A ≤ list[B]` and `B ≤ int` in a BDD path, +/// we can _assume_ that `A ≤ list[int]` also holds, even if it doesn't actually appear in the BDD. +/// +/// Unfortunately, there are certain implications that are technically true, but not helpful; +/// for instance, because they cause us to endlessly expand a constraint by substituting a bound +/// into itself. +/// +/// We use a "fuel" mechanism to prevent these kinds of situations, without having to play +/// whack-a-mole to implement detection patterns for all of the pathological patterns. Each +/// derived constraint costs at least one unit of fuel. Nested typevars increase that cost according +/// to their depth, as does any constructor depth introduced relative to the antecedents. Measuring +/// structural growth instead of absolute depth ensures that propagating an existing complex +/// concrete bound remains cheap, while repeatedly wrapping that bound continues to consume path +/// fuel after no nested typevars remain. +/// +/// We track this fuel in two ways: First, there is a global limit on the total amount of work we +/// are willing to do for a particular BDD path traversal. Second, there is a more focused +/// "per-path" limit, which records how far removed a derived constraint is from a constraint that +/// actually appears in the BDD. If either of those limits are exceeded, we ignore the derived +/// constraint that we are currently considering. #[derive(Debug)] pub(crate) struct PathAssignments { map: SequentMap, - /// Each assignment's source order and the first nested-substitution history that produced it. - /// (Most assignments have exactly one history, and so we save space and iteration overhead by - /// storing the first history here.) - assignments: FxIndexMap, - /// Additional histories that can produce an assignment, keyed by its index in `assignments`. - /// These are stored separately so that branch-local additions can be rolled back by truncating - /// the set. - additional_substitution_histories: FxIndexSet<(usize, NestedSubstitutionHistory)>, + /// Each assignment's source order and the first per-path fuel value with which it was derived. + assignments: FxIndexMap, + /// Additional per-path fuel values that can derive an assignment, keyed by its index in + /// `assignments`. These are stored separately so that branch-local additions can be rolled + /// back by truncating the set. Only the greatest fuel value participates in further + /// derivation. + additional_fuels: FxIndexSet<(usize, u16)>, + /// The amount of global fuel that remains across all assignments and paths. + remaining_overall_fuel: u16, /// Constraints that we have discovered, mapped to whether we have processed them yet. (This /// ensures a stable order for all of the derived constraints that we create, while still /// letting us create them lazily.) @@ -6479,6 +6516,10 @@ pub(crate) struct PathAssignments { impl PathAssignments { fn new(constraints: impl IntoIterator) -> Self { + /// The total amount of fuel that we are willing to spend for this path traversal. This was + /// chosen empirically, to balance performance with accurate ecosystem diagnostics. + const OVERALL_FUEL_BUDGET: u16 = 256; + let discovered = constraints .into_iter() .map(|constraint| (constraint, false)) @@ -6486,8 +6527,9 @@ impl PathAssignments { Self { map: SequentMap::default(), assignments: FxIndexMap::default(), - additional_substitution_histories: FxIndexSet::default(), + additional_fuels: FxIndexSet::default(), discovered, + remaining_overall_fuel: OVERALL_FUEL_BUDGET, } } @@ -6521,11 +6563,17 @@ impl PathAssignments { source_order: usize, f: impl FnOnce(&mut Self, Range) -> R, ) -> Option { + /// The maximum number of "trips through the sequent map" that we are willing to take for a + /// derived constraint. This records how far removed we are from a constraint that comes + /// directly from the BDD. + const PATH_FUEL_BUDGET: u16 = 8; + // Record a snapshot of the assignments that we already knew held — both so that we can // pass along the range of which assignments are new, and so that we can reset back to this // point before returning. let start = self.assignments.len(); - let additional_histories_start = self.additional_substitution_histories.len(); + let additional_fuels_start = self.additional_fuels.len(); + let previous_remaining_overall_fuel = self.remaining_overall_fuel; // Add the new assignment and anything we can derive from it. tracing::trace!( @@ -6544,8 +6592,8 @@ impl PathAssignments { builder, assignment, source_order, - false, - NestedSubstitutionHistory::default(), + None, + PATH_FUEL_BUDGET, ); let result = if found_conflict.is_err() { // If that results in the path now being impossible due to a contradiction, return @@ -6574,8 +6622,8 @@ impl PathAssignments { // Reset back to where we were before following this edge, so that the caller can reuse a // single instance for the entire BDD traversal. self.assignments.truncate(start); - self.additional_substitution_histories - .truncate(additional_histories_start); + self.additional_fuels.truncate(additional_fuels_start); + self.remaining_overall_fuel = previous_remaining_overall_fuel; result } @@ -6598,25 +6646,16 @@ impl PathAssignments { || self.assignment_holds(constraint.when_unconstrained()) } - /// If `assignment` holds on this path, returns an iterator of its substitution histories. - /// Otherwise returns `None`. - fn histories_for( - &self, - assignment: ConstraintAssignment, - ) -> Option + Clone> { - // This is complicated by the fact that we store each assignment's first history inline in - // the `assignments` field, and the others in `additional_substitution_histories`; and - // moreover that `additional_substitution_histories` interleaves histories from all - // assignments on this path. Assignments will typically have a single history, so it - // should™ be fine that we're scanning and filtering that entire list. - let (index, _, (_, history)) = self.assignments.get_full(&assignment)?; - let first = iter::once(history); - let rest = self - .additional_substitution_histories + /// Returns the greatest remaining fuel for any derivation of `assignment` on this path. + fn fuel_for(&self, assignment: ConstraintAssignment) -> Option { + let (index, _, (_, first_fuel)) = self.assignments.get_full(&assignment)?; + let max_fuel = self + .additional_fuels .iter() - .filter(move |(history_index, _)| *history_index == index) - .map(|(_, history)| history); - Some(iter::chain(first, rest)) + .filter(|(fuel_index, _)| *fuel_index == index) + .map(|(_, fuel)| *fuel) + .fold(*first_fuel, u16::max); + Some(max_fuel) } /// Update our sequent map to ensure that it holds all of the sequents that involve the given @@ -6656,8 +6695,8 @@ impl PathAssignments { builder: &ConstraintSetBuilder<'db>, assignment: ConstraintAssignment, source_order: usize, - derived: bool, - history: NestedSubstitutionHistory, + derived_fuel_cost: Option, + path_fuel: u16, ) -> Result<(), PathAssignmentConflict> { if matches!(assignment, ConstraintAssignment::Unconstrained(_)) { // An `Unconstrained` assignment means "this constraint can go either way". If there is @@ -6671,7 +6710,8 @@ impl PathAssignments { // derive any additional information from the sequent map. We still want to record the // assignment, but as an optimization we can return early without actually querying the // sequent map. - self.assignments.insert(assignment, (source_order, history)); + self.assignments + .insert(assignment, (source_order, path_fuel)); return Ok(()); } @@ -6693,35 +6733,53 @@ impl PathAssignments { match self.assignments.entry(assignment) { Entry::Vacant(entry) => { - entry.insert((source_order, history)); + if let Some(fuel_cost) = derived_fuel_cost { + self.remaining_overall_fuel = + match self.remaining_overall_fuel.checked_sub(fuel_cost) { + Some(updated_fuel) => updated_fuel, + None => return Ok(()), + }; + } + entry.insert((source_order, path_fuel)); } + Entry::Occupied(mut entry) => { let index = entry.index(); - let (existing_source_order, existing_history) = entry.get_mut(); + let (existing_source_order, existing_fuel) = entry.get_mut(); // If a constraint appears both as an "origin" constraint (it actually appears in // the BDD structure) and as a "derived" constraint (we infer it from other // constraints), we should prefer the origin source_order, regardless of which // order we encounter the various constraints in the BDD. - if !derived { + if derived_fuel_cost.is_none() { *existing_source_order = source_order; } - // A smaller history blocks fewer future substitutions, so it preserves every - // derivation available from a larger history. If an existing history is a subset - // of this one, this one is redundant and does not need to be processed. - if existing_history.is_subset_of(&history) - || self.additional_substitution_histories.iter().any( - |(history_index, existing_history)| { - *history_index == index && existing_history.is_subset_of(&history) - }, - ) - || !self - .additional_substitution_histories - .insert((index, history)) + // We've already seen this assignment, and in theory have already queried the + // sequent map for its consequents, which should let us return early. + // + // However, a new derivation chain can replenish the fuel for this assignment, + // giving it more chances to participate in multi-step sequent chains. That means + // there might be some consequents that were skipped previously due to a lack of + // fuel, that can be added now because of the replinished fuel budget. + + // There is another derivation of this assignment that already provides at least as + // much fuel as this constraint. That means replenishing the fuel won't have any + // effect. + if *existing_fuel >= path_fuel + || self + .additional_fuels + .iter() + .any(|(fuel_index, existing_fuel)| { + *fuel_index == index && *existing_fuel >= path_fuel + }) { return Ok(()); } + + // Record the replenished fuel separately so that `walk_edge` can restore the + // parent branch by truncating `additional_fuels`. + self.additional_fuels.insert((index, path_fuel)); } } @@ -6787,54 +6845,66 @@ impl PathAssignments { } } - let mut new_constraints = FxIndexSet::default(); + let mut new_constraints: FxIndexMap = FxIndexMap::default(); + let mut add_new_constraint = |constraint, available_fuel: u16, fuel_cost: u16| { + if let Some(post_fuel) = available_fuel.checked_sub(fuel_cost) { + let new_fuel = (post_fuel, fuel_cost); + new_constraints + .entry(constraint) + .and_modify(|existing_fuel| { + *existing_fuel = std::cmp::max_by_key( + *existing_fuel, + new_fuel, + |&(post_fuel, fuel_cost)| (post_fuel, std::cmp::Reverse(fuel_cost)), + ); + }) + .or_insert(new_fuel); + } + }; + for ((ante1, ante2), posts) in &self.map.pair_implications { - let Some(histories1) = self.histories_for(ante1.when_true()) else { + let Some(ante1_fuel) = self.fuel_for(ante1.when_true()) else { continue; }; - let Some(histories2) = self.histories_for(ante2.when_true()) else { + let Some(ante2_fuel) = self.fuel_for(ante2.when_true()) else { continue; }; - + let available_fuel = ante1_fuel.min(ante2_fuel); + let (ante1_constructor_depth, _) = builder.cached_constraint_bound_depth(db, *ante1); + let (ante2_constructor_depth, _) = builder.cached_constraint_bound_depth(db, *ante2); + let antecedent_constructor_depth = ante1_constructor_depth.max(ante2_constructor_depth); for post in posts { - // The number of histories can grow combinatorially because we consider every pair - // of antecedent histories. `add_assignment` partially prunes this growth by - // discarding a new history if an existing history for the same assignment is its - // subset. We do not yet remove existing histories that are supersets of a new - // history, because those removals would have to be restored when we backtrack out - // of the current BDD branch. - // - // TODO: Retain only subset-minimal histories for each assignment by adding support - // for rolling back histories removed by dominance pruning. - for history1 in histories1.clone() { - for history2 in histories2.clone() { - if let Some(history) = history1.merged(history2, post.nested_substitution) { - new_constraints.insert((post.constraint, history)); - } - } - } + let fuel_cost = builder.sequent_fuel_cost(db, *post, antecedent_constructor_depth); + add_new_constraint(*post, available_fuel, fuel_cost); } } for (ante, posts) in &self.map.single_implications { - let Some(histories) = self.histories_for(ante.when_true()) else { + let Some(available_fuel) = self.fuel_for(ante.when_true()) else { continue; }; + let ante_data = builder.constraint_data(*ante); + let (antecedent_constructor_depth, _) = + builder.cached_constraint_bound_depth(db, *ante); for post in posts { - for history in histories.clone() { - new_constraints.insert((*post, history.clone())); - } + let post_data = builder.constraint_data(*post); + let fuel_cost = if post_data.is_bound_projection_of(db, ante_data) { + 1 + } else { + builder.sequent_fuel_cost(db, *post, antecedent_constructor_depth) + }; + add_new_constraint(*post, available_fuel, fuel_cost); } } - for (new_constraint, history) in new_constraints { + for (new_constraint, (available_fuel, fuel_cost)) in new_constraints { self.add_assignment( db, builder, new_constraint.when_true(), source_order, - true, - history, + Some(fuel_cost), + available_fuel, )?; } From 70acda33d669fe42830d9f015633e9bb23f5c4c9 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 14 Jul 2026 12:14:33 -0700 Subject: [PATCH 060/222] [ty] Narrow TypeGuard keyword arguments, remove invalid-type-guard-call (#26809) Closes astral-sh/ty#3991. ## Summary Stop emitting the `invalid-type-guard-call` diagnostic; a `TypeIs`/`TypeGuard` call without a narrowable target is valid and should simply have no narrowing effect. There's no indication in the typing spec or conformance suite that non-narrowing calls to type-guard functions should be an error, and this rule unnecessarily restricts flexible runtime use of such functions, which may not need to apply a static-analysis narrowing in all cases. Retain the rule for now with `LintStatus::removed` so existing configuration references receive a friendlier `unknown-rule` warning clarifying that the rule has been removed. (When we go to stable we should fully remove the rule.) Narrow explicit keyword arguments when they bind to the first non-`self`/`cls` formal parameter, including bound methods, unbound methods, class methods, and matching overloads. Never narrow any other parameter. ## Test plan mdtests cover keyword reordering, `TypeIs` and `TypeGuard`, literals/starred calls, omitted defaulted targets, multiple matching overloads, and bound/unbound/class-method calls. --- crates/ty/docs/rules.md | 161 +++++++----------- crates/ty_python_core/src/place.rs | 16 +- .../lint_docs/invalid-type-guard-call.md | 33 ---- .../resources/mdtest/narrow/type_guards.md | 60 +++++-- .../src/types/diagnostic.rs | 9 +- .../src/types/infer/builder.rs | 70 +------- .../src/types/infer/builder/typeguard.rs | 106 ++++++++++++ ty.schema.json | 10 -- 8 files changed, 235 insertions(+), 230 deletions(-) delete mode 100644 crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-call.md create mode 100644 crates/ty_python_semantic/src/types/infer/builder/typeguard.rs diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index bd9e003dd3..cd2b7ca475 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -118,7 +118,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -201,7 +201,7 @@ value = unknown # ty: ignore[unresolved-reference] Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -760,7 +760,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax] Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -795,7 +795,7 @@ def my_function() -> int: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -977,7 +977,7 @@ t[3] # error Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -1178,7 +1178,7 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1236,7 +1236,7 @@ C.instance_only_var = 56 # error Default level: error · Added in 0.0.33 · Related issues · -View source +View source @@ -1623,7 +1623,7 @@ This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](h Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1676,7 +1676,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1920,7 +1920,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -2107,7 +2107,7 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2740,7 +2740,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2832,7 +2832,7 @@ NewAlias = TypeAliasType(get_name(), int) # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2962,53 +2962,6 @@ b: Annotated[int] # error [type expressions]: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions -## `invalid-type-guard-call` - - -Default level: error · -Added in 0.0.1-alpha.11 · -Related issues · -View source - - - -**What it does** - - -Checks for type guard function calls without a valid target. - -**Why is this bad?** - - -The first non-keyword non-variadic argument to a type guard function -is its target and must map to a symbol. - -Starred (`is_str(*a)`), literal (`is_str(42)`) and other non-symbol-like -expressions are invalid as narrowing targets. - -**Examples** - - -```toml -[environment] -python-version = "3.13" -``` - -```python -from typing import TypeIs - - -def is_int(value: object = object()) -> TypeIs[int]: - return isinstance(value, int) - - -# no positional narrowing target -is_int() # error - -# narrowing target passed by keyword -is_int(value=1) # error -``` - ## `invalid-type-guard-definition` @@ -3072,7 +3025,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -3116,7 +3069,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3173,7 +3126,7 @@ V = TypeVar("V", list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -3215,7 +3168,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -3251,7 +3204,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -3294,7 +3247,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -3524,7 +3477,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3555,7 +3508,7 @@ func() # error Default level: ignore · Added in 0.0.41 · Related issues · -View source +View source @@ -3614,7 +3567,7 @@ class ExplicitChild(Parent): Default level: ignore · Added in 0.0.45 · Related issues · -View source +View source @@ -3653,7 +3606,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -3692,7 +3645,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3768,7 +3721,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3797,7 +3750,7 @@ for i in 34: # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3825,7 +3778,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -3862,7 +3815,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -3899,7 +3852,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3930,7 +3883,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3961,7 +3914,7 @@ f(x=1) # error Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4039,7 +3992,7 @@ A()[0] # error Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4085,7 +4038,7 @@ from module import a # error Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -4117,7 +4070,7 @@ html.parser # error Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4154,7 +4107,7 @@ print(x) # error Default level: warn · Added in 0.0.60 · Related issues · -View source +View source @@ -4229,7 +4182,7 @@ def test() -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4264,7 +4217,7 @@ cast(int, f()) # error Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -4302,7 +4255,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -4346,7 +4299,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4381,7 +4334,7 @@ static_assert(int(2.0 * 3.0) == 6) # error Default level: warn · Added in 0.0.39 · Related issues · -View source +View source @@ -4432,7 +4385,7 @@ Consider using [`functools.total_ordering`][total_ordering] instead, which does Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4466,7 +4419,7 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -4506,7 +4459,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4536,7 +4489,7 @@ f("foo") # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4575,7 +4528,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4633,7 +4586,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -4677,7 +4630,7 @@ class C(Generic[T]): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4706,7 +4659,7 @@ reveal_type(1) # revealed: Literal[1] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4737,7 +4690,7 @@ f(x=1, y=2) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4770,7 +4723,7 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -4845,7 +4798,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4874,7 +4827,7 @@ import foo # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4949,7 +4902,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5045,7 +4998,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5078,7 +5031,7 @@ A() + A() # error Default level: warn · Added in 0.0.21 · Related issues · -View source +View source @@ -5277,7 +5230,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_core/src/place.rs b/crates/ty_python_core/src/place.rs index 409f1ae221..5e1c034742 100644 --- a/crates/ty_python_core/src/place.rs +++ b/crates/ty_python_core/src/place.rs @@ -713,10 +713,18 @@ impl<'db, 'a> PossiblyNarrowedPlacesBuilder<'db, 'a> { // Under the current narrowing semantics, we only ever use the first two positional // arguments: argument 0 for most narrowing calls, and argument 1 for unbound - // TypeGuard/TypeIs methods (e.g. `C.f(C(), x)`). - // This set is only a conservative upper bound, so if later positional arguments ever - // become narrowable we can widen this scan again. - for argument in expr_call.arguments.args.iter().take(2) { + // TypeGuard/TypeIs methods (e.g. `C.f(C(), x)`). TypeGuard and TypeIs calls can also + // narrow an explicit keyword argument. We don't know which keyword maps to the target + // parameter while building the semantic index, so include every explicit keyword here. + // This set is only a conservative upper bound. + for argument in expr_call.arguments.args.iter().take(2).chain( + expr_call + .arguments + .keywords + .iter() + .filter(|keyword| keyword.arg.is_some()) + .map(|keyword| &keyword.value), + ) { if let Some(place_expr) = PlaceExpr::try_from_expr(argument) { if let Some(place) = self.places.place_id((&place_expr).into()) { places.insert(place); diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-call.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-call.md deleted file mode 100644 index 957c2082fc..0000000000 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-guard-call.md +++ /dev/null @@ -1,33 +0,0 @@ -## What it does - -Checks for type guard function calls without a valid target. - -## Why is this bad? - -The first non-keyword non-variadic argument to a type guard function -is its target and must map to a symbol. - -Starred (`is_str(*a)`), literal (`is_str(42)`) and other non-symbol-like -expressions are invalid as narrowing targets. - -## Examples - -```toml -[environment] -python-version = "3.13" -``` - -```python -from typing import TypeIs - - -def is_int(value: object = object()) -> TypeIs[int]: - return isinstance(value, int) - - -# no positional narrowing target -is_int() # error - -# narrowing target passed by keyword -is_int(value=1) # error -``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md index 0b28b4d785..4ed3de2dd9 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md @@ -140,11 +140,11 @@ Methods narrow the first positional argument after `self` or `cls` from typing import TypeGuard class C: - def f(self, x: object) -> TypeGuard[str]: + def f(self, x: object, other: object = object()) -> TypeGuard[str]: return True @classmethod - def g(cls, x: object) -> TypeGuard[int]: + def g(cls, x: object, other: object = object()) -> TypeGuard[int]: return True def h( @@ -156,7 +156,7 @@ class C: def j(cls) -> TypeGuard[int]: # error: [invalid-type-guard-definition] "`TypeGuard` function must have a parameter to narrow" return True -def _(x: object): +def _(x: object, other: object): if C().f(x): reveal_type(x) # revealed: str if C.f(C(), x): @@ -165,9 +165,18 @@ def _(x: object): reveal_type(x) # revealed: int if C().g(x): reveal_type(x) # revealed: int - if C().h(): # error: [invalid-type-guard-call] "Type guard call does not have a target" + if C().f(other=other, x=x): + reveal_type(x) # revealed: str + reveal_type(other) # revealed: object + if C.f(C(), other=other, x=x): + reveal_type(x) # revealed: str + reveal_type(other) # revealed: object + if C.g(other=other, x=x): + reveal_type(x) # revealed: int + reveal_type(other) # revealed: object + if C().h(): pass - if C.j(): # error: [invalid-type-guard-call] "Type guard call does not have a target" + if C.j(): pass ``` @@ -250,30 +259,53 @@ def g(a: Literal["foo", "bar"]) -> TypeIs[Literal["foo"]]: return False ``` -## Invalid calls +## Calls ```py -from typing import Any +from typing import Any, Literal, overload from typing_extensions import TypeGuard, TypeIs -def f(a: object) -> TypeGuard[str]: +def f(a: object, other: object = object()) -> TypeGuard[str]: + return True + +def g(a: object, other: object = object()) -> TypeIs[int]: + return True + +def defaulted(a: object = object(), other: object = object()) -> TypeIs[int]: return True -def g(a: object) -> TypeIs[int]: +@overload +def overloaded(a: object, mode: Literal[True]) -> TypeIs[int]: ... +@overload +def overloaded(a: object, mode: Literal[False]) -> TypeIs[int]: ... +def overloaded(a: object, mode: bool) -> TypeIs[int]: return True -def _(d: Any): +def _(d: Any, guarded: object, narrowed: object, other: object, mode: bool): if f(): # error: [missing-argument] "No argument provided for required parameter `a` of function `f`" ... if g(*d): pass - if f("foo"): # TODO: error: [invalid-type-guard-call] - ... + if f("foo"): ... - if g(a=d): # error: [invalid-type-guard-call] "Type guard call does not have a target" - ... + if f(other=other, a=guarded): + reveal_type(guarded) # revealed: str + reveal_type(other) # revealed: object + + if g(other=other, a=narrowed): + reveal_type(narrowed) # revealed: int + reveal_type(other) # revealed: object + + if defaulted(other=other): + reveal_type(other) # revealed: object + + if defaulted(): + pass + + if overloaded(mode=mode, a=narrowed): + reveal_type(narrowed) # revealed: int ``` ## Narrowing diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index d903bf34cf..9a2736b1cc 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -686,10 +686,13 @@ declare_lint! { } declare_lint! { - #[doc = include_str!("../../resources/lint_docs/invalid-type-guard-call.md")] + /// Type guard calls without a narrowing target are valid and have no narrowing effect. pub(crate) static INVALID_TYPE_GUARD_CALL = { - summary: "detects type guard function calls that has no narrowing effect", - status: LintStatus::stable("0.0.1-alpha.11"), + summary: "detects type guard function calls that have no narrowing effect", + status: LintStatus::removed( + "0.0.60", + "Type guard calls without a narrowing target are valid and have no narrowing effect.", + ), default_level: Level::Error, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e271cd6c8d..7e60e627c8 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -53,12 +53,11 @@ use crate::types::diagnostic::{ GeneratorMismatchKind, INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, INVALID_LEGACY_TYPE_VARIABLE, INVALID_NEWTYPE, INVALID_PARAMSPEC, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, - INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, - POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_SUBMODULE, UNDEFINED_REVEAL, - UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, - UNUSED_AWAITABLE, hint_if_stdlib_attribute_exists_on_other_versions, - report_attempted_protocol_instantiation, report_bad_dunder_delattr_call, - report_bad_dunder_delete_call, report_call_to_abstract_method, + INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_MISSING_IMPLICIT_CALL, + POSSIBLY_MISSING_SUBMODULE, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, + UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, + hint_if_stdlib_attribute_exists_on_other_versions, report_attempted_protocol_instantiation, + report_bad_dunder_delattr_call, report_bad_dunder_delete_call, report_call_to_abstract_method, report_cannot_pop_required_field_on_typed_dict, report_invalid_assignment, report_invalid_class_match_pattern, report_invalid_exception_caught, report_invalid_exception_cause, report_invalid_exception_raised, @@ -130,7 +129,7 @@ use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, NodeWithScopeRef, Sc use ty_python_core::symbol::{ScopedSymbolId, Symbol}; use ty_python_core::{ ApplicableConstraints, EnclosingSnapshotResult, EvaluationMode, SemanticIndex, Truthiness, - place_table, unpack::UnpackPosition, + unpack::UnpackPosition, }; use ty_python_core::{ExpressionNodeKey, Statement}; @@ -153,6 +152,7 @@ mod type_call; mod type_expression; mod type_form; mod typed_dict; +mod typeguard; mod typevar; use super::comparisons::{self, BinaryComparisonVisitor}; @@ -8395,7 +8395,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let db = self.db(); - let scope = self.scope(); let return_ty = bindings.return_type(db); let return_ty = match collection_initializer_class { Some(collection_class @ (KnownClass::List | KnownClass::Set)) @@ -8413,60 +8412,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => return_ty, }; - let find_narrowed_place = |argument_index: usize| match arguments.args.get(argument_index) { - None => { - // This branch looks extraneous, especially in the face of `missing-arguments`. - // However, that lint won't be able to catch this: - // - // ```python - // def f(v: object = object()) -> TypeIs[int]: ... - // - // if f(): ... - // ``` - // - // TODO: Will this report things that is actually fine? - if let Some(builder) = self - .context - .report_lint(&INVALID_TYPE_GUARD_CALL, arguments) - { - builder.into_diagnostic("Type guard call does not have a target"); - } - None - } - Some(expr) => match PlaceExpr::try_from_expr(expr) { - Some(place_expr) => place_table(db, scope).place_id(&place_expr), - None => None, - }, - }; - - let narrowed_argument_index = || { - bindings - .single_element() - .and_then(|binding| { - binding - .signature_type - .as_function_literal() - .or_else(|| binding.callable_type.as_function_literal()) - .map(|function| { - usize::from( - function.has_implicit_receiver(db) && binding.bound_type.is_none(), - ) - }) - }) - .unwrap_or(0) - }; - - match return_ty { - Type::TypeIs(type_is) => match find_narrowed_place(narrowed_argument_index()) { - Some(place) => type_is.bind(db, scope, place), - None => return_ty, - }, - Type::TypeGuard(type_guard) => match find_narrowed_place(narrowed_argument_index()) { - Some(place) => type_guard.bind(db, scope, place), - None => return_ty, - }, - _ => return_ty, - } + typeguard::bind_type_guard_return_type(db, self.scope(), return_ty, &bindings, arguments) } fn infer_starred_expression( diff --git a/crates/ty_python_semantic/src/types/infer/builder/typeguard.rs b/crates/ty_python_semantic/src/types/infer/builder/typeguard.rs new file mode 100644 index 0000000000..15bed91ee7 --- /dev/null +++ b/crates/ty_python_semantic/src/types/infer/builder/typeguard.rs @@ -0,0 +1,106 @@ +use ruff_python_ast as ast; +use ty_python_core::place::PlaceExpr; +use ty_python_core::place_table; +use ty_python_core::scope::ScopeId; + +use crate::Db; +use crate::types::Type; +use crate::types::call::{Binding, Bindings}; + +pub(super) fn bind_type_guard_return_type<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + return_ty: Type<'db>, + bindings: &Bindings<'db>, + arguments: &ast::Arguments, +) -> Type<'db> { + let narrowed_argument_index = || { + bindings + .single_element() + .and_then(|binding| { + binding + .signature_type + .as_function_literal() + .or_else(|| binding.callable_type.as_function_literal()) + .map(|function| { + usize::from( + function.has_implicit_receiver(db) && binding.bound_type.is_none(), + ) + }) + }) + .unwrap_or(0) + }; + + let find_narrowed_place = || { + // Use the call binding to find the argument that maps to the first parameter a type + // guard can narrow. This supports keyword arguments without falling back to a later + // parameter when the target is defaulted. + let matched_narrowed_argument_index = bindings.single_element().and_then(|binding| { + let has_implicit_receiver = binding + .signature_type + .as_function_literal() + .or_else(|| binding.callable_type.as_function_literal()) + .is_some_and(|function| function.has_implicit_receiver(db)); + let bound_argument_offset = usize::from(binding.bound_type.is_some()); + let narrowed_parameter_index = + usize::from(bound_argument_offset > 0 || has_implicit_receiver); + let narrowed_argument_index = |overload: &Binding<'db>| { + overload + .argument_matches() + .iter() + .enumerate() + .skip(bound_argument_offset) + .find_map(|(argument_index, matched_argument)| { + matched_argument + .parameters + .iter() + .any(|parameter| parameter.index == narrowed_parameter_index) + .then_some(argument_index - bound_argument_offset) + }) + }; + let mut matching_overloads = binding.matching_overloads(); + let (_, first_overload) = matching_overloads.next()?; + let first_argument_index = narrowed_argument_index(first_overload); + + Some( + if matching_overloads + .all(|(_, overload)| narrowed_argument_index(overload) == first_argument_index) + { + first_argument_index + } else { + None + }, + ) + }); + + let argument = match matched_narrowed_argument_index { + Some(Some(argument_index)) => arguments.iter_source_order().nth(argument_index), + // The target parameter was omitted, so there is no expression to narrow. + Some(None) => None, + // Preserve positional behavior when there isn't a unique callable binding whose + // parameter mapping we can use. + None => arguments + .args + .get(narrowed_argument_index()) + .map(ast::ArgOrKeyword::from), + }?; + if argument.is_variadic() { + return None; + } + + let place_expr = PlaceExpr::try_from_expr(argument.value())?; + place_table(db, scope).place_id(&place_expr) + }; + + match return_ty { + Type::TypeIs(type_is) => match find_narrowed_place() { + Some(place) => type_is.bind(db, scope, place), + None => return_ty, + }, + Type::TypeGuard(type_guard) => match find_narrowed_place() { + Some(place) => type_guard.bind(db, scope, place), + None => return_ty, + }, + _ => return_ty, + } +} diff --git a/ty.schema.json b/ty.schema.json index 530c80cad3..bbfc408b27 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -997,16 +997,6 @@ } ] }, - "invalid-type-guard-call": { - "title": "detects type guard function calls that has no narrowing effect", - "description": "## What it does\n\nChecks for type guard function calls without a valid target.\n\n## Why is this bad?\n\nThe first non-keyword non-variadic argument to a type guard function\nis its target and must map to a symbol.\n\nStarred (`is_str(*a)`), literal (`is_str(42)`) and other non-symbol-like\nexpressions are invalid as narrowing targets.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeIs\n\n\ndef is_int(value: object = object()) -> TypeIs[int]:\n return isinstance(value, int)\n\n\n# no positional narrowing target\nis_int() # error\n\n# narrowing target passed by keyword\nis_int(value=1) # error\n```", - "default": "error", - "oneOf": [ - { - "$ref": "#/definitions/Level" - } - ] - }, "invalid-type-guard-definition": { "title": "detects malformed type guard functions", "description": "## What it does\n\nChecks for type guard functions without\na first non-self-like non-keyword-only non-variadic parameter.\n\n## Why is this bad?\n\nType narrowing functions must accept at least one positional argument\n(non-static methods must accept another in addition to `self`/`cls`).\n\nExtra parameters/arguments are allowed but do not affect narrowing.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.13\"\n```\n\n```python\nfrom typing import TypeIs\n\n\n# no parameter\ndef f() -> TypeIs[int]: # error\n return True\n\n\n# no positional arguments allowed\ndef f(*, v: object) -> TypeIs[int]: # error\n return True\n\n\n# expected variadic arguments\ndef f(*args: object) -> TypeIs[int]: # error\n return True\n\n\nclass C:\n # only positional argument is `self`\n def f(self) -> TypeIs[int]: # error\n return True\n```", From bfe70b869f32a2ed9a3217945682db97fc3b7de3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 13:31:17 -0700 Subject: [PATCH 061/222] [ty] Support type[Protocol] (#26649) ## Summary This PR adds general support for `type[P]`, where `P` is a class-backed protocol. During review of #26566, we talked through two different questions. 1. Which class objects can inhabit `type[P]`? 2. Which attributes can be accessed through a value already typed as `type[P]`? For the former, a class object `X` can satisfy `type[P]` when: - `X` is a subtype of or assignable to `type`. - The modeled instance type of `X` satisfies `P`. - Every mutable `ClassVar` declared on `P` is available on `X` as an invariant read/write attribute. - Every method declared on `P` is available on `X` as a covariant read-only attribute with its unbound signature. For attribute lookup, we implement sound behavior in this PR, with a separate follow-up change (https://github.com/astral-sh/ruff/pull/26661) to prototype the unsound behavior used in other type checkers that exposes instance attributes declared on `P` through a value of type `type[P]` (even though a valid inhabitant is not required to define those attributes on the class object): ```python from typing import Protocol class P(Protocol): value: int class C: def __init__(self) -> None: self.value = 1 cls: type[P] = C reveal_type(cls()) # P reveal_type(cls.value) # int ``` The assignment is valid because `C()` satisfies `P`. The lookup of `cls.value` is intentionally unsound (`C.value` does not necessarily exist at runtime) but matches other type checkers. --- crates/ruff_benchmark/benches/ty.rs | 2 +- .../resources/mdtest/class/super.md | 10 + .../resources/mdtest/protocols.md | 419 ++++++++++++++++-- ...ol_cl\342\200\246_(288988036f34ddcf).snap" | 2 +- .../resources/mdtest/type_of/generics.md | 7 +- crates/ty_python_semantic/src/types.rs | 38 +- .../src/types/attribute_write.rs | 12 +- crates/ty_python_semantic/src/types/bool.rs | 1 + .../src/types/bound_super.rs | 3 + .../ty_python_semantic/src/types/callable.rs | 3 + .../src/types/diagnostic.rs | 1 + .../ty_python_semantic/src/types/display.rs | 14 + .../ty_python_semantic/src/types/generics.rs | 36 +- .../src/types/ide_support.rs | 41 +- .../src/types/infer/builder.rs | 1 + .../types/infer/builder/type_expression.rs | 34 +- .../ty_python_semantic/src/types/instance.rs | 47 +- .../src/types/list_members.rs | 16 + crates/ty_python_semantic/src/types/narrow.rs | 14 +- .../src/types/protocol_class.rs | 109 +++++ .../ty_python_semantic/src/types/relation.rs | 65 ++- .../src/types/set_theoretic.rs | 24 + .../src/types/subclass_of.rs | 112 ++++- 23 files changed, 889 insertions(+), 122 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index b213c0aedf..8b403f1647 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1856,7 +1856,7 @@ fn attrs(criterion: &mut Criterion) { max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 102, + 103, ); bench_project(&benchmark, criterion); diff --git a/crates/ty_python_semantic/resources/mdtest/class/super.md b/crates/ty_python_semantic/resources/mdtest/class/super.md index 8726a368e8..323b4f3b46 100644 --- a/crates/ty_python_semantic/resources/mdtest/class/super.md +++ b/crates/ty_python_semantic/resources/mdtest/class/super.md @@ -664,6 +664,16 @@ reveal_type(super(B, object)) super(object, object()).__class__ ``` +An inhabitant of `type[Protocol]` may be either a nominal subclass of the protocol or a structural +implementation, so the protocol's presence in the class's MRO is unknown: + +```py +class StructuralProtocol(typing.Protocol): ... + +def protocol_super(cls: type[StructuralProtocol]): + reveal_type(super(StructuralProtocol, cls)) # revealed: , Unknown> +``` + Not all objects valid in a class's bases list are valid as the first argument to `super()`. For example, it's valid to inherit from `typing.ChainMap`, but it's not valid as the first argument to `super()`. diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 6af552cdff..c35abac23d 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -432,7 +432,7 @@ And as a corollary, `type[MyProtocol]` can also be called: ```py def f(x: type[MyProtocol]): - reveal_type(x()) # revealed: @Todo(type[T] for protocols) + reveal_type(x()) # revealed: MyProtocol ``` ## Members of a protocol @@ -898,8 +898,8 @@ class ExplicitSubclass(HasXWithDefault): ... reveal_type(ExplicitSubclass.x) # revealed: int def f(arg: HasXWithDefault): - # TODO: should emit `[unresolved-reference]` and reveal `Unknown` - reveal_type(type(arg).x) # revealed: int + # `arg` may be an implicit subtype that does not define `x` on its class object. + type(arg).x # error: [unresolved-attribute] ``` Assignments in a class body of a protocol -- of any kind -- are not permitted by ty unless the @@ -2961,7 +2961,7 @@ class Foo(Protocol): def method(self) -> str: ... def f(x: Foo): - reveal_type(type(x).method) # revealed: def method(self, /) -> str + reveal_type(type(x).method) # revealed: (self, /) -> str class Bar: def __init__(self): @@ -4368,7 +4368,7 @@ def _(r: Recursive): reveal_type(r.t) # revealed: tuple[int, tuple[str, Recursive]] reveal_type(r.callable1) # revealed: (int, /) -> Recursive reveal_type(r.callable2) # revealed: (Recursive, /) -> int - reveal_type(r.subtype_of) # revealed: @Todo(type[T] for protocols) + reveal_type(r.subtype_of) # revealed: type[Recursive] reveal_type(r.generic) # revealed: GenericC[Recursive] reveal_type(r.method(r)) # revealed: Recursive reveal_type(r.nested) # revealed: Recursive | ((Recursive, tuple[Recursive, Recursive], /) -> Recursive) @@ -4723,11 +4723,17 @@ Where `P` is a protocol type, a class object `N` can be said to inhabit the type - All method members on `P` exist on the class object `N` - Instantiating `N` creates an object that would satisfy the protocol `P` -Currently meta-protocols are not fully supported by ty, but we try to keep false positives to a -minimum in the meantime. +Ordinary instance attributes are required only on the object constructed by `N`, so they are not +available through a value of type `type[P]`. Class variables and methods are available because every +inhabitant of `type[P]` must provide them on the class object itself. + +```toml +[environment] +python-version = "3.12" +``` ```py -from typing import Protocol, ClassVar +from typing import Any, ClassVar, Protocol, Self from ty_extensions import static_assert from ty_extensions._internal import TypeOf, is_assignable_to, is_subtype_of @@ -4737,39 +4743,398 @@ class Foo(Protocol): def method(self) -> bytes: ... def _(f: type[Foo]): - reveal_type(f) # revealed: type[@Todo(type[T] for protocols)] - - # TODO: we should emit `unresolved-attribute` here: although we would accept this for a - # nominal class, we would see any class `N` as inhabiting `Foo` if it had an implicit - # instance attribute `x`, and implicit instance attributes are rarely bound on the class - # object. - reveal_type(f.x) # revealed: @Todo(type[T] for protocols) - - # TODO: should be `str` - reveal_type(f.y) # revealed: @Todo(type[T] for protocols) + reveal_type(f) # revealed: type[Foo] + f.x # error: [unresolved-attribute] + f.x = 1 # error: [invalid-assignment] + reveal_type(f.y) # revealed: str f.y = "foo" # fine + f.y = b"bad" # error: [invalid-assignment] + reveal_type(f.method) # revealed: (self, /) -> bytes + reveal_type(f()) # revealed: Foo +``` - # TODO: should be `Callable[[Foo], bytes]` - reveal_type(f.method) # revealed: @Todo(type[T] for protocols) +Both a particular class object, represented by `TypeOf[C]`, and an arbitrary subclass of `C`, +represented by `type[C]`, are checked structurally. `Bar` fails the protocol requirements, while +`Baz` satisfies them. +```py class Bar: ... -# TODO: these should pass -static_assert(not is_assignable_to(type[Bar], type[Foo])) # error: [static-assert-error] -static_assert(not is_assignable_to(TypeOf[Bar], type[Foo])) # error: [static-assert-error] +static_assert(not is_assignable_to(type[Bar], type[Foo])) +static_assert(not is_assignable_to(TypeOf[Bar], type[Foo])) +static_assert(not is_subtype_of(type[Bar], type[Foo])) +static_assert(not is_subtype_of(TypeOf[Bar], type[Foo])) class Baz: - x: int y: ClassVar[str] = "foo" + def __init__(self) -> None: + self.x = 1 def method(self) -> bytes: return b"foo" static_assert(is_assignable_to(type[Baz], type[Foo])) static_assert(is_assignable_to(TypeOf[Baz], type[Foo])) +static_assert(is_subtype_of(type[Baz], type[Foo])) +static_assert(is_subtype_of(TypeOf[Baz], type[Foo])) +``` -# TODO: these should pass -static_assert(is_subtype_of(type[Baz], type[Foo])) # error: [static-assert-error] -static_assert(is_subtype_of(TypeOf[Baz], type[Foo])) # error: [static-assert-error] +As stated above, a class object must construct instances that satisfy `Foo` in order to inhabit +`type[Foo]`. The type of instances a class is considered to construct respects the `__call__` of its +metaclass. `Factory` constructs `Baz` instances (and itself has the necessary attributes to satisfy +the classvar/method portion of `Foo`), so it inhabits `type[Foo]`. `BadFactory` constructs `object`, +which does not satisfy `Foo`, so it cannot inhabit `type[Foo]`. + +```py +class FactoryMeta(type): + def __call__(self) -> Baz: + return Baz() + +class Factory(metaclass=FactoryMeta): + y: ClassVar[str] = "foo" + def method(self) -> bytes: + return b"foo" + +static_assert(is_assignable_to(TypeOf[Factory], type[Foo])) + +class BadFactoryMeta(type): + def __call__(self) -> object: + return object() + +class BadFactory(Baz, metaclass=BadFactoryMeta): ... + +static_assert(not is_assignable_to(TypeOf[BadFactory], type[Foo])) +``` + +Even when construction returns a `Foo`, the class object itself must provide the required class +variable and method (the instance attribute is not required). + +```py +class MissingClassVar(metaclass=FactoryMeta): + def method(self) -> bytes: + return b"foo" + +static_assert(not is_assignable_to(type[MissingClassVar], type[Foo])) + +class MissingMethod(metaclass=FactoryMeta): + y: ClassVar[str] = "foo" + +static_assert(not is_assignable_to(type[MissingMethod], type[Foo])) +``` + +Conversely, compatible class members are not enough if construction produces an object without the +required instance attribute. + +```py +class MissingInstanceAttribute: + y: ClassVar[str] = "foo" + def method(self) -> bytes: + return b"foo" + +static_assert(not is_assignable_to(type[MissingInstanceAttribute], type[Foo])) +``` + +A static method can have the right signature on an instance while lacking the unbound signature +required on the class object. + +```py +class StaticMethod: + y: ClassVar[str] = "foo" + def __init__(self) -> None: + self.x = 1 + @staticmethod + def method() -> bytes: + return b"foo" + +static_assert(not is_assignable_to(type[StaticMethod], type[Foo])) +``` + +Static methods and class methods declared by a protocol are checked on the candidate class object. +They can be provided by the candidate's metaclass. + +```py +class DecoratedMethods(Protocol): + @staticmethod + def static(value: int) -> str: ... + @classmethod + def class_(cls, value: int) -> str: ... + +class DecoratedMethodsImpl: + @staticmethod + def static(value: int) -> str: + return str(value) + @classmethod + def class_(cls, value: int) -> str: + return str(value) + +static_assert(is_assignable_to(TypeOf[DecoratedMethodsImpl], type[DecoratedMethods])) + +class DecoratedMethodsMeta(type): + @staticmethod + def static(value: int) -> str: + return str(value) + @classmethod + def class_(cls, value: int) -> str: + return str(value) + def __call__(self) -> DecoratedMethodsImpl: + return DecoratedMethodsImpl() + +class MetaclassOnlyDecoratedMethods(metaclass=DecoratedMethodsMeta): ... + +static_assert(is_assignable_to(TypeOf[MetaclassOnlyDecoratedMethods], type[DecoratedMethods])) +``` + +It is not enough for the constructed instance to acquire callables with matching signatures. + +```py +def decorated_method(value: int) -> str: + return str(value) + +class InstanceOnlyDecoratedMethods: + def __init__(self) -> None: + self.static = decorated_method + self.class_ = decorated_method + +static_assert(not is_assignable_to(TypeOf[InstanceOnlyDecoratedMethods], type[DecoratedMethods])) + +def _(cls: type[DecoratedMethods]) -> None: + reveal_type(cls.static) # revealed: (value: int) -> str + reveal_type(cls.class_) # revealed: (value: int) -> str +``` + +`Self` in a class method is bound to the class object being checked. + +```py +class SelfFactory(Protocol): + @classmethod + def make(cls) -> Self: ... + +class SelfFactoryImpl: + @classmethod + def make(cls) -> Self: + return cls() + +static_assert(is_assignable_to(TypeOf[SelfFactoryImpl], type[SelfFactory])) +``` + +A `@property` declaration requires a readable attribute on the constructed instance, but does not +require the implementation to use `@property` or guarantee that the attribute exists on the class +object. + +```py +class PropertyProtocol(Protocol): + @property + def value(self) -> int: ... + +class PropertyImpl: + def __init__(self) -> None: + self.value = 1 + +static_assert(is_assignable_to(TypeOf[PropertyImpl], type[PropertyProtocol])) + +class MissingProperty: ... + +static_assert(not is_assignable_to(TypeOf[MissingProperty], type[PropertyProtocol])) + +def _(cls: type[PropertyProtocol]) -> None: + cls.value # error: [unresolved-attribute] +``` + +Protocol and abstract class objects are accepted as inhabitants of `type[Foo]`. This is +intentionally more permissive than the typing spec, which requires a concrete class. + +```py +from abc import ABC, ABCMeta, abstractmethod + +class AbstractFoo(ABC): + x: int + y: ClassVar[str] = "foo" + @abstractmethod + def method(self) -> bytes: ... + +static_assert(is_assignable_to(TypeOf[Foo], type[Foo])) +static_assert(is_assignable_to(TypeOf[AbstractFoo], type[Foo])) +``` + +A structural implementation can use any subclass of `type` as its metaclass, so `type[Foo]` is not +limited to the protocol class's own metaclass. + +```py +static_assert(is_subtype_of(type[Foo], type)) +static_assert(not is_subtype_of(type[Foo], ABCMeta)) +``` + +`type[Any]` is assignable to `type[Foo]` but is not a subtype of it. + +```py +static_assert(is_assignable_to(type[Any], type[Foo])) +static_assert(not is_subtype_of(type[Any], type[Foo])) +``` + +An ordinary instance attribute does not satisfy a `ClassVar` requirement on another meta-protocol. + +```py +class InstanceAttributeProtocol(Protocol): + value: int + +class ClassVariableProtocol(Protocol): + value: ClassVar[int] + +static_assert(not is_assignable_to(type[InstanceAttributeProtocol], type[ClassVariableProtocol])) +``` + +A metaclass data descriptor takes precedence over an otherwise compatible unbound instance method. + +```py +class HidingMeta(type): + @property + def method(cls) -> int: + return 1 + +class HiddenMethod(metaclass=HidingMeta): + y: ClassVar[str] = "foo" + def __init__(self) -> None: + self.x = 1 + def method(self) -> bytes: + return b"foo" + +static_assert(not is_assignable_to(TypeOf[HiddenMethod], type[Foo])) +``` + +## Generic meta-protocols + +Generic protocol arguments are preserved by structural matching, member lookup, and construction. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to + +class GenericFoo[T](Protocol): + value: T + def get(self) -> T: ... + +class IntFoo: + def __init__(self) -> None: + self.value = 1 + def get(self) -> int: + return self.value + +class GenericFooImpl[T]: + def __init__(self, value: T) -> None: + self.value = value + def get(self) -> T: + return self.value + +static_assert(is_assignable_to(type[IntFoo], type[GenericFoo[int]])) +static_assert(not is_assignable_to(type[IntFoo], type[GenericFoo[str]])) + +def _(f: type[GenericFoo[int]]) -> None: + reveal_type(f.get) # revealed: (self, /) -> int + reveal_type(f()) # revealed: GenericFoo[int] +``` + +Inference derives the protocol argument from exact class objects, generic aliases, and parameters +already annotated as `type[GenericFoo[T]]`. + +```py +def infer_meta_protocol[T](cls: type[GenericFoo[T]]) -> T: + raise NotImplementedError + +reveal_type(infer_meta_protocol(IntFoo)) # revealed: int +reveal_type(infer_meta_protocol(GenericFooImpl[int])) # revealed: int + +def _(f: type[GenericFoo[int]]) -> None: + reveal_type(infer_meta_protocol(f)) # revealed: int +``` + +For a covariant protocol, inference combines the specializations contributed by each class object in +a union. + +```py +class Producer[T](Protocol): + def get(self) -> T: ... + +def infer_producer[T](cls: type[Producer[T]]) -> T: + raise NotImplementedError + +def _(flag: bool) -> None: + cls = GenericFooImpl[int] if flag else GenericFooImpl[str] + reveal_type(infer_producer(cls)) # revealed: int | str +``` + +## Generic substitution of `type[Protocol]` + +Passing `type[P]` through a generic identity function preserves its structural meaning, including +inside a union. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, TypeVar + +class P(Protocol): + value: int + +class GenericP[T](Protocol): + value: T + +T_identity = TypeVar("T_identity") + +def class_identity(cls: type[T_identity]) -> type[T_identity]: + return cls + +def _(cls: type[P]) -> None: + preserved: type[P] = class_identity(cls) + +def _(cls: type[P] | type[int]) -> None: + preserved: type[P] | type[int] = class_identity(cls) +``` + +The protocol class object is also accepted by the identity function as an inhabitant of `type[P]`. + +```py +reveal_type(class_identity(P)) # revealed: type[P] +``` + +Generic protocol arguments are also preserved through the identity function. + +```py +def _(cls: type[GenericP[int]]) -> None: + preserved: type[GenericP[int]] = class_identity(cls) + wrong: type[GenericP[str]] = class_identity(cls) # error: [invalid-assignment] +``` + +The same substitution occurs when `type[P]` appears in a generic callable signature such as +`classmethod`. + +```py +def predicate(cls: type[P]) -> bool: + return True + +classmethod(predicate) +``` + +## Meta-types of protocol intersections + +Calling `type()` on an intersection retains each positive class constraint. + +```py +from typing import Protocol + +class RuntimeProtocol(Protocol): + def get(self) -> object: ... + +def _(value: RuntimeProtocol) -> None: + if isinstance(value, int): + reveal_type(type(value)) # revealed: type[RuntimeProtocol] & type[int] ``` ## Regression test for `ClassVar` members in stubs diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl\342\200\246_(288988036f34ddcf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl\342\200\246_(288988036f34ddcf).snap" index 5f07b59018..83895eae63 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl\342\200\246_(288988036f34ddcf).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Calls_to_protocol_cl\342\200\246_(288988036f34ddcf).snap" @@ -37,7 +37,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 22 | 23 | reveal_type(SubclassOfGenericProtocol[int]()) # revealed: SubclassOfGenericProtocol[int] 24 | def f(x: type[MyProtocol]): -25 | reveal_type(x()) # revealed: @Todo(type[T] for protocols) +25 | reveal_type(x()) # revealed: MyProtocol ``` # Diagnostics diff --git a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md index 68609209c9..e05a6b94b7 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md +++ b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md @@ -451,7 +451,7 @@ var: type[C[int]] = C[int] var: type[C[int]] = D[int] # error: [invalid-assignment] "Object of type `` is not assignable to `type[C[int]]`" ``` -However, generic `Protocol` classes are still TODO: +Generic protocol meta-types preserve their specialization and use structural assignability: ```py from typing import Protocol @@ -459,11 +459,10 @@ from typing import Protocol class Proto[U](Protocol): def some_method(self): ... -# TODO: should be error: [invalid-assignment] -var: type[Proto[int]] = C[int] +var: type[Proto[int]] = C[int] # error: [invalid-assignment] def _(p: type[Proto[int]]): - reveal_type(p) # revealed: type[@Todo(type[T] for protocols)] + reveal_type(p) # revealed: type[Proto[int]] ``` ## Generic `@final` classes diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index af437963e2..6724deb183 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -2002,6 +2002,7 @@ impl<'db> Type<'db> { Type::SubclassOf(subclass_of) => match subclass_of.subclass_of() { SubclassOfInner::Class(_) => true, + SubclassOfInner::Protocol(_) => true, SubclassOfInner::Dynamic(dynamic) => Type::Dynamic(dynamic).is_hintable(db), SubclassOfInner::TypeVar(tvar) => Type::TypeVar(tvar).is_hintable(db), }, @@ -2865,7 +2866,12 @@ impl<'db> Type<'db> { ); let own_class = match self { - Type::SubclassOf(subclass_of) => subclass_of.subclass_of().into_class(db), + Type::SubclassOf(subclass_of) => match subclass_of.subclass_of() { + SubclassOfInner::Protocol(protocol) => { + protocol.class_origin().map(|origin| *origin) + } + subclass_of => subclass_of.into_class(db), + }, _ => self.to_class_type(db), }; let own_class_attr = own_class.map(|class| class.own_class_member(db, None, name).inner); @@ -4777,6 +4783,10 @@ impl<'db> Type<'db> { Binding::single(self, Signature::dynamic(Type::Dynamic(dynamic_type))).into() } SubclassOfInner::Class(class) => self.constructor_bindings(db, class), + SubclassOfInner::Protocol(protocol) => protocol.class_origin().map_or_else( + || Binding::single(self, Signature::dynamic(Type::unknown())).into(), + |origin| self.constructor_bindings(db, *origin), + ), SubclassOfInner::TypeVar(tvar) => { let constructor_instance_type = Type::TypeVar(tvar); let bindings = match tvar.typevar(db).bound_or_constraints(db) { @@ -6296,7 +6306,10 @@ impl<'db> Type<'db> { } Type::AlwaysTruthy | Type::AlwaysFalsy => KnownClass::Type.to_instance(db), Type::BoundSuper(_) => KnownClass::Super.to_class_literal(db), - Type::ProtocolInstance(protocol) => protocol.to_meta_type(db), + // Class-member lookup on a protocol instance must use the protocol's nominal class. + // The structural `type[Protocol]` view is exposed by `dunder_class` and explicit + // `type[Protocol]` annotations instead. + Type::ProtocolInstance(protocol) => protocol.to_nominal_meta_type(db), // `TypedDict` instances are instances of `dict` at runtime, but its important that we // understand a more specific meta type in order to correctly handle `__getitem__`. Type::TypedDict(typed_dict) => match typed_dict { @@ -6313,20 +6326,24 @@ impl<'db> Type<'db> { /// Get the type of the `__class__` attribute of this type. /// - /// For most types, this is equivalent to the meta type of this type. For `TypedDict` types, - /// this returns `type[dict[str, object]]` instead, because inhabitants of a `TypedDict` are - /// instances of `dict` at runtime. + /// For most types, this is equivalent to the meta type of this type. `TypedDict` types return + /// `type[dict[str, object]]`, because their inhabitants are instances of `dict` at runtime. + /// Class-backed protocols return their structural `type[Protocol]` view. #[must_use] pub(crate) fn dunder_class(self, db: &'db dyn Db) -> Type<'db> { - if self.is_typed_dict() { - return KnownClass::Dict + match self { + Type::Union(union) => union.map(db, |element| element.dunder_class(db)), + Type::Intersection(intersection) => intersection + .try_dunder_class(db) + .unwrap_or_else(|| self.to_meta_type(db)), + Type::ProtocolInstance(protocol) => protocol.to_meta_type(db), + Type::TypedDict(_) => KnownClass::Dict .to_specialized_class_type(db, &[KnownClass::Str.to_instance(db), Type::object()]) .map(Type::from) // Guard against user-customized typesheds with a broken `dict` class - .unwrap_or_else(Type::unknown); + .unwrap_or_else(Type::unknown), + _ => self.to_meta_type(db), } - - self.to_meta_type(db) } #[must_use] @@ -7185,6 +7202,7 @@ impl<'db> Type<'db> { Self::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() { SubclassOfInner::Dynamic(_) => None, SubclassOfInner::Class(class) => class.type_definition(db), + SubclassOfInner::Protocol(protocol) => protocol.class_origin()?.type_definition(db), SubclassOfInner::TypeVar(bound_typevar) => Some(TypeDefinition::TypeVar( bound_typevar.typevar(db).definition(db)?, )), diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index 6015f7f3e5..bbbc8d6894 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -272,7 +272,17 @@ pub(super) fn attribute_write_requirement<'db>( instance_attribute_write_requirement(db, object_ty, attribute) } - Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { + Type::SubclassOf(subclass_of) => subclass_of + .meta_write_requirement(db, attribute) + .map_or_else( + || class_attribute_write_requirement(db, object_ty, attribute), + |(write_ty, qualifiers)| AttributeWriteRequirement::ProtocolMember { + write_ty, + qualifiers, + }, + ), + + Type::ClassLiteral(..) | Type::GenericAlias(..) => { class_attribute_write_requirement(db, object_ty, attribute) } diff --git a/crates/ty_python_semantic/src/types/bool.rs b/crates/ty_python_semantic/src/types/bool.rs index d9734d1b73..b9be571ab5 100644 --- a/crates/ty_python_semantic/src/types/bool.rs +++ b/crates/ty_python_semantic/src/types/bool.rs @@ -268,6 +268,7 @@ impl<'db> Type<'db> { SubclassOfInner::Class(class) => { Type::from(class).try_bool_impl(db, allow_short_circuit, visitor)? } + SubclassOfInner::Protocol(_) => Truthiness::Ambiguous, SubclassOfInner::TypeVar(bound_typevar) => Type::TypeVar(bound_typevar) .try_bool_impl(db, allow_short_circuit, visitor)?, } diff --git a/crates/ty_python_semantic/src/types/bound_super.rs b/crates/ty_python_semantic/src/types/bound_super.rs index 43eed5e91c..ef054c4571 100644 --- a/crates/ty_python_semantic/src/types/bound_super.rs +++ b/crates/ty_python_semantic/src/types/bound_super.rs @@ -626,6 +626,9 @@ impl<'db> BoundSuperType<'db> { None, )?) } + // `type[Protocol]` is structural: an inhabitant need not inherit from the protocol + // class, so its MRO cannot be recovered from the protocol's nominal origin. + SubclassOfInner::Protocol(_) => SuperOwnerKind::Dynamic(DynamicType::Unknown), SubclassOfInner::Dynamic(dynamic) => SuperOwnerKind::Dynamic(dynamic), SubclassOfInner::TypeVar(bound_typevar) => { let typevar = bound_typevar.typevar(db); diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index 5e0d3107c5..d01a6d259f 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -152,6 +152,9 @@ impl<'db> Type<'db> { // TODO: This is unsound so in future we can consider an opt-in option to disable it. Type::SubclassOf(subclass_of_ty) => match subclass_of_ty.subclass_of() { SubclassOfInner::Class(class) => Some(class.into_callable(db)), + SubclassOfInner::Protocol(protocol) => protocol + .class_origin() + .map(|origin| (*origin).into_callable(db)), SubclassOfInner::TypeVar(tvar) => match tvar.typevar(db).bound_or_constraints(db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { let upcast_callables = bound diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 9a2736b1cc..14c2836e16 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2815,6 +2815,7 @@ pub(crate) fn report_undeclared_protocol_member( }) => return true, Type::SubclassOf(subclass_of) => match subclass_of.subclass_of() { SubclassOfInner::Class(class) => class, + SubclassOfInner::Protocol(_) => return true, SubclassOfInner::Dynamic(DynamicType::Any) => return true, SubclassOfInner::Dynamic(_) | SubclassOfInner::TypeVar(_) => return false, }, diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 31def01cfe..c49ec37434 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -1061,6 +1061,15 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'db> { write!(f.with_type(Type::Dynamic(dynamic)), "{dynamic}")?; f.write_char(']') } + SubclassOfInner::Protocol(protocol) => { + f.with_type(KnownClass::Type.to_class_literal(self.db)) + .write_str("type")?; + f.write_char('[')?; + Type::ProtocolInstance(protocol) + .display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + f.write_char(']') + } SubclassOfInner::TypeVar(bound_typevar) => { f.set_invalid_type_annotation(); f.with_type(KnownClass::Type.to_class_literal(self.db)) @@ -2763,6 +2772,11 @@ impl<'db> FmtDetailed<'db> for DisplaySubclassOfGroup<'db> { Type::Dynamic(dynamic).representation(self.db, self.settings.singleline()); join.entry(&rep); } + SubclassOfInner::Protocol(protocol) => { + let rep = Type::ProtocolInstance(protocol) + .representation(self.db, self.settings.singleline()); + join.entry(&rep); + } SubclassOfInner::TypeVar(bound_typevar) => { let rep = Type::TypeVar(bound_typevar) .representation(self.db, self.settings.singleline()); diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 397d5727bf..ec7a373004 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -31,9 +31,9 @@ use crate::types::visitor::{ use crate::types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarInstance, CallableType, CallableTypes, ClassLiteral, FindLegacyTypeVarsVisitor, IntersectionType, KnownClass, KnownInstanceType, - MaterializationKind, Type, TypeAliasType, TypeContext, TypeMapping, TypeVarBoundOrConstraints, - TypeVarKind, TypeVarVariance, UnionAccumulator, UnionType, binding_type, - infer_definition_types, inferred_declaration, + MaterializationKind, SubclassOfInner, Type, TypeAliasType, TypeContext, TypeMapping, + TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, UnionAccumulator, UnionType, + binding_type, infer_definition_types, inferred_declaration, }; use crate::{Db, FxIndexMap, FxOrderMap, FxOrderSet}; use ty_python_core::definition::{Definition, DefinitionKind}; @@ -2997,6 +2997,36 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } } + ( + Type::SubclassOf(formal_subclass), + actual @ (Type::ClassLiteral(_) + | Type::GenericAlias(_) + | Type::SubclassOf(_) + | Type::Union(_)), + ) if matches!(formal_subclass.subclass_of(), SubclassOfInner::Protocol(_)) => { + let SubclassOfInner::Protocol(protocol) = formal_subclass.subclass_of() else { + return Ok(()); + }; + let formal_protocol = Type::ProtocolInstance(protocol); + if let Type::Union(union) = actual { + for element in union.elements(self.db) { + self.infer_map_impl( + formal_protocol, + element.bindings(self.db).return_type(self.db), + polarity, + seen, + )?; + } + return Ok(()); + } + return self.infer_map_impl( + formal_protocol, + actual.bindings(self.db).return_type(self.db), + polarity, + seen, + ); + } + (Type::SubclassOf(subclass_of), ty) | (ty, Type::SubclassOf(subclass_of)) if subclass_of.is_type_var() => { diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 0ab5dca368..c5f8813b8f 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -9,7 +9,7 @@ use crate::types::constraints::ConstraintSetBuilder; use crate::types::signatures::{ParametersKind, Signature}; use crate::types::{ CallDunderError, CallableTypes, ClassBase, ClassLiteral, ClassType, KnownClass, KnownFunction, - KnownUnion, Type, TypeContext, + KnownUnion, SubclassOfInner, Type, TypeContext, }; use crate::{Db, DisplaySettings, HasDefinition, HasType, SemanticModel}; use itertools::Either; @@ -214,6 +214,18 @@ pub fn definitions_for_attribute<'db>( let db = model.db(); let name_str = attribute.attr.as_str(); + // A structural protocol meta-type still uses its nominal protocol declaration as the source + // location for go-to-definition, even though the origin is not a nominal upper bound. + let subclass_origin = |subclass_of: SubclassOfInner<'db>| { + let class = match subclass_of { + SubclassOfInner::Protocol(protocol) => protocol.class_origin().map(|origin| *origin), + subclass_of => subclass_of.into_class(db), + }?; + class + .static_class_literal(db) + .map(|(literal, _)| ClassLiteral::Static(literal)) + }; + let mut resolved = Vec::new(); // Determine the type of the LHS @@ -267,13 +279,12 @@ pub fn definitions_for_attribute<'db>( let class_literal = match lookup_type { Type::ClassLiteral(class_literal) => class_literal, - Type::SubclassOf(subclass) => match subclass.subclass_of().into_class(db) { - Some(cls) => match cls.static_class_literal(db) { - Some((lit, _)) => ClassLiteral::Static(lit), - None => continue, - }, - None => continue, - }, + Type::SubclassOf(subclass) => { + let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { + continue; + }; + class_literal + } _ => continue, }; @@ -291,13 +302,12 @@ pub fn definitions_for_attribute<'db>( if resolved.is_empty() && meta_type != lookup_type { let class_literal = match meta_type { Type::ClassLiteral(class_literal) => class_literal, - Type::SubclassOf(subclass) => match subclass.subclass_of().into_class(db) { - Some(cls) => match cls.static_class_literal(db) { - Some((lit, _)) => ClassLiteral::Static(lit), - None => continue, - }, - None => continue, - }, + Type::SubclassOf(subclass) => { + let Some(class_literal) = subclass_origin(subclass.subclass_of()) else { + continue; + }; + class_literal + } _ => continue, }; @@ -2061,6 +2071,7 @@ fn extract_class_literal<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option None, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 7e60e627c8..d3b93d59d4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9438,6 +9438,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "Attribute lookup on a dynamic `SubclassOf` type \ should always return a bound symbol" ), + SubclassOfInner::Protocol(_) => false, SubclassOfInner::TypeVar(_) => false, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 10301bdca8..75b82c338f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1226,12 +1226,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let infer_type_argument = |builder: &mut Self, slice: &ast::Expr| { let slice_ty = builder.infer_type_expression(slice); - if matches!(slice_ty, Type::ProtocolInstance(_)) { - return SubclassOfType::from( - builder.db(), - todo_type!("type[T] for protocols").expect_dynamic(), - ); - } SubclassOfType::try_from_instance(builder.db(), slice_ty).unwrap_or_else(|| { match slice_ty { Type::Callable(_) => invalid_type_argument(builder, slice), @@ -1282,12 +1276,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { _ => self.infer_subclass_of_type_expression(parameters), }, value_ty @ Type::ClassLiteral(class_literal) => { - if class_literal.is_protocol(self.db()) { - SubclassOfType::from( - self.db(), - todo_type!("type[T] for protocols").expect_dynamic(), - ) - } else if class_literal.is_tuple(self.db()) { + if class_literal.is_tuple(self.db()) { let class_type = self .infer_tuple_type_expression(subscript) .map(|tuple_type| tuple_type.to_class_type(self.db())) @@ -1298,13 +1287,20 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Some(generic_context) => { let db = self.db(); let specialize = &|types: &[Option>]| { - SubclassOfType::from( - db, - class_literal.apply_specialization(db, |_| { - generic_context - .specialize_partial(db, types.iter().copied()) - }), - ) + let class = class_literal.apply_specialization(db, |_| { + generic_context + .specialize_partial(db, types.iter().copied()) + }); + if class_literal.is_protocol(db) { + match Type::instance(db, class) { + Type::ProtocolInstance(protocol) => { + SubclassOfType::from_protocol(protocol) + } + _ => SubclassOfType::from(db, class), + } + } else { + SubclassOfType::from(db, class) + } }; self.infer_explicit_callable_specialization( subscript, diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 1393fed646..e440537f2d 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -579,6 +579,33 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { result.or(db, self.constraints, || structurally_satisfied) } + /// Return whether a class-object type inhabits `type[protocol]`. + /// + /// The effective constructor return must satisfy the instance protocol, while the class object + /// itself must provide the protocol's `ClassVar` and unbound method requirements. Ordinary + /// instance attributes and properties are intentionally not required on the class object. + /// + /// `meta_ty` must be a class-object type represented by `ClassLiteral`, `SubclassOf`, or + /// `GenericAlias`. Other types are not necessarily subtypes of `type` or callable, and could + /// therefore incorrectly satisfy this check through an `Unknown` constructor return type. + pub(super) fn check_meta_type_satisfies_protocol( + &self, + db: &'db dyn Db, + meta_ty: Type<'db>, + protocol: ProtocolInstanceType<'db>, + ) -> ConstraintSet<'db, 'c> { + debug_assert!(matches!( + meta_ty, + Type::ClassLiteral(_) | Type::SubclassOf(_) | Type::GenericAlias(_) + )); + + let constructed_ty = meta_ty.bindings(db).return_type(db); + self.check_type_pair(db, constructed_ty, Type::ProtocolInstance(protocol)) + .and(db, self.constraints, || { + self.check_meta_protocol_members(db, constructed_ty, meta_ty, protocol) + }) + } + pub(super) fn check_nominal_instance_pair( &self, db: &'db dyn Db, @@ -809,10 +836,18 @@ impl<'db> ProtocolInstanceType<'db> { } } - /// Return the meta-type of this protocol-instance type. + /// Return the class that defines this protocol, if it is class-backed. + pub(super) const fn class_origin(self) -> Option> { + match self.inner { + Protocol::FromClass(class) => Some(class), + Protocol::Synthesized(_) => None, + } + } + + /// Return the structural meta-type of this protocol-instance type. pub(super) fn to_meta_type(self, db: &'db dyn Db) -> Type<'db> { match self.inner { - Protocol::FromClass(class) => SubclassOfType::from(db, class), + Protocol::FromClass(_) => SubclassOfType::from_protocol(self), // TODO: we can and should do better here. // @@ -831,6 +866,14 @@ impl<'db> ProtocolInstanceType<'db> { } } + /// Return the nominal meta-type used for internal class-member lookup on a protocol instance. + pub(super) fn to_nominal_meta_type(self, db: &'db dyn Db) -> Type<'db> { + match self.inner { + Protocol::FromClass(class) => SubclassOfType::from(db, *class), + Protocol::Synthesized(_) => self.to_meta_type(db), + } + } + /// Return `true` if this protocol is a supertype of `object`. /// /// This indicates that the protocol represents the same set of possible runtime objects diff --git a/crates/ty_python_semantic/src/types/list_members.rs b/crates/ty_python_semantic/src/types/list_members.rs index c8bf2dc885..7e4f4c1270 100644 --- a/crates/ty_python_semantic/src/types/list_members.rs +++ b/crates/ty_python_semantic/src/types/list_members.rs @@ -249,6 +249,22 @@ impl<'db> AllMembers<'db> { SubclassOfInner::Dynamic(_) => { self.extend_with_type(db, KnownClass::Type.to_instance(db)); } + SubclassOfInner::Protocol(protocol) => { + if let Some((class_literal, _)) = protocol + .class_origin() + .and_then(|origin| origin.static_class_literal(db)) + { + self.extend_with_class_members(db, ty, ClassLiteral::Static(class_literal)); + self.extend_with_synthetic_members( + db, + ty, + ClassLiteral::Static(class_literal), + ); + } + // A structural implementation can use any metaclass, so only members of + // `type` itself are guaranteed in addition to the protocol interface. + self.extend_with_type(db, KnownClass::Type.to_instance(db)); + } _ => { if let Some(class_type) = subclass_of_type.subclass_of().into_class(db) { if let Some((class_literal, _)) = class_type.static_class_literal(db) { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index ae72b1629f..66e84de0c3 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -466,6 +466,17 @@ impl ClassInfoConstraintFunction { // e.g. `isinstance(x, list[int])` fails at runtime. SubclassOfInner::Class(ClassType::Generic(_)) => None, SubclassOfInner::Dynamic(dynamic) => Some(Type::Dynamic(dynamic)), + // TODO: This narrowing is not fully sound: + // - `type[protocol]` currently admits non-concrete classes, some of which are + // not valid runtime class-info arguments. + // - A class can inhabit `type[protocol]` because its metaclass constructs + // protocol-conforming objects even if its nominal instances do not conform. + SubclassOfInner::Protocol(protocol) => match self { + ClassInfoConstraintFunction::IsInstance => { + Some(Type::ProtocolInstance(protocol)) + } + ClassInfoConstraintFunction::IsSubclass => Some(classinfo), + }, SubclassOfInner::TypeVar(bound_typevar) => match self { ClassInfoConstraintFunction::IsSubclass => Some(classinfo), ClassInfoConstraintFunction::IsInstance => { @@ -3079,7 +3090,8 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { match subclass_of.subclass_of().with_transposed_type_var(db) { SubclassOfInner::Class(ClassType::NonGeneric(class)) => Some(class), SubclassOfInner::Class(ClassType::Generic(_)) - | SubclassOfInner::Dynamic(_) => None, + | SubclassOfInner::Dynamic(_) + | SubclassOfInner::Protocol(_) => None, SubclassOfInner::TypeVar(tvar) => { find_underlying_class(db, tvar.typevar(db).upper_bound(db)?) } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index cd3be7cb67..777748a973 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -320,6 +320,27 @@ impl<'db> ProtocolInterface<'db> { }) } + /// Returns the write requirement exposed through `type[Protocol]` lookup. + /// + /// Only members required on every class object that satisfies the meta-protocol are available. + /// Ordinary instance attributes are required on the constructed object instead. + pub(super) fn meta_write_requirement( + self, + db: &'db dyn Db, + receiver_ty: Type<'db>, + name: &str, + ) -> Option<(Option>, TypeQualifiers)> { + self.member_by_name(db, name).and_then(|member| { + Some(( + member + .meta_access(db)? + .write + .and_then(|write| write.bind_self(db, receiver_ty)), + member.qualifiers(), + )) + }) + } + /// Returns the callable signature exposed by instance access to a protocol's `__call__` /// method. /// @@ -360,6 +381,29 @@ impl<'db> ProtocolInterface<'db> { .unwrap_or_else(|| Type::object().member(db, name)) } + /// Looks up a member guaranteed to exist on every inhabitant of `type[Protocol]`. + /// + /// Methods retain their unbound signatures and `ClassVar`s retain their class-side types. + /// Properties are only required on the constructed instance, so they are undefined even when + /// the nominal protocol origin provides a property descriptor. + pub(super) fn meta_member( + self, + db: &'db dyn Db, + name: &str, + ) -> Option> { + self.member_by_name(db, name).and_then(|member| { + let read = member.meta_access(db)?.read; + Some(PlaceAndQualifiers { + place: read + .and_then(|read| read.resolve(db)) + .map(|read| Place::bound(read.ty())) + .unwrap_or(Place::Undefined) + .with_provenance(Provenance::from_definition(member.definition())), + qualifiers: member.qualifiers(), + }) + }) + } + pub(super) fn recursive_type_normalized_impl( self, db: &'db dyn Db, @@ -1057,6 +1101,13 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { } } + fn meta_access(&self, db: &'db dyn Db) -> Option> { + if self.has_todo_type() { + return None; + } + Some(self.capabilities(db).class) + } + fn has_todo_type(&self) -> bool { self.data .kind @@ -1806,6 +1857,64 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { result } + /// Checks the members that a class object must provide to inhabit `type[Protocol]`. + /// + /// Ordinary instance attributes and properties are deliberately absent from this check. They + /// are requirements on the object produced by constructing the class, not on the class object + /// itself. `ClassVar`s and methods are checked through class access; unlike ordinary protocol + /// matching, method access compares the unbound signature instead of checking only presence. + pub(super) fn check_meta_protocol_members( + &self, + db: &'db dyn Db, + instance_ty: Type<'db>, + meta_ty: Type<'db>, + protocol: ProtocolInstanceType<'db>, + ) -> ConstraintSet<'db, 'c> { + protocol + .interface(db) + .members(db) + .when_all(db, self.constraints, |member| { + let required = member.capabilities(db).class; + if required.read.is_none() && required.write.is_none() { + return self.always(); + } + + let result = if member.is_method() { + required.read.map_or_else( + || self.always(), + |required_ty| { + self.check_protocol_member_read( + db, + instance_ty, + meta_ty, + &member, + required_ty, + ProtocolMemberAccessMode::Class, + ) + }, + ) + } else { + self.type_satisfies_protocol_member_access( + db, + instance_ty, + meta_ty, + &member, + required, + ProtocolMemberAccessMode::Class, + ) + }; + + if let Some(context) = self.report_context() + && result.is_never_satisfied(db) + { + context.push(ErrorContext::ProtocolMemberIncompatible { + member_name: member.name.into(), + }); + } + result + }) + } + /// Compares either instance access or class access when relating two protocol members. /// /// Both members bind `Self` to the source protocol type; readable types are compared diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 1bd4332114..437b4e4763 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -2129,15 +2129,29 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // `Literal[]` is a subtype of `type[B]` if `C` is a subclass of `B`, // since `type[B]` describes all possible runtime subclasses of the class object `B`. (Type::ClassLiteral(source_cls), Type::SubclassOf(target_subclass_ty)) => { - target_subclass_ty - .subclass_of() - .into_class(db) - .map(|target_cls| { - self.check_class_pair(db, source_cls.default_specialization(db), target_cls) - }) - .unwrap_or_else(|| { - ConstraintSet::from_bool(self.constraints, self.is_eager_assignability()) - }) + match target_subclass_ty.subclass_of() { + SubclassOfInner::Protocol(target_protocol) => self + .check_meta_type_satisfies_protocol( + db, + Type::ClassLiteral(source_cls), + target_protocol, + ), + target => target + .into_class(db) + .map(|target_cls| { + self.check_class_pair( + db, + source_cls.default_specialization(db), + target_cls, + ) + }) + .unwrap_or_else(|| { + ConstraintSet::from_bool( + self.constraints, + self.is_eager_assignability(), + ) + }), + } } // Similarly, `` is assignable to `` (a generic-alias type) @@ -2160,15 +2174,25 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { ), (Type::GenericAlias(source_alias), Type::SubclassOf(target_subclass_ty)) => { - target_subclass_ty - .subclass_of() - .into_class(db) - .map(|target_cls| { - self.check_class_pair(db, ClassType::Generic(source_alias), target_cls) - }) - .unwrap_or_else(|| { - ConstraintSet::from_bool(self.constraints, self.is_eager_assignability()) - }) + match target_subclass_ty.subclass_of() { + SubclassOfInner::Protocol(target_protocol) => self + .check_meta_type_satisfies_protocol( + db, + Type::GenericAlias(source_alias), + target_protocol, + ), + target => target + .into_class(db) + .map(|target_cls| { + self.check_class_pair(db, ClassType::Generic(source_alias), target_cls) + }) + .unwrap_or_else(|| { + ConstraintSet::from_bool( + self.constraints, + self.is_eager_assignability(), + ) + }), + } } // This branch asks: given two types `type[T]` and `type[S]`, is `type[T]` a subtype of `type[S]`? @@ -2947,6 +2971,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | (Type::ClassLiteral(class_b), Type::SubclassOf(subclass_of_ty)) => { match subclass_of_ty.subclass_of() { SubclassOfInner::Dynamic(_) => self.never(), + SubclassOfInner::Protocol(_) => self.never(), SubclassOfInner::Class(class_a) => ConstraintSet::from_bool( self.constraints, !class_a.could_exist_in_mro_of_with_disjointness_checker( @@ -2963,6 +2988,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | (Type::GenericAlias(alias_b), Type::SubclassOf(subclass_of_ty)) => { match subclass_of_ty.subclass_of() { SubclassOfInner::Dynamic(_) => self.never(), + SubclassOfInner::Protocol(_) => self.never(), SubclassOfInner::Class(class_a) => ConstraintSet::from_bool( self.constraints, !class_a.could_exist_in_mro_of_with_disjointness_checker( @@ -2989,6 +3015,9 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { SubclassOfInner::Class(class) => { self.check_type_pair(db, class.metaclass_instance_type(db), other) } + SubclassOfInner::Protocol(_) => { + self.check_type_pair(db, KnownClass::Type.to_instance(db), other) + } SubclassOfInner::TypeVar(_) => unreachable!(), }, diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index 285055b042..cb4ce82969 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -927,6 +927,30 @@ impl<'db> IntersectionType<'db> { builder.build() } + /// Compute the `__class__` type when this intersection contains a positive class-backed + /// protocol constraint. + /// + /// Negative instance constraints are not transferred: an object not satisfying `P` does not + /// imply that other instances of its class cannot satisfy `P`. + pub(crate) fn try_dunder_class(self, db: &'db dyn Db) -> Option> { + if !self.iter_positive(db).any(|positive| { + matches!( + positive, + Type::ProtocolInstance(protocol) if protocol.class_origin().is_some() + ) + }) { + return None; + } + + Some( + self.iter_positive(db) + .fold(IntersectionBuilder::new(db), |builder, positive| { + builder.add_positive(positive.dunder_class(db)) + }) + .build(), + ) + } + pub(crate) fn map_with_boundness( self, db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 7e52944ab8..83460f2023 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -1,14 +1,13 @@ use crate::place::PlaceAndQualifiers; use crate::types::class::DynamicClassLiteral; use crate::types::constraints::ConstraintSet; -use crate::types::protocol_class::ProtocolClass; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; use crate::types::variance::VarianceInferable; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarIdentity, BoundTypeVarInstance, ClassLiteral, ClassType, DynamicType, FindLegacyTypeVarsVisitor, KnownClass, MaterializationKind, MemberLookupPolicy, - SpecialFormType, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, - TypedDictType, UnionType, todo_type, + ProtocolInstanceType, SpecialFormType, Type, TypeContext, TypeMapping, TypeQualifiers, + TypeVarBoundOrConstraints, TypeVarVariance, TypedDictType, UnionType, todo_type, }; use crate::{Db, FxOrderSet}; use ty_python_core::definition::Definition; @@ -52,12 +51,19 @@ impl<'db> SubclassOfType<'db> { Type::SubclassOf(Self { subclass_of }) } } - SubclassOfInner::Dynamic(_) | SubclassOfInner::TypeVar(_) => { - Type::SubclassOf(Self { subclass_of }) - } + SubclassOfInner::Dynamic(_) + | SubclassOfInner::Protocol(_) + | SubclassOfInner::TypeVar(_) => Type::SubclassOf(Self { subclass_of }), } } + /// Construct the meta-type of a class-backed protocol. + pub(super) const fn from_protocol(protocol: ProtocolInstanceType<'db>) -> Type<'db> { + Type::SubclassOf(Self { + subclass_of: SubclassOfInner::Protocol(protocol), + }) + } + /// Given the class object `T`, returns a [`Type`] instance representing `type[T]`. pub(crate) fn try_from_type(db: &'db dyn Db, ty: Type<'db>) -> Option> { let subclass_of = match ty { @@ -120,6 +126,25 @@ impl<'db> SubclassOfType<'db> { self.subclass_of } + /// Returns the effective write requirement exposed by `type[Protocol]` attribute lookup. + pub(super) fn meta_write_requirement( + self, + db: &'db dyn Db, + name: &str, + ) -> Option<(Option>, TypeQualifiers)> { + let SubclassOfInner::Protocol(protocol) = self.subclass_of else { + return None; + }; + protocol + .interface(db) + .meta_write_requirement(db, Type::ProtocolInstance(protocol), name) + .map(|(write_ty, mut qualifiers)| { + // `ClassVar` prohibits instance writes, not writes through the class object. + qualifiers.remove(TypeQualifiers::CLASS_VAR); + (write_ty, qualifiers) + }) + } + pub(crate) const fn is_dynamic(self) -> bool { // Unpack `self` so that we're forced to update this method if any more fields are added in the future. let Self { subclass_of } = self; @@ -163,6 +188,9 @@ impl<'db> SubclassOfType<'db> { visitor, )), }), + SubclassOfInner::Protocol(protocol) => protocol + .apply_type_mapping_impl(db, type_mapping, tcx, visitor) + .to_meta_type(db), SubclassOfInner::Dynamic(_) => match type_mapping { TypeMapping::Materialize(materialization_kind) => match materialization_kind { MaterializationKind::Top => KnownClass::Type.to_instance(db), @@ -172,7 +200,7 @@ impl<'db> SubclassOfType<'db> { }, SubclassOfInner::TypeVar(typevar) => { let mapped = typevar.apply_type_mapping_impl(db, type_mapping, visitor); - mapped.to_meta_type(db) + Self::try_from_instance(db, mapped).unwrap_or_else(|| mapped.to_meta_type(db)) } } } @@ -189,6 +217,9 @@ impl<'db> SubclassOfType<'db> { SubclassOfInner::Class(class) => { class.find_legacy_typevars_impl(db, binding_context, typevars, visitor); } + SubclassOfInner::Protocol(protocol) => { + protocol.find_legacy_typevars_impl(db, binding_context, typevars, visitor); + } SubclassOfInner::TypeVar(typevar) => { Type::TypeVar(typevar).find_legacy_typevars_impl( db, @@ -206,9 +237,16 @@ impl<'db> SubclassOfType<'db> { name: &str, policy: MemberLookupPolicy, ) -> Option> { + if let SubclassOfInner::Protocol(protocol) = self.subclass_of + && let Some(member) = protocol.interface(db).meta_member(db, name) + { + return Some(member); + } + let class_like = match self.subclass_of.with_transposed_type_var(db) { SubclassOfInner::Class(class) => Type::from(class), SubclassOfInner::Dynamic(dynamic) => Type::Dynamic(dynamic), + SubclassOfInner::Protocol(protocol) => Type::from(*protocol.class_origin()?), SubclassOfInner::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { None => unreachable!(), @@ -240,6 +278,7 @@ impl<'db> SubclassOfType<'db> { match self.subclass_of { SubclassOfInner::Class(class) => Type::instance(db, class), SubclassOfInner::Dynamic(dynamic_type) => Type::Dynamic(dynamic_type), + SubclassOfInner::Protocol(protocol) => Type::ProtocolInstance(protocol), SubclassOfInner::TypeVar(bound_typevar) => Type::TypeVar(bound_typevar), } } @@ -268,6 +307,9 @@ impl<'db> SubclassOfType<'db> { } SubclassOfInner::Class(class) => SubclassOfType::try_from_type(db, class.metaclass(db)) .unwrap_or(SubclassOfType::subclass_of_unknown()), + // Structural implementations of a protocol can have arbitrary metaclasses. The only + // guaranteed upper bound is therefore `type`, not the protocol origin's metaclass. + SubclassOfInner::Protocol(_) => KnownClass::Type.to_subclass_of(db), // For `type[T]` where `T` is a TypeVar, `with_transposed_type_var` transforms // the bounds from instance types to `type[]` types. For example, `type[T]` where // `T: A | B` becomes a TypeVar with bound `type[A] | type[B]`. The metatype is @@ -296,6 +338,7 @@ impl<'db> VarianceInferable<'db> for SubclassOfType<'db> { fn variance_of(self, db: &dyn Db, typevar: BoundTypeVarIdentity<'_>) -> TypeVarVariance { match self.subclass_of { SubclassOfInner::Class(class) => class.variance_of(db, typevar), + SubclassOfInner::Protocol(protocol) => protocol.variance_of(db, typevar), SubclassOfInner::Dynamic(_) | SubclassOfInner::TypeVar(_) => TypeVarVariance::Bivariant, } } @@ -309,6 +352,21 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { source: SubclassOfType<'db>, target: SubclassOfType<'db>, ) -> ConstraintSet<'db, 'c> { + if let SubclassOfInner::Protocol(target_protocol) = target.subclass_of { + return self.check_meta_type_satisfies_protocol( + db, + Type::SubclassOf(source), + target_protocol, + ); + } + if let SubclassOfInner::Protocol(source_protocol) = source.subclass_of { + return self.check_type_pair( + db, + Type::ProtocolInstance(source_protocol), + target.to_instance(db), + ); + } + match (source.subclass_of, target.subclass_of) { (SubclassOfInner::Dynamic(_), SubclassOfInner::Dynamic(_)) => { ConstraintSet::from_bool(self.constraints, !self.relation.is_subtyping()) @@ -333,6 +391,9 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { (SubclassOfInner::TypeVar(_), _) | (_, SubclassOfInner::TypeVar(_)) => { unreachable!() } + (SubclassOfInner::Protocol(_), _) | (_, SubclassOfInner::Protocol(_)) => { + unreachable!("protocol meta-types are handled above") + } } } } @@ -347,6 +408,14 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { left: SubclassOfType<'db>, right: SubclassOfType<'db>, ) -> ConstraintSet<'db, 'c> { + if matches!(left.subclass_of, SubclassOfInner::Protocol(_)) + || matches!(right.subclass_of, SubclassOfInner::Protocol(_)) + { + // Protocols are open structural types, so their meta-types can generally overlap with + // concrete class-object types and with other protocol meta-types. + return ConstraintSet::from_bool(self.constraints, false); + } + match (left.subclass_of, right.subclass_of) { (SubclassOfInner::Dynamic(_), _) | (_, SubclassOfInner::Dynamic(_)) => { ConstraintSet::from_bool(self.constraints, false) @@ -360,6 +429,9 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { (SubclassOfInner::TypeVar(_), _) | (_, SubclassOfInner::TypeVar(_)) => { unreachable!() } + (SubclassOfInner::Protocol(_), _) | (_, SubclassOfInner::Protocol(_)) => { + unreachable!("protocol meta-types are handled above") + } } } } @@ -368,7 +440,8 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// /// 1. A "subclass of a class": `type[C]` for any class object `C` /// 2. A "subclass of a dynamic type": `type[Any]`, `type[Unknown]` and `type[@Todo]` -/// 3. A "subclass of a type variable": `type[T]` for any type variable `T` +/// 3. A protocol meta-type: `type[P]` for a class-backed protocol `P` +/// 4. A "subclass of a type variable": `type[T]` for any type variable `T` /// /// In the long term, we may want to implement . /// Doing this would allow us to get rid of this enum, @@ -376,13 +449,14 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// rather than using the [`Type::SubclassOf`] variant at all; /// [`SubclassOfType`] would then be a simple wrapper around [`ClassType`]. /// -/// Note that this enum is similar to the [`super::ClassBase`] enum, -/// but does not include the `ClassBase::Protocol` and `ClassBase::Generic` variants -/// (`type[Protocol]` and `type[Generic]` are not valid types). +/// Note that this enum is similar to the [`super::ClassBase`] enum, but does not include the +/// `ClassBase::Protocol` and `ClassBase::Generic` special-form variants (`type[Protocol]` and +/// `type[Generic]` are not valid types). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] pub(crate) enum SubclassOfInner<'db> { Class(ClassType<'db>), Dynamic(DynamicType<'db>), + Protocol(ProtocolInstanceType<'db>), TypeVar(BoundTypeVarInstance<'db>), } @@ -401,7 +475,7 @@ impl<'db> SubclassOfInner<'db> { pub(crate) fn into_class(self, db: &'db dyn Db) -> Option> { match self { - Self::Dynamic(_) => None, + Self::Dynamic(_) | Self::Protocol(_) => None, Self::Class(class) => Some(class), Self::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db) { @@ -419,14 +493,14 @@ impl<'db> SubclassOfInner<'db> { pub(crate) const fn into_dynamic(self) -> Option> { match self { - Self::Class(_) | Self::TypeVar(_) => None, + Self::Class(_) | Self::Protocol(_) | Self::TypeVar(_) => None, Self::Dynamic(dynamic) => Some(dynamic), } } pub(crate) const fn into_type_var(self) -> Option> { match self { - Self::Class(_) | Self::Dynamic(_) => None, + Self::Class(_) | Self::Dynamic(_) | Self::Protocol(_) => None, Self::TypeVar(bound_typevar) => Some(bound_typevar), } } @@ -499,6 +573,9 @@ impl<'db> SubclassOfInner<'db> { class.recursive_type_normalized_impl(db, div, nested)?, )), Self::Dynamic(dynamic) => Some(Self::Dynamic(dynamic.recursive_type_normalized())), + Self::Protocol(protocol) => Some(Self::Protocol( + protocol.recursive_type_normalized_impl(db, div, nested)?, + )), Self::TypeVar(_) => Some(self), } } @@ -516,12 +593,6 @@ impl<'db> From> for SubclassOfInner<'db> { } } -impl<'db> From> for SubclassOfInner<'db> { - fn from(value: ProtocolClass<'db>) -> Self { - SubclassOfInner::Class(*value) - } -} - impl<'db> From> for SubclassOfInner<'db> { fn from(value: BoundTypeVarInstance<'db>) -> Self { SubclassOfInner::TypeVar(value) @@ -533,6 +604,7 @@ impl<'db> From> for Type<'db> { match value.subclass_of { SubclassOfInner::Class(class) => class.into(), SubclassOfInner::Dynamic(dynamic) => Type::Dynamic(dynamic), + SubclassOfInner::Protocol(protocol) => Type::ProtocolInstance(protocol), SubclassOfInner::TypeVar(bound_typevar) => Type::TypeVar(bound_typevar), } } From f74bcb19991569539adb54287bea0ff11e087d45 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 14 Jul 2026 13:55:55 -0700 Subject: [PATCH 062/222] [ty] Support class objects satisfying instance-method protocols (#26789) ## Summary Allow a class object to satisfy an instance-method protocol member through a directly accessible bound classmethod or staticmethod. This matches mypy and pyright behavior, fixes one conformance test assertion, and seems correct to me. Ecosystem looks good, too. Special dunder methods which are looked up on the meta-type still must exist on the meta-type to match a protocol; we add extra diagnostic context to clarify this rule when it applies. `Self` handling in protocols requires a fix so that we don't unsoundly allow a `Self` returning method on a class object to wrongly match a `Self` returning method on a protocol. ## Test plan Added mdtests. --- .../resources/mdtest/protocols.md | 315 ++++++++++++++++-- .../src/types/protocol_class.rs | 173 ++++++++-- .../src/types/relation_error.rs | 5 + 3 files changed, 439 insertions(+), 54 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index c35abac23d..88fe91a7dd 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2909,14 +2909,14 @@ static_assert(not is_assignable_to(Foo, SupportsFooMethod)) static_assert(is_assignable_to(Foo, SupportsFooAttr)) ``` -The reason for this is that some methods, such as dunder methods, are always looked up on the class -directly. If a class with an `__iter__` instance attribute satisfied the `Iterable` protocol, for -example, the `Iterable` protocol would not accurately describe the requirements Python has for a -class to be iterable at runtime. Allowing callable instance attributes to satisfy method members of -protocols would also make `issubclass()` narrowing of runtime-checkable protocols unsound, as the -`issubclass()` mechanism at runtime for protocols only checks whether a method is accessible on the -class object, not the instance. (Protocols with non-method members cannot be passed to -`issubclass()` at all at runtime.) +The reason for this is that some methods, such as Python's special methods, are always looked up on +the class directly. If a class with an `__iter__` instance attribute satisfied the `Iterable` +protocol, for example, the `Iterable` protocol would not accurately describe the requirements Python +has for a class to be iterable at runtime. Allowing callable instance attributes to satisfy method +members of protocols would also make `issubclass()` narrowing of runtime-checkable protocols +unsound, as the `issubclass()` mechanism at runtime for protocols only checks whether a method is +accessible on the class object, not the instance. (Protocols with non-method members cannot be +passed to `issubclass()` at all at runtime.) ```py from typing import Iterable, Any @@ -2930,26 +2930,6 @@ class Foo: static_assert(not is_assignable_to(Foo, Iterable[Any])) ``` -Because method members are always looked up on the meta-type of an object when testing assignability -and subtyping, we understand that `IterableClass` here is a subtype of `Iterable[int]` even though -`IterableClass.__iter__` has the wrong signature: - -```py -from typing import Iterator, Iterable -from ty_extensions import static_assert -from ty_extensions._internal import TypeOf, is_subtype_of - -class Meta(type): - def __iter__(self) -> Iterator[int]: - yield from range(42) - -class IterableClass(metaclass=Meta): - def __iter__(self) -> Iterator[str]: - yield from "abc" - -static_assert(is_subtype_of(TypeOf[IterableClass], Iterable[int])) -``` - Enforcing that members must always be available on the class also means that it is safe to access a method on `type[P]`, where `P` is a protocol class, just like it is generally safe to access a method on `type[C]` where `C` is a nominal class: @@ -3278,10 +3258,12 @@ parser: Parser = IntParser ## Class objects and `Self`-returning class-method protocol members When a class object is checked against a class-method protocol member, `Self` in the protocol -signature is bound to instances of the class object rather than to the class object itself: +signature names the class object. A class method that returns `Self` returns an instance and cannot +satisfy that requirement; a class method that returns `type[Self]` can satisfy a `type[C]` +candidate: ```py -from typing import Protocol +from typing import Protocol, TypeVar from typing_extensions import Self from ty_extensions import static_assert from ty_extensions._internal import TypeOf, is_assignable_to @@ -3300,8 +3282,168 @@ class BadFactory: def make(cls) -> int: return 1 -static_assert(is_assignable_to(TypeOf[Factory], FactoryProtocol)) +class ClassObjectFactory: + @classmethod + def make(cls) -> type[Self]: + return cls + +static_assert(not is_assignable_to(TypeOf[Factory], FactoryProtocol)) static_assert(not is_assignable_to(TypeOf[BadFactory], FactoryProtocol)) +static_assert(is_assignable_to(type[ClassObjectFactory], FactoryProtocol)) + +T = TypeVar("T", bound=FactoryProtocol) + +def exact_factory(value: T) -> T: + return value.make() + +exact_factory(Factory) # error: [invalid-argument-type] +exact_factory(ClassObjectFactory) # error: [invalid-argument-type] + +def _(factory: type[ClassObjectFactory]) -> None: + exact_factory(factory) +``` + +## Class objects and `Self`-returning instance-method protocol members + +A class object can satisfy a protocol with a regular instance-method member if the class object's +directly accessible member has a compatible bound signature. Class and static methods therefore +work, but a regular instance method does not: accessing it through the class produces an unbound +function rather than a method bound to the class object. If the protocol method returns `Self`, the +implementation must return the class object, not an instance of the class. + +```py +from typing import Protocol, TypeVar +from typing_extensions import Self +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_assignable_to + +class CopierProtocol(Protocol): + def copy(self) -> Self: ... + +class PlainCopierProtocol(Protocol): + def copy(self) -> str: ... + +class Copier: + def copy(self) -> Self: + return self + +class ClassCopier: + @classmethod + def copy(cls) -> Self: + return cls() + +class StaticCopier: + @staticmethod + def copy() -> "StaticCopier": + return StaticCopier() + +class ClassObjectCopier: + @classmethod + def copy(cls) -> type[Self]: + return cls + +class PlainClassCopier: + @classmethod + def copy(cls) -> str: + return "copy" + +class PlainStaticCopier: + @staticmethod + def copy() -> str: + return "copy" + +class CopierMeta(type): + def copy(cls) -> "BadDirectCopier": + return BadDirectCopier() + +class BadDirectCopier(metaclass=CopierMeta): + def copy(self, value: int) -> Self: + return self + +static_assert(is_assignable_to(Copier, CopierProtocol)) +static_assert(not is_assignable_to(TypeOf[Copier], CopierProtocol)) +static_assert(not is_assignable_to(TypeOf[ClassCopier], CopierProtocol)) +static_assert(not is_assignable_to(TypeOf[StaticCopier], CopierProtocol)) +static_assert(is_assignable_to(type[ClassObjectCopier], CopierProtocol)) +static_assert(is_assignable_to(TypeOf[PlainClassCopier], PlainCopierProtocol)) +static_assert(is_assignable_to(TypeOf[PlainStaticCopier], PlainCopierProtocol)) +# The metaclass method is compatible, but ordinary protocol methods describe direct access on the +# class object, where `BadDirectCopier.copy` is an incompatible unbound function. +static_assert(not is_assignable_to(TypeOf[BadDirectCopier], CopierProtocol)) + +T = TypeVar("T", bound=CopierProtocol) + +def exact_copy(value: T) -> T: + return value.copy() + +exact_copy(ClassCopier) # error: [invalid-argument-type] +exact_copy(StaticCopier) # error: [invalid-argument-type] +exact_copy(ClassObjectCopier) # error: [invalid-argument-type] + +def _(copier: type[ClassObjectCopier]) -> None: + exact_copy(copier) +``` + +## Class objects and dunder instance-method protocol members + +Special methods are looked up on the meta-type of an object when testing assignability and +subtyping, matching Python's special-method lookup. We therefore understand that `IterableClass` +here is a subtype of `Iterable[int]` even though `IterableClass.__iter__` has the wrong signature: + +```py +from typing import Iterable, Iterator +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_subtype_of + +class Meta(type): + def __iter__(self) -> Iterator[int]: + yield from range(42) + +class IterableClass(metaclass=Meta): + def __iter__(self) -> Iterator[str]: + yield from "abc" + +static_assert(is_subtype_of(TypeOf[IterableClass], Iterable[int])) + +class DirectIterable: + @classmethod + def __iter__(cls) -> Iterator[int]: + yield from range(42) + +iterable: Iterable[int] = DirectIterable # snapshot +``` + +```snapshot +error[invalid-assignment]: Object of type `` is not assignable to `Iterable[int]` + --> src/mdtest_snippet.py:20:11 + | +20 | iterable: Iterable[int] = DirectIterable # snapshot + | ------------- ^^^^^^^^^^^^^^ Incompatible value of type `` + | | + | Declared type + | +info: type `` is not assignable to protocol `Iterable[int]` +info: └── protocol member `__iter__` is not defined on type `` +info: └── special methods must be defined on the meta-type when matching a protocol +``` + +A custom dunder such as `__custom__` is an ordinary method: it is accessed directly on the class +object and does not use Python's special-method lookup. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_assignable_to + +class CustomProtocol(Protocol): + def __custom__(self, value: int) -> str: ... + +class Custom: + @classmethod + def __custom__(cls, value: int) -> str: + return str(value) + +static_assert(is_assignable_to(TypeOf[Custom], CustomProtocol)) ``` ## Subtyping of protocols with `@classmethod` or `@staticmethod` members @@ -4901,7 +5043,7 @@ def _(cls: type[DecoratedMethods]) -> None: reveal_type(cls.class_) # revealed: (value: int) -> str ``` -`Self` in a class method is bound to the class object being checked. +`Self` in a class method names the instance constructed by the class object being checked. ```py class SelfFactory(Protocol): @@ -5000,6 +5142,115 @@ class HiddenMethod(metaclass=HidingMeta): static_assert(not is_assignable_to(TypeOf[HiddenMethod], type[Foo])) ``` +## Meta-protocols satisfying instance-method protocols + +A value of type `type[P]` is itself a class object, so it can satisfy another protocol through its +directly accessible methods. An ordinary method on `P` remains unbound when accessed through +`type[P]`, while class and static methods are bound. Generic specialization and overloads are +preserved in either case. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Iterable, Iterator, Protocol, Self, overload + +class BoundMethod(Protocol): + def method(self, value: int) -> str: ... + +class InstanceMethodSource(Protocol): + def method(self, value: int) -> str: ... + +class UnboundMethod(Protocol): + def method(self, instance: InstanceMethodSource, /, value: int) -> str: ... + +class StaticMethodSource(Protocol): + @staticmethod + def method(value: int) -> str: ... + +class ClassMethodSource(Protocol): + @classmethod + def method(cls, value: int) -> str: ... + +def _( + instance_source: type[InstanceMethodSource], + static_source: type[StaticMethodSource], + class_source: type[ClassMethodSource], +) -> None: + reveal_type(instance_source.method) # revealed: (self, /, value: int) -> str + unbound: UnboundMethod = instance_source + bound: BoundMethod = instance_source # error: [invalid-assignment] + reveal_type(static_source.method) # revealed: (value: int) -> str + static_bound: BoundMethod = static_source + reveal_type(class_source.method) # revealed: (value: int) -> str + class_bound: BoundMethod = class_source + +class GenericBoundMethod[T](Protocol): + def method(self, value: T) -> T: ... + +class GenericStaticMethodSource[T](Protocol): + @staticmethod + def method(value: T) -> T: ... + +def _(source: type[GenericStaticMethodSource[int]]) -> None: + reveal_type(source.method) # revealed: (value: int) -> int + good: GenericBoundMethod[int] = source + bad: GenericBoundMethod[str] = source # error: [invalid-assignment] + +class OverloadedBoundMethod(Protocol): + @overload + def method(self, value: int) -> int: ... + @overload + def method(self, value: str) -> str: ... + +class OverloadedStaticMethodSource(Protocol): + @overload + @staticmethod + def method(value: int) -> int: ... + @overload + @staticmethod + def method(value: str) -> str: ... + +def _(source: type[OverloadedStaticMethodSource]) -> None: + reveal_type(source.method) # revealed: Overload[(value: int) -> int, (value: str) -> str] + overloaded: OverloadedBoundMethod = source + +class Copier(Protocol): + def copy(self) -> Self: ... + +class InstanceFactory(Protocol): + @classmethod + def copy(cls) -> Self: ... + +class ClassObjectFactory(Protocol): + @classmethod + def copy(cls) -> type[Self]: ... + +def _(instance_factory: type[InstanceFactory], class_object_factory: type[ClassObjectFactory]) -> None: + reveal_type(instance_factory.copy) # revealed: () -> InstanceFactory + bad: Copier = instance_factory # error: [invalid-assignment] + reveal_type(class_object_factory.copy) # revealed: () -> type[ClassObjectFactory] + good: Copier = class_object_factory + +class IterableSource(Protocol): + def __iter__(self) -> Iterator[int]: ... + +class CustomSource(Protocol): + @classmethod + def __custom__(cls, value: int) -> str: ... + +class CustomConsumer(Protocol): + def __custom__(self, value: int) -> str: ... + +def _(iterable_source: type[IterableSource], custom_source: type[CustomSource]) -> None: + reveal_type(iterable_source.__iter__) # revealed: (self, /) -> Iterator[int] + iterable: Iterable[int] = iterable_source # error: [invalid-assignment] + reveal_type(custom_source.__custom__) # revealed: (value: int) -> str + custom: CustomConsumer = custom_source +``` + ## Generic meta-protocols Generic protocol arguments are preserved by structural matching, member lookup, and construction. diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 777748a973..6ec685cd16 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -1053,6 +1053,121 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { ) } + /// Returns whether this member is dispatched through special-method lookup on the type. + /// + /// The names are the methods registered in CPython's `slotdefs` table or explicitly looked + /// up on the type by Python or its standard library. + fn uses_special_method_lookup(&self) -> bool { + matches!( + self.name, + "__abs__" + | "__add__" + | "__aenter__" + | "__aexit__" + | "__aiter__" + | "__and__" + | "__anext__" + | "__await__" + | "__bool__" + | "__buffer__" + | "__bytes__" + | "__call__" + | "__ceil__" + | "__complex__" + | "__contains__" + | "__copy__" + | "__del__" + | "__delattr__" + | "__delete__" + | "__delitem__" + | "__dir__" + | "__divmod__" + | "__enter__" + | "__eq__" + | "__exit__" + | "__float__" + | "__floor__" + | "__floordiv__" + | "__format__" + | "__fspath__" + | "__ge__" + | "__get__" + | "__getattr__" + | "__getattribute__" + | "__getitem__" + | "__getnewargs__" + | "__getnewargs_ex__" + | "__gt__" + | "__hash__" + | "__iadd__" + | "__iand__" + | "__ifloordiv__" + | "__ilshift__" + | "__imatmul__" + | "__imod__" + | "__imul__" + | "__index__" + | "__init__" + | "__instancecheck__" + | "__int__" + | "__invert__" + | "__ior__" + | "__ipow__" + | "__irshift__" + | "__isub__" + | "__iter__" + | "__itruediv__" + | "__ixor__" + | "__le__" + | "__len__" + | "__length_hint__" + | "__lshift__" + | "__lt__" + | "__matmul__" + | "__missing__" + | "__mod__" + | "__mul__" + | "__ne__" + | "__neg__" + | "__new__" + | "__next__" + | "__or__" + | "__pos__" + | "__pow__" + | "__radd__" + | "__rand__" + | "__rdivmod__" + | "__release_buffer__" + | "__replace__" + | "__repr__" + | "__reversed__" + | "__rfloordiv__" + | "__rlshift__" + | "__rmatmul__" + | "__rmod__" + | "__rmul__" + | "__ror__" + | "__round__" + | "__rpow__" + | "__rrshift__" + | "__rshift__" + | "__rsub__" + | "__rtruediv__" + | "__rxor__" + | "__set__" + | "__set_name__" + | "__setattr__" + | "__setitem__" + | "__sizeof__" + | "__str__" + | "__sub__" + | "__subclasscheck__" + | "__truediv__" + | "__trunc__" + | "__xor__" + ) + } + fn is_property(&self) -> bool { matches!(self.data.kind, ProtocolMemberKind::Property { .. }) } @@ -1068,8 +1183,9 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { /// Returns the accesses that a candidate value must provide for this member. /// /// A module-level callable can satisfy an ordinary or static method through direct member - /// access. A class object can likewise satisfy a class or static method. The member does not - /// also need to exist on the value's meta-type. + /// access. A class object can likewise satisfy a class, static, or ordinary instance method; + /// special instance methods instead use special-method lookup through the meta-type. Neither + /// case needs a separate class-side check for the same member. fn implementation_capabilities( &self, db: &'db dyn Db, @@ -1084,14 +1200,9 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { _, ProtocolMethodKind::Instance | ProtocolMethodKind::Static ) - ) | ( - Type::ClassLiteral(_), - ProtocolMemberKind::Method( - _, - ProtocolMethodKind::Class | ProtocolMethodKind::Static - ) ) - ) { + ) || (is_class_object_type(ty) && self.is_method()) + { ProtocolMemberCapabilities { class: ProtocolMemberAccess::NONE, ..capabilities @@ -1263,6 +1374,13 @@ fn property_set_type<'db>( property_set_member_type(db, property.setter(db)?)?.bind_self(db, receiver_ty) } +fn is_class_object_type(ty: Type<'_>) -> bool { + matches!( + ty, + Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) + ) +} + fn protocol_member_read_type<'db>( db: &'db dyn Db, ty: Type<'db>, @@ -1279,10 +1397,12 @@ fn protocol_member_read_type<'db>( return Some(ty); } - // PEP 544 matches module-level functions directly, without descriptor binding on `ModuleType`. + // Module-level functions and ordinary methods on class objects are matched through direct + // member access. Special instance methods still use special-method lookup on the meta-type. let place = if access == ProtocolMemberAccessMode::Instance && member.is_instance_method() && !matches!(ty, Type::ModuleLiteral(_)) + && (!is_class_object_type(ty) || member.uses_special_method_lookup()) { ty.invoke_descriptor_protocol( db, @@ -1625,12 +1745,12 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); }; - // For a method on a class object, `Self` names instances of that class: a - // `@classmethod` returning `Self` returns `Factory`, not `type[Factory]`. For a - // non-method member, `Self` names the object whose attribute is being checked, so a - // class object must stay a class object. - let non_method_self_binding_ty = ty.literal_fallback_instance(db).unwrap_or(ty); - let method_self_binding_ty = ty + // `Self` in a protocol member names the value satisfying the protocol. `Self` in a + // method on a class object names instances of that class: a `@classmethod` returning + // `Self` returns `Factory`, not `type[Factory]`. Keep the bindings separate so a method + // that returns an instance cannot satisfy a protocol that promises the class object. + let protocol_self_binding_ty = ty.literal_fallback_instance(db).unwrap_or(ty); + let implementation_self_binding_ty = ty .to_instance(db) .or_else(|| ty.literal_fallback_instance(db)) .unwrap_or(ty); @@ -1656,8 +1776,10 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .when_some_and(db, self.constraints, |callables| { self.check_callables_vs_callable( db, - &callables.map(|callable| callable.apply_self(db, method_self_binding_ty)), - required_callable.apply_self(db, method_self_binding_ty), + &callables.map(|callable| { + callable.apply_self(db, implementation_self_binding_ty) + }), + required_callable.apply_self(db, protocol_self_binding_ty), ) }) } else if member.is_instance_method() { @@ -1674,11 +1796,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { if callable.is_function_like(db) { self.check_callable_pair( db, - callable.bind_self(db, Some(method_self_binding_ty)), + callable.bind_self(db, Some(implementation_self_binding_ty)), protocol_bind_self( db, required_callable, - Some(method_self_binding_ty), + Some(protocol_self_binding_ty), ), ) } else { @@ -1696,11 +1818,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.check_type_pair( db, attribute_type, - Type::Callable(required_callable.apply_self(db, method_self_binding_ty)), + Type::Callable(required_callable.apply_self(db, protocol_self_binding_ty)), ) } else { required_ty - .bind_self(db, non_method_self_binding_ty) + .bind_self(db, protocol_self_binding_ty) .when_some_and(db, self.constraints, |required_ty| { let result = self.check_type_pair(db, attribute_type, required_ty); if let Some(context) = self.report_context() @@ -1820,6 +1942,13 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { ) .is_none(); if instance_read_missing || class_read_missing { + if instance_read_missing + && is_class_object_type(ty) + && member.is_instance_method() + && member.uses_special_method_lookup() + { + context.push(ErrorContext::ProtocolSpecialMethodNotDefinedOnMetaType); + } context.push(ErrorContext::ProtocolMemberNotDefined { member_name: member.name.into(), ty, diff --git a/crates/ty_python_semantic/src/types/relation_error.rs b/crates/ty_python_semantic/src/types/relation_error.rs index b9ed58f846..ce2602fdd6 100644 --- a/crates/ty_python_semantic/src/types/relation_error.rs +++ b/crates/ty_python_semantic/src/types/relation_error.rs @@ -136,6 +136,7 @@ pub(crate) enum ErrorContext<'db> { member_name: Name, ty: Type<'db>, }, + ProtocolSpecialMethodNotDefinedOnMetaType, ProtocolMemberIncompatible { member_name: Name, }, @@ -355,6 +356,10 @@ impl<'db> ErrorContext<'db> { "protocol member `{member_name}` is not defined on type `{}`", ty.display(db), ), + Self::ProtocolSpecialMethodNotDefinedOnMetaType => { + "special methods must be defined on the meta-type when matching a protocol" + .to_string() + } Self::ProtocolMemberIncompatible { member_name } => { format!("protocol member `{member_name}` is incompatible") } From c84e8eb7ba2ab68a7a61244d7411d0b855f90bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Tue, 14 Jul 2026 14:58:15 -0700 Subject: [PATCH 063/222] [ty] Refactor the syntax helpers that are used to parse field headers in docstrings (#26722) ## Summary This change refactors some syntax primitives such that they can be more easily shared between the existing Google-style docstring parser and the [upcoming NumPy-style docstring parser](https://github.com/astral-sh/ruff/pull/25924). The previous helpers combined delimiter scanning with Google-specific semantic interpretation. This change replaces them with two format-agnostic operations: - `split_once_at_top_level_colon` finds a field separator while ignoring colons inside brackets and quoted strings. - `split_trailing_parenthetical` returns the prefix and contents of a balanced trailing parenthesized group without assigning meaning to either. Each docstring parser is then responsible for layering semantics on top of those primitives. Google combines those operations to parse `name (type): description`, while the downstream NumPy parser uses them for both `name : type` fields and call-style return types like `tuple(str, int)`. ## Test Plan See included tests. --- .../ty_ide/src/docstring/document/google.rs | 206 +++++++++++++- .../ty_ide/src/docstring/document/syntax.rs | 256 +++++++++++++----- 2 files changed, 384 insertions(+), 78 deletions(-) diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 8c55f918d2..2a68ecf1d9 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -32,13 +32,14 @@ use std::cmp::Ordering; use indexmap::IndexMap; use ruff_python_stdlib::identifiers::is_identifier; +use ruff_python_trivia::Cursor; use ruff_text_size::{TextRange, TextSize}; use super::SectionKind; use super::preformatted::PreformattedBlockScanner; use super::syntax::{ - ParsedLine, container_block_end, parse_parenthesized_type, parsed_lines, - split_once_unbracketed_colon, + ParsedLine, consume_quoted_string, container_block_end, parsed_lines, + split_once_at_top_level_colon, split_trailing_parenthetical, }; /// Returns parameter documentation from recognized Google-style parameter sections. @@ -156,14 +157,131 @@ fn extend_parameter_documentation(parameters: &mut Parameters, lines: &[ParsedLi /// Parses a parameter item into its display name and description. fn parse_parameter(line: &str) -> Option<(&str, &str)> { - let (name, description) = split_once_unbracketed_colon(line)?; - let (display_name, _) = parse_parenthesized_type(name.trim()); + let (display_name, description) = + if let Some((name, description)) = split_once_at_field_delimiter(line) { + let (display_name, _ty) = split_name_and_type(name.trim()); + (display_name, description) + } else { + // If malformed type syntax hides the delimiter, recover the conventional field shape + // and discard the type. + recover_parameter_without_type(line)? + }; google_parameter_names(display_name) .is_some() .then_some((display_name, description.trim())) } +/// Splits at the field delimiter, skipping top-level colons in reST roles. +/// +/// For example, ``:exc:`ValueError`: description`` returns +/// ``(":exc:`ValueError`", " description")``. +fn split_once_at_field_delimiter(line: &str) -> Option<(&str, &str)> { + let mut cursor = Cursor::new(line); + loop { + let (before_colon, after_colon) = split_once_at_top_level_colon(cursor.as_str())?; + cursor.skip_bytes(before_colon.len()); + + if consume_rest_prefix_role(&mut cursor) { + continue; + } + + return Some((&line[..cursor.offset().to_usize()], after_colon)); + } +} + +/// Consumes the prefix-role pattern recognized by the field parser, leaving the cursor unchanged +/// otherwise. +/// +/// For example, this consumes the entire input: +/// +/// ```text +/// :exc:`ValueError` +/// ``` +fn consume_rest_prefix_role(cursor: &mut Cursor<'_>) -> bool { + let mut role = cursor.clone(); + + // First, require the candidate delimiter to be the opening colon of a role. + if !role.eat_char(':') { + return false; + } + + // Role names start with a Unicode alphanumeric run. Rejecting punctuation here preserves the + // first colon in `value::class:` as the field delimiter. + if !role.eat_if(char::is_alphanumeric) { + return false; + } + + // Next, scan the rest of the role name until its closing colon and the opening content + // backtick. + loop { + role.eat_while(char::is_alphanumeric); + if role.eat_char2(':', '`') { + break; + } + + // `-._+:` separators are allowed, but only internally to alphanumeric characters. + if !role.eat_if(|character| matches!(character, '-' | '.' | '_' | '+' | ':')) + || !role.eat_if(char::is_alphanumeric) + { + return false; + } + } + + // Finally, skip the role content so delimiter scanning resumes after its closing backtick. + role.eat_while(|character| character != '`'); + if !role.eat_char('`') { + return false; + } + + *cursor = role; + true +} + +/// Splits a display name from a trailing parenthesized type. +/// +/// For example, `"value (str)"` yields `("value", Some("str"))`. +fn split_name_and_type(value: &str) -> (&str, Option<&str>) { + let Some((name, ty)) = split_trailing_parenthetical(value) else { + return (value, None); + }; + + if name.is_empty() || ty.is_empty() { + (value, None) + } else { + (name, Some(ty)) + } +} + +/// Implements a simple heuristic to recover a parameter name and description +/// from a line with a malformed type. +/// +/// This assumes the first `" ("` begins the type and the first unquoted `")"` followed by +/// optional whitespace and `":"` ends it. +/// +/// For example, `"value (list[str) : description"` yields the name `"value"` and description +/// `" description"`. +fn recover_parameter_without_type(line: &str) -> Option<(&str, &str)> { + let (display_name, remainder) = line.split_once(" (")?; + let mut cursor = Cursor::new(remainder); + + while let Some(character) = cursor.bump() { + match character { + '\'' | '"' => consume_quoted_string(&mut cursor, character), + ')' => { + let mut delimiter = cursor.clone(); + delimiter.eat_while(char::is_whitespace); + if delimiter.eat_char(':') { + return Some((display_name.trim(), delimiter.as_str())); + } + } + _ => {} + } + } + + None +} + /// Returns whether `name` is a valid Python parameter name, including variadic prefixes. fn is_parameter_name(name: &str) -> bool { let identifier = name.strip_prefix('*').unwrap_or(name); @@ -366,7 +484,7 @@ fn section_item_indent(header: SectionHeader, line: ParsedLine<'_>) -> Option parse_parameter(trimmed).is_some(), HeaderKind::Structured(SectionKind::Attributes | SectionKind::Raises) => { - split_once_unbracketed_colon(trimmed).is_some_and(|(name, _)| !name.trim().is_empty()) + split_once_at_field_delimiter(trimmed).is_some_and(|(name, _)| !name.trim().is_empty()) } HeaderKind::Structured(SectionKind::Returns | SectionKind::Yields) => !trimmed.is_empty(), HeaderKind::Opaque => false, @@ -382,7 +500,7 @@ fn is_inline_section_header(line: &str) -> bool { return false; } - let Some((name, description)) = split_once_unbracketed_colon(line) else { + let Some((name, description)) = split_once_at_top_level_colon(line) else { return false; }; @@ -438,7 +556,9 @@ mod tests { use itertools::Itertools; use ruff_text_size::TextSize; - use super::{SectionKind, parameter_documentation, parsed_lines, sections}; + use super::{ + SectionKind, parameter_documentation, parsed_lines, sections, split_once_at_field_delimiter, + }; #[test] fn extracts_aligned_parameter_items() { @@ -502,7 +622,7 @@ Partition into non-overlapping windows with padding if needed. } #[test] - fn extracts_parameter_with_unbalanced_type_brackets() { + fn extracts_parameter_despite_unbalanced_type_brackets() { let raw = "\ Args: query_embeddings (`Union[torch.Tensor, list[torch.Tensor]`): Query embeddings."; @@ -513,6 +633,30 @@ Args: "); } + #[test] + fn recovers_parameter_after_quoted_delimiter_in_malformed_type() { + let raw = "\ +Args: + value (Literal['):'], list[str): Actual description."; + + assert_snapshot!(display_parameters(raw), @" + value: + │ Actual description. + "); + } + + #[test] + fn recovers_parameter_after_spaced_delimiter_in_malformed_type() { + let raw = "\ +Args: + value (list[str) : Description."; + + assert_snapshot!(display_parameters(raw), @" + value: + │ Description. + "); + } + #[test] fn accepts_dashed_parameter_section_underline() { let raw = "\ @@ -1091,6 +1235,52 @@ Returns: ); } + #[test] + fn skips_rest_roles_before_field_delimiter() { + assert_eq!( + split_once_at_field_delimiter(":py:class:`ValueError`: Invalid value."), + Some((":py:class:`ValueError`", " Invalid value.")) + ); + assert_eq!( + split_once_at_field_delimiter(":external+python:py:class:`ValueError`: Invalid value."), + Some((":external+python:py:class:`ValueError`", " Invalid value.")) + ); + assert_eq!( + split_once_at_field_delimiter(":étiquette:`valeur`: Description."), + Some((":étiquette:`valeur`", " Description.")) + ); + } + + #[test] + fn does_not_skip_invalid_rest_roles() { + for (line, description) in [ + ("value:foo..bar:`X`", "foo..bar:`X`"), + ("value:foo-:`X`", "foo-:`X`"), + ] { + assert_eq!( + split_once_at_field_delimiter(line), + Some(("value", description)), + "{line:?}" + ); + } + } + + #[test] + fn splits_before_rest_role_adjacent_to_field_delimiter() { + assert_eq!( + split_once_at_field_delimiter("value::class:`Widget` description."), + Some(("value", ":class:`Widget` description.")) + ); + } + + #[test] + fn does_not_split_at_rest_roles_in_prose() { + assert_eq!( + split_once_at_field_delimiter("Typically :class:`Intermediate` or a subclass is used."), + None + ); + } + fn display_parameters(raw: &str) -> String { let normalized_source = crate::docstring::documentation_trim(raw); parameter_documentation(&normalized_source) diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index bcc86a6f53..4f9390aee3 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -1,4 +1,4 @@ -use ruff_python_trivia::{leading_indentation, tab_offset_u32}; +use ruff_python_trivia::{Cursor, leading_indentation, tab_offset_u32}; use ruff_source_file::UniversalNewlines; use ruff_text_size::{TextRange, TextSize}; @@ -107,6 +107,9 @@ pub(in crate::docstring) fn is_backtick_run_escaped(text: &str, index: usize) -> } /// Returns the end of an indented Markdown or reStructuredText container block. +/// +/// For example, in `["- item", " first", " second", "next"]`, the block at index 0 ends +/// at index 3. pub(super) fn container_block_end(lines: &[ParsedLine<'_>], index: usize) -> Option { let marker = lines.get(index)?; if !is_rest_directive_marker(marker.text) @@ -137,100 +140,119 @@ fn is_rest_directive_marker(line: &str) -> bool { !name.is_empty() && !name.chars().any(char::is_whitespace) } -/// Splits the input once at the first colon outside bracket pairs and quoted strings. -pub(super) fn split_once_unbracketed_colon(line: &str) -> Option<(&str, &str)> { - let mut depths = [0usize; 3]; - let mut quote = None; - let mut escaped = false; - let mut fallback_colon = None; - - for (index, character) in line.char_indices() { - if let Some(quote_character) = quote { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == quote_character { - quote = None; +/// Splits at the first top-level colon, ignoring colons inside brackets and quoted strings. +/// +/// Bracket kinds are tracked independently, so mismatched nesting such as `([a)]` is treated as +/// balanced. This is sufficient because callers only need to find a delimiter after all bracket +/// groups close; validating the surrounding syntax is outside this helper's scope. +/// +/// For example, `"value (Literal['a:b']): description"` splits before `"description"`, not at +/// the colon inside the quoted string. +/// Returns `None` if every colon is inside a bracket group, including an unclosed one. +pub(super) fn split_once_at_top_level_colon(line: &str) -> Option<(&str, &str)> { + let mut nesting = BracketNesting::default(); + let mut cursor = Cursor::new(line); + + while let Some(character) = cursor.bump() { + match character { + '\'' | '"' => consume_quoted_string(&mut cursor, character), + ':' if nesting.is_top_level() => { + let index = cursor.offset().to_usize() - character.len_utf8(); + return Some((&line[..index], cursor.as_str())); } - continue; + _ => nesting.update(character), } + } + + None +} +#[derive(Default)] +struct BracketNesting { + parentheses: usize, + square: usize, + curly: usize, +} + +impl BracketNesting { + fn is_top_level(&self) -> bool { + self.parentheses == 0 && self.square == 0 && self.curly == 0 + } + + /// Updates the nesting depth while tolerating unmatched closing brackets. + /// + /// For example, `'('` increments the parenthesis depth and a later `')'` decrements it. + fn update(&mut self, character: char) { match character { - '\'' | '"' => quote = Some(character), - '(' => depths[0] += 1, - ')' => depths[0] = depths[0].saturating_sub(1), - '[' => depths[1] += 1, - ']' => depths[1] = depths[1].saturating_sub(1), - '{' => depths[2] += 1, - '}' => depths[2] = depths[2].saturating_sub(1), - ':' if depths == [0; 3] => { - return Some((&line[..index], &line[index + character.len_utf8()..])); - } - // Retain a colon outside parentheses as a fallback. This recovers an item delimiter - // after malformed square or curly brackets while preferring a fully balanced split. - ':' if depths[0] == 0 && fallback_colon.is_none() => fallback_colon = Some(index), + '(' => self.parentheses += 1, + ')' => self.parentheses = self.parentheses.saturating_sub(1), + '[' => self.square += 1, + ']' => self.square = self.square.saturating_sub(1), + '{' => self.curly += 1, + '}' => self.curly = self.curly.saturating_sub(1), _ => {} } } +} - fallback_colon.map(|index| (&line[..index], &line[index + ':'.len_utf8()..])) +/// Advances past a quoted string after its opening quote has been consumed. +/// +/// For example, after the opening quote in `"value" trailing` has been consumed, a cursor over +/// `value" trailing` with `quote` set to `'"'` advances to ` trailing`. +pub(super) fn consume_quoted_string(cursor: &mut Cursor<'_>, quote: char) { + while let Some(character) = cursor.bump() { + if character == '\\' { + cursor.bump(); + } else if character == quote { + break; + } + } } -/// Splits a trailing parenthesized type from a parameter display name. -pub(super) fn parse_parenthesized_type(name: &str) -> (&str, Option<&str>) { - if !name.ends_with(')') { - return (name, None); +/// Splits and trims the prefix and contents of a trailing parenthetical expression. +/// +/// Parentheses inside quoted strings and Markdown code spans do not affect nesting. +/// +/// For example, `"value (Callable[[int], str])"` splits into `"value"` and +/// `"Callable[[int], str]"`. +pub(super) fn split_trailing_parenthetical(value: &str) -> Option<(&str, &str)> { + if !value.ends_with(')') { + return None; } let mut depth = 0usize; - let mut opening = None; - let mut quote = None; - let mut escaped = false; - - for (index, character) in name.char_indices() { - if let Some(quote_character) = quote { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == quote_character { - quote = None; - } - continue; - } + let mut outermost_opening = None; + let mut cursor = Cursor::new(value); + while let Some(character) = cursor.bump() { + let index = cursor.offset().to_usize() - character.len_utf8(); match character { - '\'' | '"' => quote = Some(character), + '\'' | '"' => consume_quoted_string(&mut cursor, character), + '`' if !is_backtick_run_escaped(value, index) => { + let opening = find_backtick_run(value, TextSize::of(&value[..index]))?; + let span = markdown_code_span(value, opening).unwrap_or(opening); + cursor.skip_bytes((span.end() - cursor.offset()).to_usize()); + } '(' => { if depth == 0 { - opening = Some(index); + outermost_opening = Some(index); } depth += 1; } ')' => { - depth = match depth.checked_sub(1) { - Some(depth) => depth, - None => return (name, None), - }; - if depth == 0 && index + character.len_utf8() == name.len() { - let Some(opening) = opening else { - return (name, None); - }; - let display_name = name[..opening].trim(); - let ty = name[opening + '('.len_utf8()..index].trim(); - - return if display_name.is_empty() || ty.is_empty() { - (name, None) - } else { - (display_name, Some(ty)) - }; + depth = depth.checked_sub(1)?; + if depth == 0 && cursor.is_eof() { + let opening = outermost_opening?; + let prefix = value[..opening].trim(); + let contents = value[opening + '('.len_utf8()..index].trim(); + return Some((prefix, contents)); } } _ => {} } } - (name, None) + + None } /// Calculates indentation width, advancing tabs to the next multiple of eight columns. @@ -247,7 +269,9 @@ pub(super) fn indentation(line: &str) -> TextSize { #[cfg(test)] mod tests { - use super::is_markdown_code_span; + use super::{ + is_markdown_code_span, split_once_at_top_level_colon, split_trailing_parenthetical, + }; #[test] fn recognizes_complete_markdown_code_spans() { @@ -264,4 +288,96 @@ mod tests { assert_eq!(is_markdown_code_span(text), expected, "{text:?}"); } } + + #[test] + fn splits_after_nested_brackets() { + assert_eq!( + split_once_at_top_level_colon("value (dict[str, list[{key: value}]]): Description"), + Some(("value (dict[str, list[{key: value}]])", " Description")) + ); + } + + #[test] + fn ignores_colons_inside_quoted_strings() { + assert_eq!( + split_once_at_top_level_colon(r"value (Literal['a\'b:c']): Description"), + Some((r"value (Literal['a\'b:c'])", " Description")) + ); + } + + #[test] + fn treats_backticks_as_plain_text() { + assert_eq!( + split_once_at_top_level_colon("value (`str): Description such as `.py`."), + Some(("value (`str)", " Description such as `.py`.")) + ); + } + + #[test] + fn ignores_colons_inside_balanced_brackets() { + for line in ["value [a:b]", "value {a:b}"] { + assert_eq!(split_once_at_top_level_colon(line), None, "{line:?}"); + } + } + + #[test] + fn does_not_split_inside_unclosed_brackets() { + for line in [ + "value (str: Description", + "value [str: Description", + "value {str: Description", + ] { + assert_eq!(split_once_at_top_level_colon(line), None, "{line:?}"); + } + } + + #[test] + fn splits_trailing_parenthesized_group() { + assert_eq!( + split_trailing_parenthetical(" value ( str )"), + Some(("value", "str")) + ); + } + + #[test] + fn splits_nested_parenthesized_group() { + assert_eq!( + split_trailing_parenthetical("value (Callable[(int), tuple[str]])"), + Some(("value", "Callable[(int), tuple[str]]")) + ); + } + + #[test] + fn ignores_parentheses_inside_quoted_strings() { + assert_eq!( + split_trailing_parenthetical("value (Literal[')'])"), + Some(("value", "Literal[')']")) + ); + } + + #[test] + fn ignores_parentheses_inside_code_spans() { + assert_eq!( + split_trailing_parenthetical("value (`(`)"), + Some(("value", "`(`")) + ); + } + + #[test] + fn ignores_parentheses_after_escaped_quotes() { + assert_eq!( + split_trailing_parenthetical(r#"value (Literal["a\"b)c"])"#), + Some(("value", r#"Literal["a\"b)c"]"#)) + ); + } + + #[test] + fn rejects_unclosed_parenthesized_group() { + assert_eq!(split_trailing_parenthetical("value (str"), None); + } + + #[test] + fn rejects_parenthesized_group_before_trailing_text() { + assert_eq!(split_trailing_parenthetical("value (str) or None"), None); + } } From ed3233e7565c8940a59e73235e3fdb999d814839 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 15:03:30 -0700 Subject: [PATCH 064/222] [ty] Handle cycles in derived constraint relations (#26814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary While deriving implications from a constraint `L ≤ T ≤ U`, the sequent map checks when `L` is assignable to `U`. Running that relation directly in the active builder bypasses Salsa cycle recovery. Recursive protocols can therefore re-enter the same derived relation until stack overflow. This PR routes both derived-relation checks through the existing owned, cached query and gives recursive assignability its coinductive initial result: provisionally true until another part of the relation contradicts it. The result is then loaded back into the active builder, preserving the existing sequent construction. The regression combines two recursive members with an incompatible non-recursive member. It now terminates while still rejecting the invalid implementation. This is split out of #26788 as its low-level base and is stacked on #26776. --- .../regression/derived_constraint_cycles.md | 33 +++++++++++++++++++ .../src/types/constraints.rs | 16 ++++++--- .../ty_python_semantic/src/types/relation.rs | 5 ++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md index f1fa5ed3c8..86ec59c3f7 100644 --- a/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md +++ b/crates/ty_python_semantic/resources/mdtest/regression/derived_constraint_cycles.md @@ -27,6 +27,39 @@ def reduce[T](function: Callable[[T, T], T]) -> T: reduce(add) ``` +## Recursive relations between derived constraints + +Validating a derived constraint can require another relation check. If that relation recursively +reaches the same pair of types, we assume that it holds until we find a contradiction elsewhere in +the protocol. This lets the check terminate without accepting a protocol that has an incompatible +non-recursive member. + +```py +from __future__ import annotations + +from typing import Protocol, cast + +class Array(Protocol): + def __abs__(self) -> Array: ... + def __pos__(self) -> Array: ... + def marker(self) -> int: ... + +class Concrete[T]: + def __abs__[S](self: S) -> S: + return self + + def __pos__[S](self: S) -> S: + return self + + def marker(self) -> str: + return "" + +def convert[T](value: Concrete[T]) -> Array: + return cast(Array, value) + +invalid: Array = Concrete[int]() # error: [invalid-assignment] +``` + ## Independent nested substitutions can compose The repeat guard tracks substitution history separately for each derived constraint. This allows two diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 81b6774805..e51afe2750 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -5442,10 +5442,13 @@ impl SequentMap { ) { // If the post constraint is unsatisfiable, then the antecedents contradict each other. let post_data = builder.constraint_data(post); - let when = post_data - .bounds - .materialized_lower() - .when_constraint_set_assignable_to(db, post_data.bounds.materialized_upper(), builder); + let when = builder.load( + db, + &post_data + .bounds + .materialized_lower() + .when_constraint_set_assignable_to_owned(db, post_data.bounds.materialized_upper()), + ); if when.is_never_satisfied(db) { self.add_pair_impossibility(db, builder, ante1, ante2); return; @@ -5562,7 +5565,10 @@ impl SequentMap { return; } - let when = lower.when_constraint_set_assignable_to(db, upper, builder); + let when = builder.load( + db, + &lower.when_constraint_set_assignable_to_owned(db, upper), + ); // If L is _never_ assignable to U, this constraint would violate transitivity, and should // never have been added. diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 437b4e4763..4d4d955dd4 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -454,6 +454,9 @@ impl<'db> Type<'db> { /// Returns an _owned_ (i.e. salsa-cached) constraint set that describes when `self` is /// constraint-set assignable to `target`. + /// + /// Recursive relations are evaluated coinductively: a cycle is provisionally satisfied until + /// another part of the relation produces a contradiction. pub(super) fn when_constraint_set_assignable_to_owned( self, db: &'db dyn Db, @@ -461,7 +464,7 @@ impl<'db> Type<'db> { ) -> Cow<'db, OwnedConstraintSet<'db>> { #[salsa::tracked( returns(ref), - cycle_initial=|_, _, _, _| OwnedConstraintSet::default(), + cycle_initial=|_, _, _, _| OwnedConstraintSet::always(), heap_size=ruff_memory_usage::heap_size, )] fn when_constraint_set_assignable_to_owned_impl<'db>( From 5ec1ca7f3010d5473a7de668e3b946118bf5d5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Tue, 14 Jul 2026 15:05:46 -0700 Subject: [PATCH 065/222] [ty] Refactor Google docstrings into a document model (#26670) ## Summary This refactors Google-style docstring parsing so that we build a document model while we recognize section structure. The result has two main benefits: - It represents section bodies as explicit prose and item fragments. This is directly suitable for [downstream structured Markdown rendering](https://github.com/astral-sh/ruff/pull/26599). - It reuses parsed headers and item classifications for both boundary detection and document construction, reducing duplicated parsing and improving consistency in response to this [prior review feedback](https://github.com/astral-sh/ruff/pull/25919#discussion_r3544127094). The easiest way to review this change is just to review the new file contents. ## Test Plan This is a refactor that relies on existing test coverage. --- .../ty_ide/src/docstring/document/google.rs | 1087 +++++++++++------ 1 file changed, 710 insertions(+), 377 deletions(-) diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 2a68ecf1d9..8802ebf0db 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -9,8 +9,9 @@ //! - `Other Args`, `Other Arguments`, and `Other Parameters` //! //! It accepts comma-separated Python names with optional parenthesized types, preserves -//! continuation text, and skips section-like text inside preformatted or container blocks. Other -//! known headings only delimit parameter sections; their contents are not parsed here. +//! continuation text, and skips section-like text inside preformatted or container blocks. +//! Supported section bodies are parsed into prose and named-item fragments; other known headings +//! only delimit sections. //! //! Example: //! @@ -28,8 +29,6 @@ //! retries: Number of retries. //! ``` -use std::cmp::Ordering; - use indexmap::IndexMap; use ruff_python_stdlib::identifiers::is_identifier; use ruff_python_trivia::Cursor; @@ -38,7 +37,7 @@ use ruff_text_size::{TextRange, TextSize}; use super::SectionKind; use super::preformatted::PreformattedBlockScanner; use super::syntax::{ - ParsedLine, consume_quoted_string, container_block_end, parsed_lines, + ParsedLine, consume_quoted_string, container_block_end, indentation, parsed_lines, split_once_at_top_level_colon, split_trailing_parenthetical, }; @@ -47,14 +46,18 @@ use super::syntax::{ /// `normalized_source` must have already undergone PEP-257 trimming and universal newline /// normalization. pub(super) fn parameter_documentation(normalized_source: &str) -> IndexMap { - let lines = parsed_lines(normalized_source); let mut parameters = Parameters::default(); - for section in sections(&lines) { + for section in sections(normalized_source) { + let Section { + kind, + fragments, + range: _, + } = section; if matches!( - section.kind, + kind, SectionKind::Parameters | SectionKind::KeywordArguments | SectionKind::OtherParameters ) { - extend_parameter_documentation(&mut parameters, section.body); + parameters.extend_fragments(fragments); } } parameters.into_inner() @@ -62,180 +65,35 @@ pub(super) fn parameter_documentation(normalized_source: &str) -> IndexMap( - lines: &'a [ParsedLine<'a>], -) -> impl Iterator> + 'a { - let mut preformatted_blocks = PreformattedBlockScanner::default(); - let mut index = 0; - - std::iter::from_fn(move || { - while index < lines.len() { - // Skip blocks that "own" all internal content (in which we should not - // recognize content that might otherwise look like a Google section header) - if preformatted_blocks.consume_preformatted_line(lines[index].text) { - index += 1; - continue; - } - if let Some(end) = container_block_end(lines, index) { - index = end; - continue; - } - - let Some(header) = parse_section_header(lines, index) else { - preformatted_blocks.observe_line_outside_preformatted_block(lines[index].text); - index += 1; - continue; - }; - - let (range, body_end_line_index) = section_body_end(lines, header); - index = body_end_line_index; - if let HeaderKind::Structured(kind) = header.kind { - return Some(Section { - kind, - body: &lines[header.body_start_line_index..body_end_line_index], - range, - header_indent: header.indent, - }); - } - } - - None - }) +pub(in crate::docstring) fn sections(source: &str) -> impl Iterator { + Parser::new(parsed_lines(source)).parse().into_iter() } /// A recognized Google-style docstring section. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::docstring) struct Section<'a> { - /// The recognized section kind. - pub(in crate::docstring) kind: SectionKind, - /// The lines in the section body. - pub(in crate::docstring) body: &'a [ParsedLine<'a>], - /// The section's source range, including its header. - pub(in crate::docstring) range: TextRange, - /// The indentation of the section header. - pub(in crate::docstring) header_indent: TextSize, -} - -/// Extends `parameters` with the documented items in one parameter section body. -fn extend_parameter_documentation(parameters: &mut Parameters, lines: &[ParsedLine<'_>]) { - let mut current: Option<(String, String)> = None; - let mut item_indent = None; - - for line in lines { - let trimmed = line.text.trim(); - - // The first recognized item establishes the sibling indentation. - // Each item at that indentation starts a new sibling and completes its predecessor. - if item_indent.is_none_or(|indent| line.indent == indent) - && let Some((names, description)) = parse_parameter(trimmed) - { - parameters.insert_documentation( - current.replace((names.to_string(), description.to_string())), - ); - item_indent = Some(line.indent); - continue; - } - - // Ignore prose until the first item has started. - let Some((_, description)) = &mut current else { - continue; - }; - - // Lines that are not sibling items extend the current description. - // Empty lines preserve paragraph breaks. - if !description.is_empty() && !description.ends_with('\n') { - description.push('\n'); - } - description.push_str(if trimmed.is_empty() { "\n" } else { trimmed }); - } - - // A following item completes its predecessor in the loop, so complete the final item here. - parameters.insert_documentation(current); +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::docstring) struct Section { + kind: SectionKind, + range: TextRange, + fragments: Vec, } -/// Parses a parameter item into its display name and description. -fn parse_parameter(line: &str) -> Option<(&str, &str)> { - let (display_name, description) = - if let Some((name, description)) = split_once_at_field_delimiter(line) { - let (display_name, _ty) = split_name_and_type(name.trim()); - (display_name, description) - } else { - // If malformed type syntax hides the delimiter, recover the conventional field shape - // and discard the type. - recover_parameter_without_type(line)? - }; - - google_parameter_names(display_name) - .is_some() - .then_some((display_name, description.trim())) +/// One parsed fragment in a Google section body. +#[derive(Debug, Clone, PartialEq, Eq)] +enum BodyFragment { + /// Section-level prose that is not attached to a named item. + Prose(String), + /// A named item and its description. + Item(Item), } -/// Splits at the field delimiter, skipping top-level colons in reST roles. -/// -/// For example, ``:exc:`ValueError`: description`` returns -/// ``(":exc:`ValueError`", " description")``. -fn split_once_at_field_delimiter(line: &str) -> Option<(&str, &str)> { - let mut cursor = Cursor::new(line); - loop { - let (before_colon, after_colon) = split_once_at_top_level_colon(cursor.as_str())?; - cursor.skip_bytes(before_colon.len()); - - if consume_rest_prefix_role(&mut cursor) { - continue; - } - - return Some((&line[..cursor.offset().to_usize()], after_colon)); - } -} - -/// Consumes the prefix-role pattern recognized by the field parser, leaving the cursor unchanged -/// otherwise. -/// -/// For example, this consumes the entire input: -/// -/// ```text -/// :exc:`ValueError` -/// ``` -fn consume_rest_prefix_role(cursor: &mut Cursor<'_>) -> bool { - let mut role = cursor.clone(); - - // First, require the candidate delimiter to be the opening colon of a role. - if !role.eat_char(':') { - return false; - } - - // Role names start with a Unicode alphanumeric run. Rejecting punctuation here preserves the - // first colon in `value::class:` as the field delimiter. - if !role.eat_if(char::is_alphanumeric) { - return false; - } - - // Next, scan the rest of the role name until its closing colon and the opening content - // backtick. - loop { - role.eat_while(char::is_alphanumeric); - if role.eat_char2(':', '`') { - break; - } - - // `-._+:` separators are allowed, but only internally to alphanumeric characters. - if !role.eat_if(|character| matches!(character, '-' | '.' | '_' | '+' | ':')) - || !role.eat_if(char::is_alphanumeric) - { - return false; - } - } - - // Finally, skip the role content so delimiter scanning resumes after its closing backtick. - role.eat_while(|character| character != '`'); - if !role.eat_char('`') { - return false; - } - - *cursor = role; - true +/// A named item in a Google section. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Item { + display_name: String, + ty: Option, + description: String, } /// Splits a display name from a trailing parenthesized type. @@ -293,17 +151,21 @@ fn is_parameter_name(name: &str) -> bool { struct Parameters(IndexMap); impl Parameters { - /// Inserts a completed parameter item under each of its comma-separated names. - fn insert_documentation(&mut self, parameter: Option<(String, String)>) { - let Some((names, description)) = parameter else { - return; - }; - let description = description.trim(); - if !description.is_empty() - && let Some(names) = google_parameter_names(&names) - { - for name in names { - self.0.insert(name.to_string(), description.to_string()); + fn extend_fragments(&mut self, fragments: Vec) { + for fragment in fragments { + let BodyFragment::Item(item) = fragment else { + continue; + }; + let Item { + display_name, + description, + ty: _, + } = item; + if description.is_empty() { + continue; + } + for name in display_name.split(',').map(str::trim) { + self.0.insert(name.to_string(), description.clone()); } } } @@ -313,212 +175,277 @@ impl Parameters { } } -fn google_parameter_names(display_name: &str) -> Option> { - let names = display_name.split(',').map(str::trim); - names.clone().all(is_parameter_name).then_some(names) +struct Parser<'a> { + lines: Vec>, + current_line: usize, + sections: Vec
, + current: Option>, + scanner: PreformattedBlockScanner<'a>, } -/// Parses a recognized Google-style section header at `index`. -fn parse_section_header(lines: &[ParsedLine<'_>], index: usize) -> Option { - let line = lines[index]; - let kind = section_kind(line.text)?; - - Some(SectionHeader { - kind, - indent: line.indent, - body_start_line_index: index + 1, - range: line.range, - }) -} +impl<'a> Parser<'a> { + fn new(lines: Vec>) -> Self { + Self { + lines, + current_line: 0, + sections: Vec::new(), + current: None, + scanner: PreformattedBlockScanner::default(), + } + } -fn section_kind(line: &str) -> Option { - let name = line.trim().strip_suffix(':')?.trim(); - HeaderKind::from_name(name) -} + fn parse(mut self) -> Vec
{ + while self.current_line < self.lines.len() { + self.push_line(); + } + + if let Some(section) = self.current.take() { + self.finish_section(section); + } + + self.sections + } + + fn push_line(&mut self) { + let index = self.current_line; + let line = self.lines[index]; + self.current_line += 1; + let line_header = (!line.text.trim().is_empty()) + .then(|| Self::parse_header(line)) + .flatten(); -/// Returns the section's source range and the index of the first line outside its body. -fn section_body_end(lines: &[ParsedLine<'_>], header: SectionHeader) -> (TextRange, usize) { - let mut body_end_index = header.body_start_line_index; - let mut preformatted_blocks = PreformattedBlockScanner::default(); - let mut item_indent = None; + // First, attempt to add the current line to the current section. + if let Some(mut section) = self.current.take() { + // If the line is accepted by the current section, then continue parsing that section. + if section.push_line(line, line_header) { + self.current = Some(section); + return; + } + + // If the line is rejected by the current section, then finalize it. + self.finish_section(section); + } + + // Second, skip content owned by a preformatted or container block, where nested headers + // are inert. + if self.scanner.consume_preformatted_line(line.text) { + return; + } + if let Some(end) = container_block_end(&self.lines, index) { + self.current_line = end; + return; + } - while let Some(line) = lines.get(body_end_index) { - // Once a preformatted block begins, its contents cannot end the section. - if preformatted_blocks.is_active() - && preformatted_blocks.consume_preformatted_line(line.text) + // Finally, start a new section from a standalone header, or observe syntax that may + // introduce a preformatted block. + if let Some(header) = line_header + && header.form == HeaderForm::Section { - body_end_index += 1; - continue; + self.current = Some(SectionBuilder::new(header)); + } else { + self.scanner + .observe_line_outside_preformatted_block(line.text); } + } - let Some((leading_blank_lines, line)) = - section_body_continuation(&lines[body_end_index..], header, item_indent) - else { - break; + fn parse_header(line: ParsedLine<'_>) -> Option
{ + let trimmed = line.text.trim(); + if trimmed.ends_with("::") { + // A trailing double colon introduces a reST literal block rather than a section. + return None; + } + + let (name, description) = split_once_at_top_level_colon(trimmed)?; + let form = if description.is_empty() { + HeaderForm::Section + } else if name.chars().next().is_some_and(char::is_uppercase) { + HeaderForm::Inline + } else { + // Lowercase inline labels are more likely field-like content than section headers. + return None; }; - body_end_index += leading_blank_lines; + let kind = HeaderKind::from_name(name)?; - item_indent = item_indent.or_else(|| section_item_indent(header, line)); + Some(Header { + kind, + form, + indent: line.indent, + range: line.range, + }) + } - if !preformatted_blocks.consume_preformatted_line(line.text) { - preformatted_blocks.observe_line_outside_preformatted_block(line.text); + fn finish_section(&mut self, section: SectionBuilder<'a>) { + if let Some(section) = section.finish() { + self.sections.push(section); } - body_end_index += 1; } +} - let body = &lines[header.body_start_line_index..body_end_index]; - let range = match body.last() { - Some(last) => header.range.cover(last.range), - None => header.range, - }; - (range, body_end_index) +struct SectionBuilder<'a> { + section_header: Header, + range: TextRange, + /// Blank lines whose ownership depends on the next nonblank line. + pending_blank_lines: Vec>, + /// Prevents code examples from participating in section-boundary detection. + preformatted: PreformattedBlockScanner<'a>, + /// Indentation established by the first item-like line. + /// + /// This controls section boundaries and may come from a line that cannot be represented as a + /// structured item. + boundary_item_indent: Option, + body: BodyBuilder<'a>, } -/// Returns the number of leading blank lines and first nonblank line that continue -/// `header`'s body. -fn section_body_continuation<'a>( - lines: &[ParsedLine<'a>], - header: SectionHeader, - item_indent: Option, -) -> Option<(usize, ParsedLine<'a>)> { - let (leading_blank_lines, next_line) = lines - .iter() - .enumerate() - .find(|(_, line)| !line.text.trim().is_empty())?; - - if leading_blank_lines == 0 && section_header_ends_body(lines, 0, header) { - return None; - } - - if leading_blank_lines > 0 - && next_line.indent <= header.indent - && (parse_section_header(lines, leading_blank_lines).is_some() - || is_inline_section_header(next_line.text)) - { - return None; - } - - // Returns and yields have no item syntax that distinguishes an aligned body from prose - // following an empty section. - if leading_blank_lines > 0 - && next_line.indent <= header.indent - && item_indent.is_none() - && matches!( - header.kind, - HeaderKind::Structured(SectionKind::Returns | SectionKind::Yields) - ) - { - return None; +impl<'a> SectionBuilder<'a> { + fn new(section_header: Header) -> Self { + Self { + range: section_header.range, + pending_blank_lines: Vec::new(), + preformatted: PreformattedBlockScanner::default(), + boundary_item_indent: None, + body: BodyBuilder::new(section_header.kind), + section_header, + } } - // A blank line ends a parameter section when the following aligned text is - // not another parameter item. - if leading_blank_lines > 0 - && matches!( - header.kind, - HeaderKind::Structured( - SectionKind::Parameters - | SectionKind::KeywordArguments - | SectionKind::OtherParameters - ) - ) - && item_indent == Some(next_line.indent) - && section_item_indent(header, *next_line).is_none() - { - return None; - } + /// Returns `false` when `line` belongs outside this section. + fn push_line(&mut self, line: ParsedLine<'a>, line_header: Option
) -> bool { + // First, let an active preformatted block consume the line before interpreting it. + let preformatted_block_is_active = self.preformatted.is_active(); + let line_is_preformatted = self.preformatted.consume_preformatted_line(line.text); + if preformatted_block_is_active && line_is_preformatted { + self.commit_pending_blank_lines(); + self.push_content_line(line, ItemLine::default()); + return true; + } - line_belongs_to_body(header, *next_line, item_indent) - .then_some((leading_blank_lines, *next_line)) -} + // Second, defer blank lines until the next content line determines their ownership. + if line.text.trim().is_empty() { + self.pending_blank_lines.push(line); + return true; + } -/// Returns whether a recognized header at `index` ends the current section body. -fn section_header_ends_body(lines: &[ParsedLine<'_>], index: usize, header: SectionHeader) -> bool { - let Some(line) = lines.get(index) else { - return false; - }; - if line.indent <= header.indent && is_inline_section_header(line.text) { - return true; + // Third, classify a nonblank line and stop if it begins content outside + // this section. + let item_line = ItemLine::classify(self.section_header.kind, line); + let has_leading_blank_lines = !self.pending_blank_lines.is_empty(); + if self.should_end_before( + line, + line_header, + item_line.boundary_item, + has_leading_blank_lines, + ) { + return false; + } + + // Finally, commit the accepted line and update the state used to + // classify later lines. + let boundary_item = item_line.boundary_item; + self.commit_pending_blank_lines(); + self.push_content_line(line, item_line); + if boundary_item { + self.boundary_item_indent.get_or_insert(line.indent); + } + if !line_is_preformatted { + self.preformatted + .observe_line_outside_preformatted_block(line.text); + } + + true } - parse_section_header(lines, index).is_some_and(|next| next.indent <= header.indent) -} + fn should_end_before( + &self, + line: ParsedLine<'_>, + line_header: Option
, + boundary_item: bool, + has_leading_blank_lines: bool, + ) -> bool { + // A sibling-level recognized header starts a new section. + if line_header.is_some_and(|header| header.indent <= self.section_header.indent) { + return true; + } -/// Returns whether `line` belongs to `header` under Google-style indentation rules. -fn line_belongs_to_body( - header: SectionHeader, - line: ParsedLine<'_>, - item_indent: Option, -) -> bool { - match line.indent.cmp(&header.indent) { - Ordering::Less => false, - Ordering::Greater => true, - Ordering::Equal => { - let item_indent_matches_line = item_indent.is_none_or(|indent| indent == line.indent); - let is_parameter_section = matches!( - header.kind, - HeaderKind::Structured( - SectionKind::Parameters - | SectionKind::KeywordArguments - | SectionKind::OtherParameters - ) - ); + // Returns and yields sections end before blank-separated sibling-level or dedented prose + // when no item indentation has been established. + if matches!( + self.section_header.kind, + HeaderKind::Structured(SectionKind::Returns | SectionKind::Yields) + ) && has_leading_blank_lines + && line.indent <= self.section_header.indent + && self.boundary_item_indent.is_none() + { + return true; + } - // Parameter sections can start with aligned prose before an item establishes the - // sibling indentation. Once established, aligned lines must match that indentation. - item_indent_matches_line - && (is_parameter_section || section_item_indent(header, line).is_some()) + // Blank-separated aligned prose ends a parameter section unless it starts another item. + if has_leading_blank_lines + && self.section_header.kind.is_parameter_section() + && self.boundary_item_indent == Some(line.indent) + && !boundary_item + { + return true; + } + + match line.indent.cmp(&self.section_header.indent) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Greater => false, + std::cmp::Ordering::Equal => { + // Parameter sections can start with aligned prose before an item establishes the + // sibling indentation. Once established, aligned lines must match that indentation. + let item_indent_matches = self + .boundary_item_indent + .is_none_or(|indent| indent == line.indent); + !item_indent_matches + || (!self.section_header.kind.is_parameter_section() && !boundary_item) + } } } -} -/// Returns the indentation of an item recognized in the current section. -/// -/// The first recognized item establishes the indentation for sibling items. -/// Item-like lines at a different indentation within the section are treated as -/// continuation text. -fn section_item_indent(header: SectionHeader, line: ParsedLine<'_>) -> Option { - let trimmed = line.text.trim(); - let is_item = match header.kind { - HeaderKind::Structured( - SectionKind::Parameters | SectionKind::KeywordArguments | SectionKind::OtherParameters, - ) => parse_parameter(trimmed).is_some(), - HeaderKind::Structured(SectionKind::Attributes | SectionKind::Raises) => { - split_once_at_field_delimiter(trimmed).is_some_and(|(name, _)| !name.trim().is_empty()) + fn commit_pending_blank_lines(&mut self) { + for line in self.pending_blank_lines.drain(..) { + self.range = self.range.cover(line.range); + self.body.push_blank_line(); } - HeaderKind::Structured(SectionKind::Returns | SectionKind::Yields) => !trimmed.is_empty(), - HeaderKind::Opaque => false, - }; - is_item.then_some(line.indent) -} + } -/// Returns whether `line` is a recognized section header followed by inline content. -fn is_inline_section_header(line: &str) -> bool { - let line = line.trim(); - // A trailing double colon introduces a reST literal block, not an inline section. - if line.ends_with("::") { - return false; + fn push_content_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { + self.range = self.range.cover(line.range); + self.body.push_line(line, item_line); } - let Some((name, description)) = split_once_at_top_level_colon(line) else { - return false; - }; + fn finish(self) -> Option
{ + let HeaderKind::Structured(kind) = self.section_header.kind else { + return None; + }; + let fragments = self.body.finish(); + Some(Section { + kind, + range: self.range, + fragments, + }) + } +} - let name = name.trim(); - let description = description.trim(); - !description.is_empty() - && name.chars().next().is_some_and(char::is_uppercase) - && HeaderKind::from_name(name).is_some() +/// Returns whether every component of `name` is a Python identifier. +fn is_dotted_identifier(name: &str) -> bool { + !name.is_empty() && name.split('.').all(is_identifier) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct SectionHeader { +struct Header { kind: HeaderKind, + form: HeaderForm, indent: TextSize, - body_start_line_index: usize, range: TextRange, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeaderForm { + Section, + Inline, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HeaderKind { Structured(SectionKind), @@ -532,6 +459,14 @@ impl HeaderKind { .collect::>() .join(" ") .to_ascii_lowercase(); + + // Google specifies the core sections for functions and classes: + // https://google.github.io/styleguide/pyguide.html#383-functions-and-methods + // https://google.github.io/styleguide/pyguide.html#384-classes + // + // Recognize additional headings and aliases for compatibility with Sphinx Napoleon and + // existing docstrings: + // https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections Some(match normalized.as_str() { "args" | "arguments" | "parameters" => Self::Structured(SectionKind::Parameters), "keyword args" | "keyword arguments" => Self::Structured(SectionKind::KeywordArguments), @@ -542,22 +477,431 @@ impl HeaderKind { "return" | "returns" => Self::Structured(SectionKind::Returns), "yield" | "yields" => Self::Structured(SectionKind::Yields), "raise" | "raises" => Self::Structured(SectionKind::Raises), + + // Recognized headings without a structured representation still delimit sections. "attention" | "caution" | "danger" | "error" | "example" | "examples" | "hint" | "important" | "methods" | "note" | "notes" | "references" | "see also" | "tip" | "todo" | "todos" | "warning" | "warnings" | "warns" => Self::Opaque, + + // Unrecognized headings remain ordinary content rather than creating a new section. _ => return None, }) } } +enum BodyBuilder<'a> { + /// A section whose body consists of named items and their descriptions. + ItemList(ItemListBuilder<'a>), + /// A section whose entire body is semantic prose (e.g., returns or yields). + Prose(DescriptionBuilder<'a>), + /// A recognized section that participates in boundary detection but is not rendered. + Opaque, +} + +impl<'a> BodyBuilder<'a> { + fn new(kind: HeaderKind) -> Self { + match kind { + HeaderKind::Structured(SectionKind::Returns | SectionKind::Yields) => { + Self::Prose(DescriptionBuilder::default()) + } + HeaderKind::Structured(_) => Self::ItemList(ItemListBuilder::default()), + HeaderKind::Opaque => Self::Opaque, + } + } + + fn push_blank_line(&mut self) { + match self { + Self::ItemList(builder) => builder.push_blank_line(), + Self::Prose(builder) => builder.push_continuation(""), + Self::Opaque => {} + } + } + + fn push_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { + match self { + Self::ItemList(body) => body.push_line(line, item_line), + Self::Prose(builder) => builder.push_line(line.text), + Self::Opaque => {} + } + } + + fn finish(self) -> Vec { + match self { + Self::ItemList(body) => body.finish(), + Self::Prose(description) => { + let prose = description.finish(); + (!prose.is_empty()) + .then_some(BodyFragment::Prose(prose)) + .into_iter() + .collect() + } + Self::Opaque => Vec::new(), + } + } +} + +#[derive(Default)] +struct ItemListBuilder<'a> { + fragments: Vec, + current_item: Option>, + /// Content encountered before the first recognized item. + leading_prose: DescriptionBuilder<'a>, + /// Indentation established by the first renderable item. + item_indent: Option, +} + +impl<'a> ItemListBuilder<'a> { + fn push_blank_line(&mut self) { + if let Some(item) = &mut self.current_item { + item.description.push_continuation(""); + } else { + self.leading_prose.push_continuation(""); + } + } + + fn push_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { + let line_indent = indentation(line.text); + if self + .item_indent + .is_none_or(|item_indent| line_indent == item_indent) + && let Some(item_header) = item_line.item_header + { + self.finish_leading_prose(); + self.finish_current_item(); + self.current_item = Some(ItemBuilder::new(&item_header)); + self.item_indent.get_or_insert(line_indent); + return; + } + + if let Some(item) = &mut self.current_item { + item.description.push_continuation(line.text); + } else { + self.leading_prose.push_line(line.text); + } + } + + fn finish_leading_prose(&mut self) { + let prose = std::mem::take(&mut self.leading_prose).finish(); + if !prose.is_empty() { + self.fragments.push(BodyFragment::Prose(prose)); + } + } + + fn finish_current_item(&mut self) { + if let Some(item) = self.current_item.take() { + self.fragments.push(BodyFragment::Item(item.finish())); + } + } + + fn finish(mut self) -> Vec { + self.finish_leading_prose(); + self.finish_current_item(); + self.fragments + } +} + +struct ItemBuilder<'a> { + display_name: &'a str, + ty: Option<&'a str>, + description: DescriptionBuilder<'a>, +} + +impl<'a> ItemBuilder<'a> { + fn new(item_header: &ItemHeader<'a>) -> Self { + Self { + display_name: item_header.display_name, + ty: item_header.ty, + description: DescriptionBuilder::with_inline(item_header.inline_description), + } + } + + fn finish(self) -> Item { + Item { + display_name: self.display_name.to_string(), + ty: self.ty.map(str::to_string), + description: self.description.finish(), + } + } +} + +#[derive(Default)] +struct DescriptionBuilder<'a> { + inline: Option<&'a str>, + continuation_lines: Vec<&'a str>, +} + +impl<'a> DescriptionBuilder<'a> { + fn with_inline(inline: &'a str) -> Self { + let inline = inline.trim(); + Self { + inline: (!inline.is_empty()).then_some(inline), + continuation_lines: Vec::new(), + } + } + + fn push_line(&mut self, line: &'a str) { + if self.inline.is_none() && self.continuation_lines.is_empty() { + self.inline = Some(line.trim()); + } else { + self.push_continuation(line); + } + } + + fn push_continuation(&mut self, line: &'a str) { + self.continuation_lines.push(line); + } + + fn finish(mut self) -> String { + if self.continuation_lines.is_empty() { + return self.inline.map_or_else(String::new, str::to_string); + } + + let continuation_indent = self + .continuation_lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| indentation(line)) + .min() + .unwrap_or_default(); + for line in &mut self.continuation_lines { + *line = if line.trim().is_empty() { + "" + } else { + strip_indentation(line, continuation_indent).trim_end() + }; + } + + if let Some(inline) = self.inline { + self.continuation_lines.insert(0, inline); + } + let lines = self.continuation_lines; + + let Some(start) = lines.iter().position(|line| !line.is_empty()) else { + return String::new(); + }; + let end = lines + .iter() + .rposition(|line| !line.is_empty()) + .map_or(start, |index| index + 1); + lines[start..end].join("\n") + } +} + +#[derive(Default)] +struct ItemLine<'a> { + /// Whether this line establishes item indentation for section-boundary detection. + boundary_item: bool, + item_header: Option>, +} + +impl<'a> ItemLine<'a> { + fn classify(section_kind: HeaderKind, line: ParsedLine<'a>) -> Self { + let HeaderKind::Structured(kind) = section_kind else { + return Self::default(); + }; + if matches!(kind, SectionKind::Returns | SectionKind::Yields) { + return Self { + boundary_item: true, + ..Self::default() + }; + } + + let line_text = line.text.trim(); + let split = split_once_at_field_delimiter(line_text).or_else(|| { + if matches!( + kind, + SectionKind::Parameters + | SectionKind::KeywordArguments + | SectionKind::OtherParameters + ) { + // If malformed type syntax hides the delimiter, recover the conventional field + // shape and discard the type. + recover_parameter_without_type(line_text) + } else { + None + } + }); + split + .map(|split| Self::from_split(kind, split)) + .unwrap_or_default() + } + + fn from_split(kind: SectionKind, (raw_name, inline_description): (&'a str, &'a str)) -> Self { + let name = raw_name.trim(); + if name.is_empty() { + return Self::default(); + } + + let (display_name, ty) = match kind { + SectionKind::Parameters + | SectionKind::KeywordArguments + | SectionKind::OtherParameters => { + let (display_name, ty) = split_name_and_type(name); + if !is_parameter_display_name(display_name) { + return Self::default(); + } + (display_name, ty) + } + SectionKind::Attributes => { + let (display_name, ty) = split_name_and_type(name); + if !is_attribute_display_name(display_name) { + return Self { + boundary_item: true, + ..Self::default() + }; + } + (display_name, ty) + } + SectionKind::Raises => { + if !is_dotted_identifier(name) { + return Self { + boundary_item: true, + ..Self::default() + }; + } + (name, None) + } + SectionKind::Returns | SectionKind::Yields => return Self::default(), + }; + + Self { + boundary_item: true, + item_header: Some(ItemHeader { + display_name, + ty, + inline_description, + }), + } + } +} + +struct ItemHeader<'a> { + display_name: &'a str, + ty: Option<&'a str>, + inline_description: &'a str, +} + +/// Splits at the field delimiter, skipping top-level colons in reST roles. +/// +/// For example, ``:exc:`ValueError`: description`` returns +/// ``(":exc:`ValueError`", " description")``. +fn split_once_at_field_delimiter(line: &str) -> Option<(&str, &str)> { + let mut cursor = Cursor::new(line); + loop { + let (before_colon, after_colon) = split_once_at_top_level_colon(cursor.as_str())?; + cursor.skip_bytes(before_colon.len()); + + if consume_rest_prefix_role(&mut cursor) { + continue; + } + + return Some((&line[..cursor.offset().to_usize()], after_colon)); + } +} + +/// Consumes the prefix-role pattern recognized by the field parser, leaving the cursor unchanged +/// otherwise. +/// +/// For example, this consumes the entire input: +/// +/// ```text +/// :exc:`ValueError` +/// ``` +fn consume_rest_prefix_role(cursor: &mut Cursor<'_>) -> bool { + let mut role = cursor.clone(); + + // First, require the candidate delimiter to be the opening colon of a role. + if !role.eat_char(':') { + return false; + } + + // Role names start with a Unicode alphanumeric run. Rejecting punctuation here preserves the + // first colon in `value::class:` as the field delimiter. + if !role.eat_if(char::is_alphanumeric) { + return false; + } + + // Next, scan the rest of the role name until its closing colon and the opening content + // backtick. + loop { + role.eat_while(char::is_alphanumeric); + if role.eat_char2(':', '`') { + break; + } + + // `-._+:` separators are allowed, but only internally to alphanumeric characters. + if !role.eat_if(|character| matches!(character, '-' | '.' | '_' | '+' | ':')) + || !role.eat_if(char::is_alphanumeric) + { + return false; + } + } + + // Finally, skip the role content so delimiter scanning resumes after its closing backtick. + role.eat_while(|character| character != '`'); + if !role.eat_char('`') { + return false; + } + + *cursor = role; + true +} + +fn is_parameter_display_name(display_name: &str) -> bool { + display_name + .split(',') + .all(|name| is_parameter_name(name.trim())) +} + +fn is_attribute_display_name(display_name: &str) -> bool { + display_name + .split(',') + .all(|name| is_dotted_identifier(name.trim())) +} + +fn strip_indentation(line: &str, width: TextSize) -> &str { + let mut indentation_width = TextSize::default(); + for (index, char) in line.char_indices() { + let next_indentation_width = match char { + ' ' => indentation_width + TextSize::new(1), + '\t' => TextSize::new((indentation_width.to_u32() / 8 + 1) * 8), + _ => return &line[index..], + }; + + if next_indentation_width > width { + return &line[index..]; + } + + indentation_width = next_indentation_width; + if indentation_width == width { + return &line[index + char.len_utf8()..]; + } + } + + "" +} + +impl HeaderKind { + fn is_parameter_section(self) -> bool { + matches!( + self, + Self::Structured( + SectionKind::Parameters + | SectionKind::KeywordArguments + | SectionKind::OtherParameters + ) + ) + } +} + #[cfg(test)] mod tests { use insta::assert_snapshot; use itertools::Itertools; - use ruff_text_size::TextSize; use super::{ - SectionKind, parameter_documentation, parsed_lines, sections, split_once_at_field_delimiter, + BodyFragment, Item, SectionKind, parameter_documentation, sections, + split_once_at_field_delimiter, }; #[test] @@ -925,7 +1269,7 @@ Args: } #[test] - fn keeps_rest_literal_blocks_in_parameter_documentation() { + fn preserves_rest_literal_block_indentation_in_parameter_documentation() { assert_parameter_documentation( "\ Args: @@ -937,7 +1281,7 @@ Args: &[ ( "value", - "Documentation.\nExample::\nArgs:\nnested: Not parameter documentation.", + "Documentation.\nExample::\n Args:\n nested: Not parameter documentation.", ), ("other", "Other documentation."), ], @@ -1164,8 +1508,7 @@ Other Parameters: other: Other. Returns: bool: Result."; - let lines = parsed_lines(raw); - let kinds = sections(&lines) + let kinds = sections(raw) .map(|section| section.kind) .collect::>(); @@ -1181,34 +1524,25 @@ Returns: } #[test] - fn returns_section_body_range_and_header_indent() { + fn returns_section_fragments_and_range() { let raw = " Args: value: Documentation. Methods: helper: Method documentation."; - let lines = parsed_lines(raw); - let sections = sections(&lines) - .map(|section| { - ( - section.kind, - section - .body - .iter() - .map(|line| line.text) - .collect::>(), - &raw[section.range], - section.header_indent, - ) - }) + let sections = sections(raw) + .map(|section| (section.kind, section.fragments, &raw[section.range])) .collect::>(); assert_eq!( sections, vec![( SectionKind::Parameters, - vec![" value: Documentation."], + vec![BodyFragment::Item(Item { + display_name: "value".to_string(), + ty: None, + description: "Documentation.".to_string(), + })], " Args:\n value: Documentation.", - TextSize::new(4), )] ); } @@ -1219,8 +1553,7 @@ Methods: Returns: bool: Result. Additional details."; - let lines = parsed_lines(raw); - let sections = sections(&lines) + let sections = sections(raw) .map(|section| (section.kind, &raw[section.range])) .collect::>(); From 80116e3e6445fb5dece0340343488dcdc22c57be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:03:38 -0700 Subject: [PATCH 066/222] [ty] Sync vendored typeshed stubs (#26840) Close and reopen this PR to trigger CI --------- Co-authored-by: typeshedbot <> --- .../vendor/typeshed/source_commit.txt | 2 +- .../vendor/typeshed/stdlib/_curses_panel.pyi | 8 ++--- .../typeshed/stdlib/asyncio/base_events.pyi | 4 +-- .../vendor/typeshed/stdlib/asyncio/events.pyi | 4 +-- .../vendor/typeshed/stdlib/builtins.pyi | 1 - .../stdlib/multiprocessing/managers.pyi | 5 +-- .../typeshed/stdlib/multiprocessing/pool.pyi | 4 +-- .../vendor/typeshed/stdlib/socket.pyi | 9 ++++-- .../typeshed/stdlib/sqlite3/__init__.pyi | 31 +++++++++++++------ 9 files changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/ty_vendored/vendor/typeshed/source_commit.txt b/crates/ty_vendored/vendor/typeshed/source_commit.txt index 9ef7562807..0d7e1d20a2 100644 --- a/crates/ty_vendored/vendor/typeshed/source_commit.txt +++ b/crates/ty_vendored/vendor/typeshed/source_commit.txt @@ -1 +1 @@ -b9090e99745ac1511d8efd828622b11a9a3623e8 +b00c387c669cb50d5d388d77b74c2e832e147fe8 diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_curses_panel.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/_curses_panel.pyi index 14044b5a01..b9b3f4b7b3 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_curses_panel.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_curses_panel.pyi @@ -1,4 +1,4 @@ -from _curses import window +from _curses import window as _window from typing import Final, final __version__: Final[str] @@ -30,7 +30,7 @@ class panel: def move(self, y: int, x: int, /) -> None: """Move the panel to the screen coordinates (y, x).""" - def replace(self, win: window, /) -> None: + def replace(self, win: _window, /) -> None: """Change the window associated with the panel to the window win.""" def set_userptr(self, obj: object, /) -> None: @@ -48,13 +48,13 @@ class panel: def userptr(self) -> object: """Return the user pointer for the panel.""" - def window(self) -> window: + def window(self) -> _window: """Return the window object associated with the panel.""" def bottom_panel() -> panel: """Return the bottom panel in the panel stack.""" -def new_panel(win: window, /) -> panel: +def new_panel(win: _window, /) -> panel: """Return a panel object, associating it with the given window win. The new panel is placed on top of the panel stack. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.pyi index 0b028ec4ff..62d24a994f 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/base_events.pyi @@ -25,7 +25,7 @@ from asyncio.transports import BaseTransport, DatagramTransport, ReadTransport, from collections.abc import Callable, Iterable, Sequence from concurrent.futures import Executor, ThreadPoolExecutor from contextvars import Context -from socket import AddressFamily, AddressInfo, SocketKind, _Address, _RetAddress, socket +from socket import AddressFamily, AddressInfo, _Address, _GetAddrInfoResult, _RetAddress, socket from typing import IO, Any, Literal, TypeAlias, TypeVar, overload from typing_extensions import TypeVarTuple, Unpack @@ -246,7 +246,7 @@ class BaseEventLoop(AbstractEventLoop): type: int = 0, proto: int = 0, flags: int = 0, - ) -> list[tuple[AddressFamily, SocketKind, int, str, tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes]]]: ... + ) -> _GetAddrInfoResult: ... async def getnameinfo(self, sockaddr: tuple[str, int] | tuple[str, int, int, int], flags: int = 0) -> tuple[str, str]: ... if sys.version_info >= (3, 12): diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.pyi index b60980f228..83b261fbf5 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/asyncio/events.pyi @@ -13,7 +13,7 @@ from abc import ABCMeta, abstractmethod from collections.abc import Callable, Sequence from concurrent.futures import Executor from contextvars import Context -from socket import AddressFamily, AddressInfo, SocketKind, _Address, _RetAddress, socket +from socket import AddressFamily, AddressInfo, _Address, _GetAddrInfoResult, _RetAddress, socket from typing import IO, Any, Literal, Protocol, TypeAlias, TypeVar, overload, type_check_only from typing_extensions import Self, TypeVarTuple, Unpack, deprecated @@ -270,7 +270,7 @@ class AbstractEventLoop: type: int = 0, proto: int = 0, flags: int = 0, - ) -> list[tuple[AddressFamily, SocketKind, int, str, tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes]]]: ... + ) -> _GetAddrInfoResult: ... @abstractmethod async def getnameinfo(self, sockaddr: tuple[str, int] | tuple[str, int, int, int], flags: int = 0) -> tuple[str, str]: ... diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi index 27a072dbae..b48289887a 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/builtins.pyi @@ -3250,7 +3250,6 @@ if sys.version_info >= (3, 15): cls: type[frozendict[str, _VT]], iterable: Iterable[tuple[str, _VT]], /, **kwargs: _VT ) -> frozendict[str, _VT]: ... - def __init__(self) -> None: ... def copy(self) -> frozendict[_KT, _VT]: """Return a shallow copy of the frozendict.""" diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.pyi index f8d428c44e..d73d0ecd88 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/managers.pyi @@ -1,3 +1,4 @@ +import builtins import queue import sys import threading @@ -464,9 +465,9 @@ class SyncManager(BaseManager): @overload def dict(self, iterable: Iterable[tuple[str, _VT]], /, **kwargs: _VT) -> DictProxy[str, _VT]: ... @overload - def dict(self, iterable: Iterable[list[str]], /) -> DictProxy[str, str]: ... + def dict(self, iterable: Iterable[builtins.list[str]], /) -> DictProxy[str, str]: ... @overload - def dict(self, iterable: Iterable[list[bytes]], /) -> DictProxy[bytes, bytes]: ... + def dict(self, iterable: Iterable[builtins.list[bytes]], /) -> DictProxy[bytes, bytes]: ... # Overloads are copied from builtins.list.__init__ @overload diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.pyi index 209864255a..e71c2d2be5 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/multiprocessing/pool.pyi @@ -1,5 +1,5 @@ from collections.abc import Callable, Iterable, Mapping -from multiprocessing.context import DefaultContext, Process +from multiprocessing.context import DefaultContext, Process as _Process from types import GenericAlias, TracebackType from typing import Any, Final, Generic, TypeVar from typing_extensions import Self @@ -59,7 +59,7 @@ class Pool: context: Any | None = None, ) -> None: ... @staticmethod - def Process(ctx: DefaultContext, *args: Any, **kwds: Any) -> Process: ... + def Process(ctx: DefaultContext, *args: Any, **kwds: Any) -> _Process: ... def apply(self, func: Callable[..., _T], args: Iterable[Any] = (), kwds: Mapping[str, Any] = {}) -> _T: """ Equivalent of `func(*args, **kwds)`. diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/socket.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/socket.pyi index 4d96f40f70..625338bfcd 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/socket.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/socket.pyi @@ -184,7 +184,7 @@ from _typeshed import ReadableBuffer, Unused, WriteableBuffer from collections.abc import Iterable from enum import IntEnum, IntFlag from io import BufferedReader, BufferedRWPair, BufferedWriter, IOBase, RawIOBase, TextIOWrapper -from typing import Any, Final, Literal, Protocol, SupportsIndex, overload, type_check_only +from typing import Any, Final, Literal, Protocol, SupportsIndex, TypeAlias, overload, type_check_only from typing_extensions import Self __all__ = [ @@ -1783,9 +1783,14 @@ def create_server( """ # The 5th tuple item is the socket address, for IP4, IP6, or IP6 if Python is compiled with --disable-ipv6, respectively. +_GetAddrInfoResult: TypeAlias = list[ + tuple[Literal[AddressFamily.AF_INET], SocketKind, int, str, tuple[str, int]] + | tuple[Literal[AddressFamily.AF_INET6], SocketKind, int, str, tuple[str, int, int, int] | tuple[int, bytes]] +] + def getaddrinfo( host: bytes | str | None, port: bytes | str | int | None, family: int = 0, type: int = 0, proto: int = 0, flags: int = 0 -) -> list[tuple[AddressFamily, SocketKind, int, str, tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes]]]: +) -> _GetAddrInfoResult: """Resolve host and port into list of address info entries. Translate the host/port argument into a sequence of 5-tuples that contain diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.pyi index 132fb33795..ffb45272af 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/sqlite3/__init__.pyi @@ -303,30 +303,41 @@ class OperationalError(DatabaseError): ... class ProgrammingError(DatabaseError): ... class Warning(Exception): ... +_DataError: TypeAlias = DataError +_DatabaseError: TypeAlias = DatabaseError +_Error: TypeAlias = Error +_IntegrityError: TypeAlias = IntegrityError +_InterfaceError: TypeAlias = InterfaceError +_InternalError: TypeAlias = InternalError +_NotSupportedError: TypeAlias = NotSupportedError +_OperationalError: TypeAlias = OperationalError +_ProgrammingError: TypeAlias = ProgrammingError +_Warning: TypeAlias = Warning + @disjoint_base class Connection: """SQLite database connection object.""" @property - def DataError(self) -> type[DataError]: ... + def DataError(self) -> type[_DataError]: ... @property - def DatabaseError(self) -> type[DatabaseError]: ... + def DatabaseError(self) -> type[_DatabaseError]: ... @property - def Error(self) -> type[Error]: ... + def Error(self) -> type[_Error]: ... @property - def IntegrityError(self) -> type[IntegrityError]: ... + def IntegrityError(self) -> type[_IntegrityError]: ... @property - def InterfaceError(self) -> type[InterfaceError]: ... + def InterfaceError(self) -> type[_InterfaceError]: ... @property - def InternalError(self) -> type[InternalError]: ... + def InternalError(self) -> type[_InternalError]: ... @property - def NotSupportedError(self) -> type[NotSupportedError]: ... + def NotSupportedError(self) -> type[_NotSupportedError]: ... @property - def OperationalError(self) -> type[OperationalError]: ... + def OperationalError(self) -> type[_OperationalError]: ... @property - def ProgrammingError(self) -> type[ProgrammingError]: ... + def ProgrammingError(self) -> type[_ProgrammingError]: ... @property - def Warning(self) -> type[Warning]: ... + def Warning(self) -> type[_Warning]: ... @property def in_transaction(self) -> bool: ... isolation_level: _IsolationLevel From e4a233c409c3cbfa1896d6951a7d266784070561 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 14 Jul 2026 19:32:36 -0700 Subject: [PATCH 067/222] [ty] Resolve PEP 695 aliases in type[...] annotations (#26842) ## Summary Resolve PEP 695 type aliases, including aliases preserved inside unions, before converting `type[...]` arguments to subclass types. Previously, a lazy `Type::TypeAlias` reached subclass conversion unchanged and fell back to `@Todo`, which also suppressed valid assignment and exhaustiveness diagnostics. Closes astral-sh/ty#3996. ## Test Plan Added mdtests. --- .../resources/mdtest/pep695_type_aliases.md | 45 +++++++++++++++++++ .../types/infer/builder/type_expression.rs | 21 ++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md index 69b92227b7..cbcf8f6485 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md @@ -23,6 +23,51 @@ def f() -> None: reveal_type(x) # revealed: int | str ``` +## Type aliases in `type[...]` + +```py +from typing import Type, TypeAliasType, assert_never + +class A: ... +class B: ... + +type IntAlias = int +type ChainedIntAlias = IntAlias +type UnionAlias = A | B +type NestedUnionAlias = IntAlias | str +type GenericAlias[T] = T +ManualIntAlias = TypeAliasType("ManualIntAlias", int) + +def _( + simple: type[IntAlias], + chained: type[ChainedIntAlias], + union: type[UnionAlias], + nested_union: type[NestedUnionAlias], + direct_union: type[IntAlias | str], + generic: type[GenericAlias[int]], + manual: type[ManualIntAlias], + typing_type: Type[IntAlias], +): + reveal_type(simple) # revealed: type[int] + reveal_type(chained) # revealed: type[int] + reveal_type(union) # revealed: type[A | B] + reveal_type(nested_union) # revealed: type[int | str] + reveal_type(direct_union) # revealed: type[int | str] + reveal_type(generic) # revealed: type[int] + reveal_type(manual) # revealed: type[int] + reveal_type(typing_type) # revealed: type[int] + +# error: [invalid-assignment] +bad: type[IntAlias] = str + +def exhaust_union(value: type[UnionAlias]) -> str: + if issubclass(value, A): + return "A" + if issubclass(value, B): + return "B" + assert_never(value) +``` + ## `__value__` attribute ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 75b82c338f..b189e3b1a2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1224,8 +1224,14 @@ impl<'db> TypeInferenceBuilder<'db, '_> { SubclassOfType::subclass_of_unknown() }; - let infer_type_argument = |builder: &mut Self, slice: &ast::Expr| { - let slice_ty = builder.infer_type_expression(slice); + let subclass_of_type_argument = |builder: &Self, slice: &ast::Expr, slice_ty: Type<'db>| { + let slice_ty = slice_ty.resolve_type_alias(builder.db()); + let slice_ty = match slice_ty { + Type::Union(union) if union.has_aliases(builder.db()) => { + union.expand_aliases(builder.db()) + } + _ => slice_ty, + }; SubclassOfType::try_from_instance(builder.db(), slice_ty).unwrap_or_else(|| { match slice_ty { Type::Callable(_) => invalid_type_argument(builder, slice), @@ -1234,6 +1240,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }) }; + let infer_type_argument = |builder: &mut Self, slice: &ast::Expr| { + let slice_ty = builder.infer_type_expression(slice); + subclass_of_type_argument(builder, slice, slice_ty) + }; + match slice { ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::StringLiteral(_) => { infer_type_argument(self, slice) @@ -1327,6 +1338,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ); invalid_type_argument(self, slice) } + value_ty @ Type::KnownInstance(KnownInstanceType::TypeAliasType( + TypeAliasType::PEP695(_), + )) => { + let slice_ty = self.infer_subscript_type_expression(subscript, value_ty); + subclass_of_type_argument(self, slice, slice_ty) + } _ => { self.infer_expression(parameters, TypeContext::default()); todo_type!("unsupported nested subscript in type[X]") From 923f61246a79674bc015fa50154174861c24260c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:13 -0700 Subject: [PATCH 068/222] Update astral-sh/setup-uv action to v8.3.2 (#26820) --- .github/workflows/ci.yaml | 30 ++++++++++---------- .github/workflows/daily_fuzz.yaml | 2 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/publish-versions.yml | 2 +- .github/workflows/sync_typeshed.yaml | 6 ++-- .github/workflows/ty-ecosystem-analyzer.yaml | 4 +-- .github/workflows/ty-ecosystem-report.yaml | 2 +- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 257805453d..2e3ea99eb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -345,7 +345,7 @@ jobs: cargo-nextest cargo-insta - name: "Install uv" - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" enable-cache: "true" @@ -409,7 +409,7 @@ jobs: with: tool: cargo-nextest - name: "Install uv" - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" enable-cache: "true" @@ -448,7 +448,7 @@ jobs: with: tool: cargo-nextest - name: "Install uv" - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" enable-cache: "true" @@ -558,7 +558,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -600,7 +600,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - name: "Install Rust toolchain" @@ -645,7 +645,7 @@ jobs: ref: ${{ github.event.pull_request.base.ref }} persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: python-version: ${{ env.PYTHON_VERSION }} activate-environment: true @@ -761,7 +761,7 @@ jobs: # Faster to do this separately than to use `fetch-depth: 0` with `actions/checkout` - name: Fetch full history without tags run: git fetch --no-tags --filter=blob:none --unshallow origin - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -827,7 +827,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -880,7 +880,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -916,7 +916,7 @@ jobs: - name: "Install Rust toolchain" run: rustup show - name: Install uv - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: python-version: 3.13 activate-environment: true @@ -995,7 +995,7 @@ jobs: # installation fails on 3.13 and newer python-version: "3.12" - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: Install ruff-lsp dependencies run: | @@ -1075,7 +1075,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" @@ -1175,7 +1175,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" @@ -1226,7 +1226,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" @@ -1279,7 +1279,7 @@ jobs: with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" diff --git a/.github/workflows/daily_fuzz.yaml b/.github/workflows/daily_fuzz.yaml index e0f500a6da..18ce44c002 100644 --- a/.github/workflows/daily_fuzz.yaml +++ b/.github/workflows/daily_fuzz.yaml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - name: "Install Rust toolchain" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 83ad92e0a1..70f49e0d93 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -22,7 +22,7 @@ jobs: id-token: write steps: - name: "Install uv" - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/publish-versions.yml b/.github/workflows/publish-versions.yml index 9abb85fa11..051e08ae7a 100644 --- a/.github/workflows/publish-versions.yml +++ b/.github/workflows/publish-versions.yml @@ -31,7 +31,7 @@ jobs: run: git clone https://${{ secrets.ASTRAL_VERSIONS_PAT }}@github.com/astral-sh/versions.git astral-versions - name: "Install uv" - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - name: "Update versions" env: diff --git a/.github/workflows/sync_typeshed.yaml b/.github/workflows/sync_typeshed.yaml index 5400dd3a8f..89b4cbb559 100644 --- a/.github/workflows/sync_typeshed.yaml +++ b/.github/workflows/sync_typeshed.yaml @@ -84,7 +84,7 @@ jobs: run: | git config --global user.name typeshedbot git config --global user.email '<>' - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - name: Sync typeshed stubs @@ -140,7 +140,7 @@ jobs: with: persist-credentials: true ref: ${{ env.UPSTREAM_BRANCH}} - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - name: Setup git @@ -182,7 +182,7 @@ jobs: with: persist-credentials: true ref: ${{ env.UPSTREAM_BRANCH}} - - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" - name: Setup git diff --git a/.github/workflows/ty-ecosystem-analyzer.yaml b/.github/workflows/ty-ecosystem-analyzer.yaml index 36d8cb3c51..54b07ae957 100644 --- a/.github/workflows/ty-ecosystem-analyzer.yaml +++ b/.github/workflows/ty-ecosystem-analyzer.yaml @@ -125,7 +125,7 @@ jobs: EXCLUDE_NEWER: ${{ github.event.pull_request.updated_at }} steps: - name: Install the latest version of uv - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" ignore-empty-workdir: true @@ -185,7 +185,7 @@ jobs: timeout-minutes: 5 steps: - name: Install the latest version of uv - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.11.28" ignore-empty-workdir: true diff --git a/.github/workflows/ty-ecosystem-report.yaml b/.github/workflows/ty-ecosystem-report.yaml index 0844ef3ba0..a6fdab99d6 100644 --- a/.github/workflows/ty-ecosystem-report.yaml +++ b/.github/workflows/ty-ecosystem-report.yaml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true version: "0.11.28" From bfb30741c209c939c6cf1c1fea9c94ee6f19af10 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:19 -0700 Subject: [PATCH 069/222] Update CodSpeedHQ/action action to v4.18.2 (#26821) --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e3ea99eb9..d44e0e257c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1091,7 +1091,7 @@ jobs: run: cargo codspeed build -m simulation -m memory --features "codspeed,ruff_instrumented" --profile profiling --no-default-features -p ruff_benchmark --bench formatter --bench lexer --bench linter --bench parser - name: "Run benchmarks" - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 with: mode: "simulation,memory" run: cargo codspeed run @@ -1195,7 +1195,7 @@ jobs: run: find target/codspeed -type f -exec chmod +x {} + - name: "Run benchmarks" - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 with: mode: ${{ matrix.mode }} run: cargo codspeed run --bench "${{ matrix.target }}" "${{ matrix.filter }}" @@ -1299,7 +1299,7 @@ jobs: run: find target/codspeed -type f -exec chmod +x {} + - name: "Run benchmarks" - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 env: # enabling walltime flamegraphs adds ~6 minutes to the CI time, and they don't # appear to provide much useful insight for our walltime benchmarks right now From 8d1d54c61c40f9b48cfd0db268c5447c515ba207 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:33 -0700 Subject: [PATCH 070/222] Update dependency prek to v0.4.8 (#26823) --- pyproject.toml | 2 +- uv.lock | 114 ++++++++++++++++++++++++------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ad90e2532..b8aab55786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ exclude = [ [dependency-groups] dev = [ - "prek==0.4.5", + "prek==0.4.8", ] release = [ "rooster==0.1.1", diff --git a/uv.lock b/uv.lock index fe94650f2f..ba760a617d 100644 --- a/uv.lock +++ b/uv.lock @@ -30,8 +30,8 @@ name = "anyio" version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -43,7 +43,7 @@ name = "anysqlite" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } wheels = [ @@ -64,7 +64,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -176,11 +176,11 @@ name = "hishel" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "anysqlite", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "msgpack", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "anysqlite" }, + { name = "httpx" }, + { name = "msgpack" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468, upload-time = "2025-10-18T13:32:41.854Z" } wheels = [ @@ -192,8 +192,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "h11", marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -205,10 +205,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "httpcore", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -229,7 +229,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.12'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -338,26 +338,26 @@ wheels = [ [[package]] name = "prek" -version = "0.4.5" +version = "0.4.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/65/23866f43521d31173879aa74bb3a2df50ab7f3f74cdb4eaa31b8f446c7ca/prek-0.4.5.tar.gz", hash = "sha256:2be7bcf839de19a0144ed5a5aadf73bc5899cf6823bb1c58cf1d45ae389c201a", size = 482566, upload-time = "2026-06-15T11:36:48.299Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/cb/a9eedf9a35ca6ec72f12af2b4392d7f757bb24863b7b7af4523f939cf3fa/prek-0.4.5-py3-none-linux_armv6l.whl", hash = "sha256:f7517774c72b001573520dc7111156779fd3e5b4452c11f09ff53c71a067e835", size = 5618105, upload-time = "2026-06-15T11:36:21.998Z" }, - { url = "https://files.pythonhosted.org/packages/30/a7/c96c06f17db7da0a57be2be4c229aa00b525bca8001c9c765663b339cbb7/prek-0.4.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aca9fa995536036a0171bcf7a4db96dc0a14f480054eda1d7d1c2e7739650993", size = 5972998, upload-time = "2026-06-15T11:36:41.12Z" }, - { url = "https://files.pythonhosted.org/packages/28/f1/721695355cdaa44be6f091e3a77fb9c72ed60289520f78b2f8c9a7197bdd/prek-0.4.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66877ff21ae9d548f0f7e56fab8e65f1500a74a810e7749188c3f35a4a1b911b", size = 5525098, upload-time = "2026-06-15T11:36:30.127Z" }, - { url = "https://files.pythonhosted.org/packages/9b/1b/a334e1bb5361b49adf52b5ac7b6532018940f9f0f253437e8f43c3c1f7f3/prek-0.4.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:50697089a86a78d16f087c1912a2f3bc2bea82319a220fac52cc8e3ec9fc0426", size = 5793732, upload-time = "2026-06-15T11:36:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/28/8c/aff94d276e91207a87cedff7cfefdd4aca20444137cca77bf53fffebe77a/prek-0.4.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:590427a42a3c1e5064487a0dc91167ae0c8a52168e77f574758ef9b138fcfd61", size = 5521719, upload-time = "2026-06-15T11:36:39.383Z" }, - { url = "https://files.pythonhosted.org/packages/4f/73/cfb0c5c909442050a8357e26233f7e511ba8e0d2f4b0bdc460065d62beb6/prek-0.4.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd98b986767dafdb6b4305b563ee5a3a8f13bd3c78b98d708626815ea9f147f", size = 5922623, upload-time = "2026-06-15T11:36:18.063Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ad/ff9d26551ba80d190bd08c6341176a5d56d4e6de9c2ebf077793d4adbb78/prek-0.4.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fccd11613ae92619d1ecda0ab3359ceebeb38898909ec84a8d383733d12158cc", size = 6722071, upload-time = "2026-06-15T11:36:43.086Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/11d1dfd66c919953fe89ae2fdedd4f413ee923883043816d35982177bb75/prek-0.4.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14109d37b33e5529db41a3539d4f8f72d295f6eeddede3964994d898b8cec05c", size = 6176454, upload-time = "2026-06-15T11:36:33.803Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/9749f25c2e0ee5225f812457b888acef301e0ccce64bebcda2ac1d04abee/prek-0.4.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:40d262418105b2ede9836593a1927fc927cc8093c432e998640964102196996e", size = 5791133, upload-time = "2026-06-15T11:36:23.891Z" }, - { url = "https://files.pythonhosted.org/packages/c7/72/5e0344bab1eacf813a5b1b082cb4c6253930096166dad51c1cccee0a4f83/prek-0.4.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a586d14c3b852fdee1c3dcd0b9cb0915db9f9d054334b854fd9470bf68edf129", size = 5658098, upload-time = "2026-06-15T11:36:44.862Z" }, - { url = "https://files.pythonhosted.org/packages/be/a5/1f406e0362dd0f18ba09a562d50d7c04a70ac05d350b1ab6fba36ca3e9f0/prek-0.4.5-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a8ed0d28f3e7790e4402a9324c386509066df6e67cc587f7406f9a245b97b7e8", size = 5498634, upload-time = "2026-06-15T11:36:31.828Z" }, - { url = "https://files.pythonhosted.org/packages/c7/df/b0cbf0fa527330188390b7b6c8d279cd5e509923262d0a6c5cc44bbdf103/prek-0.4.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:86f76bd3d2ecf6fd9034d75c62ff4c786eb11d0dd0a1f79bbb4343b023e12769", size = 5784840, upload-time = "2026-06-15T11:36:37.481Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d7/977ee3c622c906677dd94187a00392ce2dd76035486b3a3b1b5a5267dd34/prek-0.4.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e491a1a4641d91d8b03dcce5588397e76d2a5b432c9b0a6c70475972b4512ab4", size = 6300384, upload-time = "2026-06-15T11:36:27.602Z" }, - { url = "https://files.pythonhosted.org/packages/79/fa/43b1d761381dc1c7eeb8f2235c66e902970d4b2bff2dec0f02836c085769/prek-0.4.5-py3-none-win32.whl", hash = "sha256:7546989b2403c96137bd79d19ebfe21facb87266cefe819db2458c3b9b23f350", size = 5287935, upload-time = "2026-06-15T11:36:20.293Z" }, - { url = "https://files.pythonhosted.org/packages/f5/fe/59b5eb3124f5a4cc255a93857b9ab42402635b273f157e91de23bfa40e8f/prek-0.4.5-py3-none-win_amd64.whl", hash = "sha256:8b2ac9227504371d97338215b344184cb0b31ca94113515a3a90c509c6c5a707", size = 5682560, upload-time = "2026-06-15T11:36:25.865Z" }, - { url = "https://files.pythonhosted.org/packages/97/0e/589ff0eab9034909b1ec8654ee03483797305fb743b3554ce6140d82da9d/prek-0.4.5-py3-none-win_arm64.whl", hash = "sha256:646a86a1a082dbd99fed96314b1064f5644bb34c1f4037a63547a18e2160fb86", size = 5509019, upload-time = "2026-06-15T11:36:46.595Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" }, + { url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" }, + { url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" }, + { url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" }, ] [[package]] @@ -374,10 +374,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.12'" }, - { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -389,7 +389,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -519,7 +519,7 @@ name = "pygit2" version = "1.19.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.12'" }, + { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/44/415aa93422b4bfc21a6448acb7e16280d5f33a9a3fae38a384e37b046ae4/pygit2-1.19.3.tar.gz", hash = "sha256:a543e6d4ebb43825564935758dc234e770016fed673b84370d46ae9580558831", size = 810489, upload-time = "2026-06-13T08:06:04.982Z" } wheels = [ @@ -594,8 +594,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -607,14 +607,14 @@ name = "rooster" version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hishel", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "marko", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "pygit2", marker = "python_full_version >= '3.12'" }, - { name = "tqdm", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, + { name = "hishel" }, + { name = "httpx" }, + { name = "marko" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pygit2" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/02/8ce565271dc52bd0d0d812043b12ec60111d947f81dc30301d19d7bfd453/rooster-0.1.1.tar.gz", hash = "sha256:c9823122f0c2b035985e70384323cdd353477af988e0f065bc302646a49da482", size = 18608, upload-time = "2025-10-29T15:18:49.478Z" } wheels = [ @@ -637,7 +637,7 @@ release = [ [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "prek", marker = "python_full_version >= '3.12'", specifier = "==0.4.5" }] +dev = [{ name = "prek", marker = "python_full_version >= '3.12'", specifier = "==0.4.8" }] release = [{ name = "rooster", marker = "python_full_version >= '3.12'", specifier = "==0.1.1" }] [[package]] @@ -654,7 +654,7 @@ name = "tqdm" version = "4.68.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } wheels = [ @@ -666,10 +666,10 @@ name = "typer" version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, - { name = "shellingham", marker = "python_full_version >= '3.12'" }, + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ @@ -690,7 +690,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From 80a09c8e2f702033b96525286021e7e06d9863d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:01:41 -0700 Subject: [PATCH 071/222] Update dependency ruff to v0.15.21 (#26824) --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6212639b14..021729ca97 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ PyYAML==6.0.3 -ruff==0.15.20 +ruff==0.15.21 mkdocs==1.6.1 mkdocs-material==9.7.6 mkdocs-redirects==1.2.3 From f261ff8dc048d22d9a7ab803ad88bb3bd8354f84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:03:35 -0700 Subject: [PATCH 072/222] Update Rust crate arrayvec to v0.7.8 (#26826) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2db3ed6959..d4e7b2eba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,9 +189,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "assert_fs" From d05b4b9cfaf014c5a9d97015284e04d43b999d71 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:03:56 -0700 Subject: [PATCH 073/222] Update Rust crate ignore to v0.4.27 (#26827) --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4e7b2eba7..989216f833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,7 +553,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -672,7 +672,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -1024,7 +1024,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -1104,7 +1104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -1586,9 +1586,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" dependencies = [ "crossbeam-deque", "globset", @@ -3844,7 +3844,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -4243,7 +4243,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -5447,7 +5447,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] From aa4acef1ba9e157f67f64621a85db8386533de39 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:04:02 -0700 Subject: [PATCH 074/222] Update Rust crate indicatif to v0.18.6 (#26828) --- Cargo.lock | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 989216f833..1f1a1b779c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,13 +698,12 @@ checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" [[package]] name = "console" -version = "0.16.1" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width", "windows-sys 0.61.0", ] @@ -1634,9 +1633,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.5" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", From d49c1918b86b37a65a3ee34ce4f5063022083300 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:04:10 -0700 Subject: [PATCH 075/222] Update Rust crate quick-junit to v0.6.1 (#26829) --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f1a1b779c..be25b9891f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2770,9 +2770,9 @@ dependencies = [ [[package]] name = "quick-junit" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e64c58c4c88fc1045e8fe98a1b7cec3643187e3dd678f9bbcdd8f12a6933d6" +checksum = "8216516c957e00535b3c91770524c219dd7df90dec439a182547f750dfe41591" dependencies = [ "chrono", "indexmap", @@ -2785,9 +2785,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] From b17367c6cf9e8c699922a76add33a6dd0cbf0d8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:04:22 -0700 Subject: [PATCH 076/222] Update Rust crate rand to v0.10.2 (#26830) --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be25b9891f..1bda9f1732 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2798,7 +2798,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ - "rand 0.10.1", + "rand 0.10.2", ] [[package]] @@ -2883,9 +2883,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3451,7 +3451,7 @@ name = "ruff_notebook" version = "0.0.4" dependencies = [ "anyhow", - "rand 0.10.1", + "rand 0.10.2", "ruff_diagnostics", "ruff_source_file", "ruff_text_size", @@ -4239,7 +4239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.52.0", From e54bb616e406bbc0caf2a9c944a905f47587767d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:06 -0700 Subject: [PATCH 077/222] Update Rust crate rkyv to v0.8.17 (#26831) --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bda9f1732..8bd503bfcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3023,9 +3023,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" dependencies = [ "bytecheck", "bytes", @@ -3042,9 +3042,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" dependencies = [ "proc-macro2", "quote", From 3f02b6e517d3d11f072dc42756197af3b9964fa0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:14 -0700 Subject: [PATCH 078/222] Update Rust crate rustc-hash to v2.1.3 (#26832) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bd503bfcf..5743cb3cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3823,9 +3823,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-stable-hash" From 36ace2096c0cbe1501f27f7301ce78a038e0941a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:21 -0700 Subject: [PATCH 079/222] Update taiki-e/install-action action to v2.82.10 (#26833) --- .github/workflows/ci.yaml | 16 ++++++++-------- .github/workflows/sync_typeshed.yaml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d44e0e257c..1ecef5e6b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -339,7 +339,7 @@ jobs: - name: "Install mold" uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - name: "Install cargo nextest and insta" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: | cargo-nextest @@ -405,7 +405,7 @@ jobs: - name: "Install mold" uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - name: "Install cargo nextest" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-nextest - name: "Install uv" @@ -444,7 +444,7 @@ jobs: - name: "Install Rust toolchain" run: rustup show - name: "Install cargo nextest" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-nextest - name: "Install uv" @@ -1083,7 +1083,7 @@ jobs: run: rustup show - name: "Install codspeed" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-codspeed @@ -1122,7 +1122,7 @@ jobs: run: rustup show - name: "Install codspeed" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-codspeed @@ -1180,7 +1180,7 @@ jobs: version: "0.11.28" - name: "Install codspeed" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-codspeed @@ -1234,7 +1234,7 @@ jobs: run: rustup show - name: "Install codspeed" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-codspeed @@ -1284,7 +1284,7 @@ jobs: version: "0.11.28" - name: "Install codspeed" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: cargo-codspeed diff --git a/.github/workflows/sync_typeshed.yaml b/.github/workflows/sync_typeshed.yaml index 89b4cbb559..558c64a3aa 100644 --- a/.github/workflows/sync_typeshed.yaml +++ b/.github/workflows/sync_typeshed.yaml @@ -268,7 +268,7 @@ jobs: - name: "Install mold" uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - name: "Install cargo nextest and insta" - uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2.82.7 + uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c # v2.82.10 with: tool: | cargo-nextest From b42f7d99c595441a77fb0da56b0ffbaeebc01b72 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:25 -0700 Subject: [PATCH 080/222] Update docker/build-push-action action to v7.3.0 (#26834) --- .github/workflows/build-docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 2898cf4fd9..f666b2c6b1 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -86,7 +86,7 @@ jobs: # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ - name: Build and push by digest id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: ${{ matrix.platform }} @@ -267,7 +267,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/amd64,linux/arm64 From a8ea31b6a9492839f24cb858f85cb2ab24d85921 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:31 -0700 Subject: [PATCH 081/222] Update docker/login-action action to v4.4.0 (#26835) --- .github/workflows/build-docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index f666b2c6b1..00a4c024b3 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -46,7 +46,7 @@ jobs: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} with: registry: ghcr.io @@ -142,7 +142,7 @@ jobs: type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }} type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }} - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -204,7 +204,7 @@ jobs: steps: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -322,7 +322,7 @@ jobs: type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }} type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }} - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 8da4773e747ef30124ec1f65c4f99b6264b591e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:36 -0700 Subject: [PATCH 082/222] Update docker/metadata-action action to v6.2.0 (#26836) --- .github/workflows/build-docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 00a4c024b3..26d88de66c 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -70,7 +70,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.RUFF_BASE_IMG }} # Defining this makes sure the org.opencontainers.image.version OCI label becomes the actual release version and not the branch name @@ -134,7 +134,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.RUFF_BASE_IMG }} # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version @@ -254,7 +254,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 # ghcr.io prefers index level annotations env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index @@ -312,7 +312,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index with: From 5bae97218a34bc5c0144a2ed96386af17002ff0d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:05:43 -0700 Subject: [PATCH 083/222] Update docker/setup-buildx-action action to v4.2.0 (#26837) --- .github/workflows/build-docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 26d88de66c..d60b9c497b 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -44,7 +44,7 @@ jobs: submodules: recursive persist-credentials: false - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} @@ -130,7 +130,7 @@ jobs: pattern: digests-* merge-multiple: true - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Extract metadata (tags, labels) for Docker id: meta @@ -202,7 +202,7 @@ jobs: - debian:trixie-slim,trixie-slim,debian-slim - buildpack-deps:trixie,trixie,debian steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: @@ -308,7 +308,7 @@ jobs: pattern: digests-* merge-multiple: true - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Extract metadata (tags, labels) for Docker id: meta From 1659a424ce886dbd81d3aa6170ac2d5ebc0db2a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:05:57 +0000 Subject: [PATCH 084/222] Update dependency mdformat-mkdocs to v5.2.1 (#26822) --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 021729ca97..d9c7b2d11f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,6 @@ mkdocs==1.6.1 mkdocs-material==9.7.6 mkdocs-redirects==1.2.3 mdformat==1.0.0 -mdformat-mkdocs==5.2.0 +mdformat-mkdocs==5.2.1 mkdocs-github-admonitions-plugin @ git+https://github.com/PGijsbers/admonitions.git#7343d2f4a92e4d1491094530ef3d0d02d93afbb7 mkdocs-llmstxt==0.2.0 From e9c64c6f3876ba3c9acd021f412ac0a1a68be5c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:06:02 -0700 Subject: [PATCH 085/222] Update Rust crate memchr to v2.8.3 (#26841) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5743cb3cf5..318d7de6e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2109,9 +2109,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" From 05f38fbfb0d24304ac87714dd74a1670478acffa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:06:08 -0700 Subject: [PATCH 086/222] Update prek dependencies (#26825) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6773919191..8a57174691 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,7 +102,7 @@ repos: - id: mdformat language: python # means renovate will also update `additional_dependencies` additional_dependencies: - - mdformat-mkdocs==5.2.0 + - mdformat-mkdocs==5.2.1 - mdformat-footnote==0.1.3 exclude: | (?x)^( @@ -119,7 +119,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: c59bba8fb259db0fec2bbb77ad8ba51ea7341b56 # frozen: v0.15.20 + rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # frozen: v0.15.21 hooks: - id: ruff-format exclude: crates/ty_python_semantic/resources/corpus/ @@ -127,7 +127,7 @@ repos: # Priority 1: Second-pass fixers (e.g., markdownlint-fix runs after mdformat). - repo: https://github.com/astral-sh/ruff-pre-commit - rev: c59bba8fb259db0fec2bbb77ad8ba51ea7341b56 # frozen: v0.15.20 + rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # frozen: v0.15.21 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] @@ -150,7 +150,7 @@ repos: # Priority 2: ruffen-docs runs after markdownlint-fix (both modify markdown). - repo: https://github.com/astral-sh/ruff-pre-commit - rev: c59bba8fb259db0fec2bbb77ad8ba51ea7341b56 # frozen: v0.15.20 + rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # frozen: v0.15.21 hooks: - id: ruff-format name: mdtest format From bdf1320bba95522ff328511a2caecc9daff212c3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 23:12:16 -0700 Subject: [PATCH 087/222] Update Rust toolchain to 1.97 and MSRV to 1.95 (#26844) --- Cargo.toml | 2 +- crates/ruff/src/commands/config.rs | 4 +- crates/ruff/src/commands/version.rs | 2 +- crates/ruff/src/lib.rs | 45 +++++++++---------- crates/ruff/tests/integration_test.rs | 2 +- .../rules/fastapi_unused_path_parameter.rs | 14 +++--- .../rules/custom_type_var_for_self.rs | 4 +- .../pyupgrade/rules/outdated_version_block.rs | 6 +-- .../rules/unnecessary_encode_utf8.rs | 2 +- .../src/rules/ruff/rules/sort_dunder_slots.rs | 10 ++--- crates/ruff_linter/src/source_kind.rs | 4 +- crates/ruff_python_ast/src/helpers.rs | 30 +++---------- crates/ruff_python_formatter/src/range.rs | 44 ++++++++---------- .../ruff_python_formatter/tests/fixtures.rs | 2 +- crates/ruff_python_parser/src/error.rs | 2 +- .../ruff_python_semantic/src/cfg/visualize.rs | 2 +- crates/ruff_server/tests/e2e/main.rs | 2 +- crates/ty/src/lib.rs | 2 +- crates/ty_module_resolver/src/resolve.rs | 2 +- crates/ty_project/src/glob.rs | 2 +- crates/ty_python_semantic/src/suppression.rs | 2 +- .../src/types/infer/builder.rs | 4 +- .../src/types/infer/builder/function.rs | 2 +- .../post_inference/overloaded_function.rs | 8 ++-- .../post_inference/type_param_validation.rs | 6 +-- crates/ty_python_semantic/src/types/narrow.rs | 6 +-- .../ty_python_semantic/src/types/overrides.rs | 6 +-- .../src/types/protocol_class.rs | 4 +- crates/ty_server/tests/e2e/main.rs | 2 +- rust-toolchain.toml | 2 +- 30 files changed, 93 insertions(+), 132 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6f6474b834..17d0e852ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] # Please update rustfmt.toml when bumping the Rust edition edition = "2024" -rust-version = "1.94" +rust-version = "1.95" homepage = "https://docs.astral.sh/ruff" documentation = "https://docs.astral.sh/ruff" repository = "https://github.com/astral-sh/ruff" diff --git a/crates/ruff/src/commands/config.rs b/crates/ruff/src/commands/config.rs index f6c0054839..751e15876c 100644 --- a/crates/ruff/src/commands/config.rs +++ b/crates/ruff/src/commands/config.rs @@ -16,7 +16,7 @@ pub(crate) fn config(key: Option<&str>, format: HelpFormat) -> Result<()> { } HelpFormat::Json => { - println!("{}", &serde_json::to_string_pretty(&metadata)?); + println!("{}", serde_json::to_string_pretty(&metadata)?); } } } @@ -30,7 +30,7 @@ pub(crate) fn config(key: Option<&str>, format: HelpFormat) -> Result<()> { } HelpFormat::Json => { - println!("{}", &serde_json::to_string_pretty(&entry)?); + println!("{}", serde_json::to_string_pretty(&entry)?); } }, }, diff --git a/crates/ruff/src/commands/version.rs b/crates/ruff/src/commands/version.rs index 3361071206..d750e90815 100644 --- a/crates/ruff/src/commands/version.rs +++ b/crates/ruff/src/commands/version.rs @@ -11,7 +11,7 @@ pub(crate) fn version(output_format: HelpFormat) -> Result<()> { match output_format { HelpFormat::Text => { - writeln!(stdout, "ruff {}", &version_info)?; + writeln!(stdout, "ruff {version_info}")?; } HelpFormat::Json => { serde_json::to_writer_pretty(stdout, &version_info)?; diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index 34bccc3b8a..d7395c1ab9 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -413,32 +413,27 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result { - let Some(change_kind) = change_detected(&event?) else { - continue; - }; - - if matches!(change_kind, ChangeKind::Configuration) { - pyproject_config = - resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?; - } - Printer::clear_screen()?; - printer.write_to_user("File change detected...\n"); - - let diagnostics = commands::check::check( - &files, - &pyproject_config, - &config_arguments, - cache.into(), - noqa.into(), - fix_mode, - unsafe_fixes, - )?; - printer.write_continuously(&mut writer, &diagnostics, preview)?; - } - Err(err) => return Err(err.into()), + let Some(change_kind) = change_detected(&rx.recv()??) else { + continue; + }; + + if matches!(change_kind, ChangeKind::Configuration) { + pyproject_config = + resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?; } + Printer::clear_screen()?; + printer.write_to_user("File change detected...\n"); + + let diagnostics = commands::check::check( + &files, + &pyproject_config, + &config_arguments, + cache.into(), + noqa.into(), + fix_mode, + unsafe_fixes, + )?; + printer.write_continuously(&mut writer, &diagnostics, preview)?; } } else { // Generate lint violations. diff --git a/crates/ruff/tests/integration_test.rs b/crates/ruff/tests/integration_test.rs index 4eacdd6c8a..66d1729017 100644 --- a/crates/ruff/tests/integration_test.rs +++ b/crates/ruff/tests/integration_test.rs @@ -1818,7 +1818,7 @@ fn check_input_from_argfile() -> Result<()> { )?; // Generate the args with the argfile notation - let argfile = format!("@{}", &input_file_path.display()); + let argfile = format!("@{}", input_file_path.display()); let mut cmd = RuffCheck::default().filename(argfile.as_ref()).build(); insta::with_settings!({filters => vec![ (file_a_path.display().to_string().as_str(), "/path/to/a.py"), diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs index 24be9c37df..64f1fac64d 100644 --- a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs @@ -399,19 +399,17 @@ impl<'a> Dependency<'a> { .map(|name| name.id.as_str()) }) .collect() - } else if let Some(method_def) = class_def - .body - .iter() - .filter_map(|stmt| stmt.as_function_def_stmt()) - .find(|func_def| func_def.name.as_str() == method_name) - { + } else { + let method_def = class_def + .body + .iter() + .filter_map(|stmt| stmt.as_function_def_stmt()) + .find(|func_def| func_def.name.as_str() == method_name)?; // Skip `self` parameter non_posonly_non_variadic_parameters(method_def) .skip(1) .map(|param| param.name().as_str()) .collect() - } else { - return None; }; Some(Self::Class(parameter_names)) diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs index b22a050ad0..ef7e47ceb2 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_for_self.rs @@ -101,14 +101,14 @@ impl Violation for CustomTypeVarForSelf { fn message(&self) -> String { format!( "Use `Self` instead of custom TypeVar `{}`", - &self.typevar_name + self.typevar_name ) } fn fix_title(&self) -> Option { Some(format!( "Replace TypeVar `{}` with `Self`", - &self.typevar_name + self.typevar_name )) } } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs index dfa945fab1..06ad8060e9 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs @@ -294,11 +294,11 @@ fn fix_always_false_branch( range, .. }) => { - debug_assert!( + debug_assert_eq!( checker .locator() - .slice(TextRange::at(range.start(), "elif".text_len())) - == "elif" + .slice(TextRange::at(range.start(), "elif".text_len())), + "elif" ); let end_location = range.start() + ("elif".text_len() - "if".text_len()); Some(Fix::unsafe_edit(Edit::deletion( diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs index a5605391bd..8c418ae40c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs @@ -133,7 +133,7 @@ fn replace_with_bytes_literal(locator: &Locator, call: &ast::ExprCall, tokens: & let _ = write!( &mut replacement, "b{}", - &string.trim_start_matches('u').trim_start_matches('U') + string.trim_start_matches('u').trim_start_matches('U') ); } _ => { diff --git a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs index 4038a9508a..0fcd0ca5fb 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs @@ -240,13 +240,9 @@ impl<'a> StringLiteralDisplay<'a> { ast::Expr::Dict(dict) => { let mut narrowed_keys = Vec::with_capacity(dict.len()); for key in dict.iter_keys() { - if let Some(key) = key { - // This is somewhat unfortunate, - // *but* using a dict for __slots__ is very rare - narrowed_keys.push(key.to_owned()); - } else { - return None; - } + // This is somewhat unfortunate, + // *but* using a dict for __slots__ is very rare + narrowed_keys.push(key?.to_owned()); } // If `None` was present in the keys, it indicates a "** splat", .e.g // `__slots__ = {"foo": "bar", **other_dict}` diff --git a/crates/ruff_linter/src/source_kind.rs b/crates/ruff_linter/src/source_kind.rs index 7b029ce137..35e660190e 100644 --- a/crates/ruff_linter/src/source_kind.rs +++ b/crates/ruff_linter/src/source_kind.rs @@ -235,8 +235,8 @@ impl std::fmt::Display for SourceKindDiff<'_> { || (format!("cell {idx}"), format!("cell {idx}")), |path| { ( - format!("{}:cell {}", &fs::relativize_path(path), idx), - format!("{}:cell {}", &fs::relativize_path(path), idx), + format!("{}:cell {}", fs::relativize_path(path), idx), + format!("{}:cell {}", fs::relativize_path(path), idx), ) }, ); diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index d3e760b1a5..d0aa410fd0 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -310,18 +310,10 @@ where range: _, node_index: _, }) - | Expr::List(ast::ExprList { - elts, - range: _, - node_index: _, - .. - }) - | Expr::Tuple(ast::ExprTuple { - elts, - range: _, - node_index: _, - .. - }) => elts.iter().any(|expr| any_over_expr(expr, &mut *func)), + | Expr::List(ast::ExprList { elts, .. }) + | Expr::Tuple(ast::ExprTuple { elts, .. }) => { + elts.iter().any(|expr| any_over_expr(expr, &mut *func)) + } Expr::ListComp(ast::ExprListComp { elt, generators, @@ -380,18 +372,8 @@ where range: _, node_index: _, }) - | Expr::Attribute(ast::ExprAttribute { - value, - range: _, - node_index: _, - .. - }) - | Expr::Starred(ast::ExprStarred { - value, - range: _, - node_index: _, - .. - }) => any_over_expr(value, func), + | Expr::Attribute(ast::ExprAttribute { value, .. }) + | Expr::Starred(ast::ExprStarred { value, .. }) => any_over_expr(value, func), Expr::Yield(ast::ExprYield { value, range: _, diff --git a/crates/ruff_python_formatter/src/range.rs b/crates/ruff_python_formatter/src/range.rs index 688219a33d..47266fb534 100644 --- a/crates/ruff_python_formatter/src/range.rs +++ b/crates/ruff_python_formatter/src/range.rs @@ -541,32 +541,26 @@ impl NarrowRange<'_> { // The challenge here is that the second line of the multiline string uses a 4 space indentation. Using `dedent` would // dedent the second line to 0 spaces and the `indent` then adds a 2 space indentation to match the indentation in the source. // This is incorrect because the leading whitespace is the content of the string and not indentation, resulting in changed string content. - if let Some(indentation) = - indentation_at_offset(first_child.start(), self.context.source()) - { - let relative_indent = indentation.strip_prefix(self.enclosing_indent).unwrap(); - let expected_indents = self.level; - - // Each level must always add one level of indent. That's why an empty relative indent to the parent node tells us that the enclosing node is the Module. - let has_expected_indentation = match self.context.options().indent_style() { - IndentStyle::Tab => { - relative_indent.len() == expected_indents - && relative_indent.chars().all(|c| c == '\t') - } - IndentStyle::Space => { - relative_indent.len() - == expected_indents - * self.context.options().indent_width().value() as usize - && relative_indent.chars().all(|c| c == ' ') - } - }; - - if !has_expected_indentation { - return None; + // Missing indentation indicates a simple-statement body of a compound statement (not a suite body). + // Don't narrow the range because the formatter must run `FormatClauseBody` to determine if the body should be collapsed or not. + let indentation = indentation_at_offset(first_child.start(), self.context.source())?; + let relative_indent = indentation.strip_prefix(self.enclosing_indent).unwrap(); + let expected_indents = self.level; + + // Each level must always add one level of indent. That's why an empty relative indent to the parent node tells us that the enclosing node is the Module. + let has_expected_indentation = match self.context.options().indent_style() { + IndentStyle::Tab => { + relative_indent.len() == expected_indents + && relative_indent.chars().all(|c| c == '\t') } - } else { - // Simple-statement body of a compound statement (not a suite body). - // Don't narrow the range because the formatter must run `FormatClauseBody` to determine if the body should be collapsed or not. + IndentStyle::Space => { + relative_indent.len() + == expected_indents * self.context.options().indent_width().value() as usize + && relative_indent.chars().all(|c| c == ' ') + } + }; + + if !has_expected_indentation { return None; } } diff --git a/crates/ruff_python_formatter/tests/fixtures.rs b/crates/ruff_python_formatter/tests/fixtures.rs index 441a7f7a34..0ee9bd0977 100644 --- a/crates/ruff_python_formatter/tests/fixtures.rs +++ b/crates/ruff_python_formatter/tests/fixtures.rs @@ -425,7 +425,7 @@ Formatted once: Formatted twice: --- {reformatted}---"#, - options = &DisplayPyOptions(options), + options = DisplayPyOptions(options), reformatted = reformatted.as_code(), ); } diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 3ec09c4dab..0b2b41e8c4 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -30,7 +30,7 @@ impl std::error::Error for ParseError { impl fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{} at byte range {:?}", &self.error, self.location) + write!(f, "{} at byte range {:?}", self.error, self.location) } } diff --git a/crates/ruff_python_semantic/src/cfg/visualize.rs b/crates/ruff_python_semantic/src/cfg/visualize.rs index 64c9b18106..fe861c122b 100644 --- a/crates/ruff_python_semantic/src/cfg/visualize.rs +++ b/crates/ruff_python_semantic/src/cfg/visualize.rs @@ -24,7 +24,7 @@ trait MermaidGraph<'a>: DirectedGraph<'a> { let num_nodes = self.num_nodes(); for idx in 0..num_nodes { let node = Self::Node::new(idx); - graph.push(format!("\tnode{}{}", idx, &self.draw_node(node))); + graph.push(format!("\tnode{}{}", idx, self.draw_node(node))); } // Draw edges diff --git a/crates/ruff_server/tests/e2e/main.rs b/crates/ruff_server/tests/e2e/main.rs index 07c67d29ee..183e1f8413 100644 --- a/crates/ruff_server/tests/e2e/main.rs +++ b/crates/ruff_server/tests/e2e/main.rs @@ -521,7 +521,7 @@ impl TestServer { { panic!( "Received multiple publish diagnostic notifications for {uri}: ({existing:#?})", - uri = ¬ification.uri + uri = notification.uri ); } } diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 72767b7a33..d3da25580b 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -78,7 +78,7 @@ pub(crate) fn version(output_format: HelpFormat) -> Result<()> { match output_format { HelpFormat::Text => { - writeln!(stdout, "ty {}", &version_info)?; + writeln!(stdout, "ty {version_info}")?; } HelpFormat::Json => { serde_json::to_writer_pretty(&mut stdout, &version_info)?; diff --git a/crates/ty_module_resolver/src/resolve.rs b/crates/ty_module_resolver/src/resolve.rs index 034749c5aa..91d8138e0c 100644 --- a/crates/ty_module_resolver/src/resolve.rs +++ b/crates/ty_module_resolver/src/resolve.rs @@ -2511,7 +2511,7 @@ mod tests { Some(foo_real), path_to_module(&db, &FilePath::from(src.join("foo.py"))) ); - assert!(foo_real != foo); + assert_ne!(foo_real, foo); } #[test] diff --git a/crates/ty_project/src/glob.rs b/crates/ty_project/src/glob.rs index fcddf1ecde..4b28262181 100644 --- a/crates/ty_project/src/glob.rs +++ b/crates/ty_project/src/glob.rs @@ -108,7 +108,7 @@ impl Default for IncludeExcludeFilter { impl std::fmt::Display for IncludeExcludeFilter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "include={}, exclude={}", &self.include, &self.exclude) + write!(f, "include={}, exclude={}", self.include, self.exclude) } } diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 2ac717b737..80a0b1c983 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -192,7 +192,7 @@ fn check_invalid_suppression(context: &mut CheckSuppressionsContext) { diag.into_diagnostic(format_args!( "Invalid `{kind}` comment: {reason}", kind = invalid.kind, - reason = &invalid.error + reason = invalid.error )); } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index d3b93d59d4..06d7c9e2c0 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1745,7 +1745,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { builder.into_diagnostic(format_args!( "Cyclic definition of `{}`", - &type_alias.name.as_name_expr().unwrap().id, + type_alias.name.as_name_expr().unwrap().id, )); } // Replace with `Divergent`. @@ -4262,7 +4262,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let mut diag = builder.into_diagnostic(format_args!( "Type annotation on enum member `{}` is not allowed", - &name_expr.id + name_expr.id )); diag.info( "See: https://typing.python.org/en/latest/spec/enums.html#enum-members", diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index 6f01292f8e..3428a2c16a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -490,7 +490,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; let mut diagnostic = builder.into_diagnostic(format_args!( "Useless body for `@overload`-decorated function `{}`", - &function.name + function.name )); diagnostic.set_primary_message("This statement will never be executed"); diagnostic.info( diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/overloaded_function.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/overloaded_function.rs index e100b4fbb1..9a79acecfd 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/overloaded_function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/overloaded_function.rs @@ -111,7 +111,7 @@ pub(crate) fn check_overloaded_function<'db>( if let Some(builder) = context.report_lint(&INVALID_OVERLOAD, &function_node.name) { let mut diagnostic = builder.into_diagnostic(format_args!( "Overloaded function `{}` requires at least two overloads", - &function_node.name + function_node.name )); diagnostic.set_primary_message("Only one overload defined here"); if let Some(decorator) = @@ -158,11 +158,11 @@ pub(crate) fn check_overloaded_function<'db>( let mut diagnostic = builder.into_diagnostic(format_args!( "Overloads for function `{}` must be followed by a \ non-`@overload`-decorated implementation function", - &function_node.name + function_node.name )); diagnostic.info(format_args!( "Attempting to call `{}` will raise `TypeError` at runtime", - &function_node.name + function_node.name )); diagnostic.info("Overloaded functions without implementations are only permitted:"); diagnostic.info(" - in stub files"); @@ -183,7 +183,7 @@ pub(crate) fn check_overloaded_function<'db>( let mut diagnostic = builder.into_diagnostic(format_args!( "Overloaded function `{}` does not use the `@{}` decorator \ consistently", - &function_node.name, inconsistency.decorator_name + function_node.name, inconsistency.decorator_name )); for function in inconsistency.missing { diagnostic.annotate( diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs index 8ed101693a..4a55cd33b9 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs @@ -48,7 +48,7 @@ pub(crate) fn check_no_default_after_typevar_tuple_pep695( diagnostic.set_concise_message(format_args!( "Type parameter `{single_name}` with a default follows TypeVarTuple `{}`", - &typevar_tuple.name + typevar_tuple.name )); diagnostic.set_primary_message(format_args!("`{single_name}` has a default")); @@ -57,7 +57,7 @@ pub(crate) fn check_no_default_after_typevar_tuple_pep695( diagnostic.set_concise_message(format_args!( "Type parameters {names} with defaults follow TypeVarTuple `{}`", - &typevar_tuple.name + typevar_tuple.name )); diagnostic.set_primary_message(format_args!( @@ -77,7 +77,7 @@ pub(crate) fn check_no_default_after_typevar_tuple_pep695( diagnostic.annotate( context .secondary(typevar_tuple) - .message(format_args!("`{}` is a TypeVarTuple", &typevar_tuple.name)), + .message(format_args!("`{}` is a TypeVarTuple", typevar_tuple.name)), ); diagnostic.info("See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple"); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 66e84de0c3..97220a8e9e 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -3983,11 +3983,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { if let Some(ref mut first) = first { for rest_constraint in rest { - if let Some(rest_constraint) = rest_constraint { - merge_constraints_or(first, rest_constraint); - } else { - return None; - } + merge_constraints_or(first, rest_constraint?); } } first diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 326e53f7b6..3bab55b201 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -186,7 +186,7 @@ fn check_class_declaration<'db>( { let mut diagnostic = builder.into_diagnostic(format_args!( "Cannot overwrite NamedTuple attribute `{}`", - &member.name + member.name )); diagnostic.info("This will cause the class creation to fail at runtime"); } @@ -300,7 +300,7 @@ fn check_class_declaration<'db>( ) { let mut diagnostic = builder.into_diagnostic(format_args!( "Enum member `{}` value is not assignable to expected type", - &member.name + member.name )); diagnostic.info(format_args!( "Expected `{}`, got `{}`", @@ -1091,7 +1091,7 @@ fn check_explicit_overrides<'db>( } diagnostic.info(format_args!( "No `{member}` definitions were found on any superclasses of `{class}`", - member = &member.name, + member = member.name, class = class.name(db) )); } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 6ec685cd16..3d89667d82 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -866,10 +866,10 @@ impl<'db> ProtocolMemberData<'db> { ProtocolMemberKind::Property { read, write } => { let mut d = f.debug_struct("PropertyMember"); if let Some(read) = read.and_then(|read| read.resolve(self.db)) { - d.field("read", &format_args!("`{}`", &read.ty().display(self.db))); + d.field("read", &format_args!("`{}`", read.ty().display(self.db))); } if let Some(write) = write.and_then(|write| write.resolve(self.db)) { - d.field("write", &format_args!("`{}`", &write.ty().display(self.db))); + d.field("write", &format_args!("`{}`", write.ty().display(self.db))); } d.finish() } diff --git a/crates/ty_server/tests/e2e/main.rs b/crates/ty_server/tests/e2e/main.rs index fea06de54c..2be43b133d 100644 --- a/crates/ty_server/tests/e2e/main.rs +++ b/crates/ty_server/tests/e2e/main.rs @@ -580,7 +580,7 @@ impl TestServer { { panic!( "Received multiple publish diagnostic notifications for {uri}: ({existing:#?})", - uri = ¬ification.uri + uri = notification.uri ); } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 2d45363a5b..398f3f015c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.96.1" +channel = "1.97.0" From 9e06f276abd84708d74f3bc5411f884749f15f1d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:56:01 +0200 Subject: [PATCH 088/222] Update NPM Development dependencies (#26839) Co-authored-by: Micha Reiser --- playground/api/package-lock.json | 205 +++++++++++++++++++++++++------ playground/api/package.json | 6 +- playground/package-lock.json | 134 ++++++++++---------- 3 files changed, 236 insertions(+), 109 deletions(-) diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 5752c98d75..82d5c9ab54 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -13,10 +13,10 @@ "uuid": "^14.0.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20230801.0", - "miniflare": "^4.0.0", + "@cloudflare/workers-types": "^4.20260702.1", + "miniflare": "^4.20260706.0", "typescript": "^6.0.0", - "wrangler": "^4.1.0" + "wrangler": "^4.107.1" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -46,9 +46,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260630.1.tgz", - "integrity": "sha512-oEVsD2NZtPAMaEvFeH2Y6N63yiFuOnPDKeAM+l8AkRbLAbFk462uWOq6/ZLn8ouY4P4coMkgsOPqcT1mkuzvzg==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260706.1.tgz", + "integrity": "sha512-61XleG5EaE+Zam2Y/fwOcMInMrjd3QMKIG0/+pHycZcVZC5Oxs5GfHSTnvyhcniigGW/bcIkvn1PptWbxlVleQ==", "cpu": [ "x64" ], @@ -63,9 +63,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260630.1.tgz", - "integrity": "sha512-tar1vcQSzM+27Agrlv28BhtN1tIFKw2YHrzldEMyQJOJB/885TU8Z3oO1c/a9YOmsKABhD6I4dGFhsmXyrbK1g==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260706.1.tgz", + "integrity": "sha512-jbwuMEuhgrXEk+9MD8eHPRsXWlxqtNMqqCMBLJauTHk+NR+U2O6hJXElPqlAVlUTh0A0GPM+DfATupRXm7Ml8w==", "cpu": [ "arm64" ], @@ -80,9 +80,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260630.1.tgz", - "integrity": "sha512-mhjIg91+ikWw5v9tY4BYO7N9vLOZBhn7EnVFvxCdxcpuUUFBKATxUYHUy1kkgYxnmiI6s93PRNbzBz1NpYQ3IQ==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260706.1.tgz", + "integrity": "sha512-kppCFkt6WHTNmcE83aUj4chWV13hdQKOgXxn53sqpLiVnPi6HEIOWB1DNRAljG9IDt3KMgni9HdzCYVzq2drJA==", "cpu": [ "x64" ], @@ -97,9 +97,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260630.1.tgz", - "integrity": "sha512-7g0iGvMCwGct+vE3FOKXtFWMAIGHzK2Ei9oALp44gXuL4lBcs3PPJISeTp5itquW2JwS1fw4Hnq7zrT7N/dgPw==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260706.1.tgz", + "integrity": "sha512-H4xpocM1vXNfIVrP/JiOhLooWBXi5erE81hBj2wmCvPIUcf+49CuvZEthpVwFYn9SFop/Y8+bUiV0vVrWf8xuw==", "cpu": [ "arm64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260630.1.tgz", - "integrity": "sha512-J5KF9VF8yRpRBib/cPSuEp6iR9q3/cKgeDVhg1ZtuwpkzwnmCb+rxMF5WFLxAN8bI2x2FMG1v6o4vVFOGZ0fOQ==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260706.1.tgz", + "integrity": "sha512-SPWuI+kBNtr/mhcKE7XOECgRWOaidmzVmvtmDrRZX5Y4/+XqD/heRAU5Oa17wOZm3eQ1CB8Xzxjrr/6uQaP8YA==", "cpu": [ "x64" ], @@ -131,9 +131,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260630.1.tgz", - "integrity": "sha512-yl+c9vwvko9UZ0frmtsHuwOh3BRHvNjLrfelAp5Akpqe1+Ho1UWekr3nmjJ1D64CH0Yb0K0oRMV4i7npOFzsog==", + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -1414,16 +1414,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260630.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260630.0.tgz", - "integrity": "sha512-lyRplDrSJJWVpzSSQPBSQtNmUuxScCZyOOkXFs37uSbdTfWRDDmw6DyFKVS2s1eYtA/i4u2xR/0FyPIsTl/HJw==", + "version": "4.20260706.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260706.0.tgz", + "integrity": "sha512-UiqGo9Es/D7kJvDVpjhTQ/M2ppCSCsRc5EEKec6i4BvnCkFCRaZHRmFkMHzLhlg+daSZ+zvBaycWmgLZHn/1tQ==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", - "workerd": "1.20260630.1", + "workerd": "1.20260706.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -1708,9 +1708,9 @@ } }, "node_modules/workerd": { - "version": "1.20260630.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260630.1.tgz", - "integrity": "sha512-7M0AA4l14hmPGtzQ5YPHyXosIKI/uz3TdcPHeiFDbgb7/0c8ECVMzIaodSV5bZIVhDHL0OlzqITAdPiwAr+dTg==", + "version": "1.20260706.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260706.1.tgz", + "integrity": "sha512-0DOmgysJiPZRwtlg9Bh70aDnHuVech70fKVM4721RbdfewL29ODxn4TpgkusybLYrQAkgHkW53SUt2AdkBa+Og==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1721,17 +1721,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260630.1", - "@cloudflare/workerd-darwin-arm64": "1.20260630.1", - "@cloudflare/workerd-linux-64": "1.20260630.1", - "@cloudflare/workerd-linux-arm64": "1.20260630.1", - "@cloudflare/workerd-windows-64": "1.20260630.1" + "@cloudflare/workerd-darwin-64": "1.20260706.1", + "@cloudflare/workerd-darwin-arm64": "1.20260706.1", + "@cloudflare/workerd-linux-64": "1.20260706.1", + "@cloudflare/workerd-linux-arm64": "1.20260706.1", + "@cloudflare/workerd-windows-64": "1.20260706.1" } }, "node_modules/wrangler": { - "version": "4.106.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.106.0.tgz", - "integrity": "sha512-b6EVbsvbmAUY4bUQXT3+f8oFP8x+J5rEa5z3Akeh+6vyKiN4x8+PyZ53DPpnqdxhIihhq/a00Yq5chGJ19QXBQ==", + "version": "4.107.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.107.1.tgz", + "integrity": "sha512-HMulqgQtNvY9UKXu6nTRTqv5GhtN1RhZeG9cPX+kS8R6s7o7ct6rueoXiNe4MX8+88B7p4lq3RvSPzB205fexg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -1739,10 +1739,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260630.0", + "miniflare": "4.20260702.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260630.1" + "workerd": "1.20260702.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -1756,7 +1756,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260630.1" + "@cloudflare/workers-types": "^4.20260702.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -1764,6 +1764,133 @@ } } }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260702.1.tgz", + "integrity": "sha512-D5+vnLlvfkanyFpgyE+K8JtFGr9qcKEaMIv6iGus1bz7gegr8zWF5HLwJ5LbebdyeVWl+IzpT7m5LJQsHOcJ4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260702.1.tgz", + "integrity": "sha512-CkJNyrOzuIWil87qztB5cE6b9z+0c2Ewc8yrkSfahsdATxmjD+mrLIpJ0IkW9bqCOS1JE1kDEtfx9D4/ZpKW6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260702.1.tgz", + "integrity": "sha512-FhJl4cZQNqlG5v7LQRvzTes2q8hHylf60ecQ4Es0YvJ0hvRO30FOU2f4WSEIkZahs1HNCYxf2EL9GJQ/vIx1Ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260702.1.tgz", + "integrity": "sha512-EuXdOgwE08sbfVJsI9JHXUxf2Xlqy51P1nQt+wIDN5kcLzfkW0hbKw/GVLupLBhivGdFRfSBM9SUn/+3uMneuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260702.1.tgz", + "integrity": "sha512-84X5kRiUo9vUEWAaj/vOamPqQJK21KOUWuQ48MccFbzG+LPCZP0EGb36dQZd1OmKoZ1dYHPWB0zBAoI7tygc2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "4.20260702.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260702.0.tgz", + "integrity": "sha512-OqX/HwWWu0JqLQ7aCQU0int9fmpMzRjVWDo0T1WJDTssYc7KlaMM3G8N8HUEunkBMflmgI8TiAqmZTISqcVmEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260702.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260702.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260702.1.tgz", + "integrity": "sha512-rkIDTWQ7yhC0BjWBDuKj/q9JsOBzrGt/KdOeun+c4YlN47W4l0pa5aCyE1X6A47/ju3h6BEFhrO1CibZ5EgSpA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260702.1", + "@cloudflare/workerd-darwin-arm64": "1.20260702.1", + "@cloudflare/workerd-linux-64": "1.20260702.1", + "@cloudflare/workerd-linux-arm64": "1.20260702.1", + "@cloudflare/workerd-windows-64": "1.20260702.1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/playground/api/package.json b/playground/api/package.json index 642569aed3..84167cfce1 100644 --- a/playground/api/package.json +++ b/playground/api/package.json @@ -2,10 +2,10 @@ "name": "api", "version": "0.0.0", "devDependencies": { - "@cloudflare/workers-types": "^4.20230801.0", - "miniflare": "^4.0.0", + "@cloudflare/workers-types": "^4.20260702.1", + "miniflare": "^4.20260706.0", "typescript": "^6.0.0", - "wrangler": "^4.1.0" + "wrangler": "^4.107.1" }, "private": true, "scripts": { diff --git a/playground/package-lock.json b/playground/package-lock.json index 0782550395..d52f9de99a 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -3252,17 +3252,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3275,7 +3275,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3291,16 +3291,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -3316,14 +3316,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -3338,14 +3338,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3356,9 +3356,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -3373,15 +3373,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3398,9 +3398,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -3412,16 +3412,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3492,16 +3492,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3516,13 +3516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7607,16 +7607,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", - "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.1", - "@typescript-eslint/parser": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1" + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7700,9 +7700,9 @@ } }, "node_modules/vite": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", - "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { @@ -7801,9 +7801,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { From 98ab666261cef2df16a5f41b6a9843ef892a32ef Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:52:33 -0400 Subject: [PATCH 089/222] [`refurb`] Allow subclassing builtins in stub files (`FURB189`) (#26812) Summary -- While looking at rule stabilization candidates for 0.16, I noticed that #15291 has been blocking stabilization of this rule for quite a while despite being a one-line change. I decided to make that one-line change since it seemed sensible to me. I'll still plan to hold off on stabilization until this change has been in preview for a release cycle, but this should pave the way to stabilization in 0.17. Closed #15291 Test Plan -- New mdtest showing that the rule is inactive in stub files --- .../mdtest/refurb/subclass-builtin.md | 20 +++++++++++++++++++ .../rules/refurb/rules/subclass_builtin.rs | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 crates/ruff_linter/resources/mdtest/refurb/subclass-builtin.md diff --git a/crates/ruff_linter/resources/mdtest/refurb/subclass-builtin.md b/crates/ruff_linter/resources/mdtest/refurb/subclass-builtin.md new file mode 100644 index 0000000000..5fc8433d19 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/refurb/subclass-builtin.md @@ -0,0 +1,20 @@ +# `subclass-builtin` (`FURB189`) + +```toml +[lint] +preview = true +select = ["FURB189"] +``` + +## Stub files + +Subclassing a builtin in a stub must be allowed so the stub can faithfully represent the runtime +implementation. + +```pyi +class D(dict): ... +class L(list): ... +class S(str): ... +class SubscriptDict(dict[str, str]): ... +class SubscriptList(list[str]): ... +``` diff --git a/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs b/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs index 20cb03109a..f44adf4374 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/subclass_builtin.rs @@ -15,6 +15,9 @@ use crate::{checkers::ast::Checker, importer::ImportRequest}; /// Use the `UserDict`, `UserList`, and `UserString` objects from the `collections` module /// instead. /// +/// This rule does not apply to stub files, which should faithfully represent the runtime +/// implementation and may be out of the author's control. +/// /// ## Example /// /// ```python @@ -83,6 +86,10 @@ impl AlwaysFixableViolation for SubclassBuiltin { /// FURB189 pub(crate) fn subclass_builtin(checker: &Checker, class: &StmtClassDef) { + if checker.source_type.is_stub() { + return; + } + let Some(Arguments { args: bases, .. }) = class.arguments.as_deref() else { return; }; From a616e0873c32d3f82d89af4a9959dcf6f5a3d04a Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:57:41 -0400 Subject: [PATCH 090/222] Refactor TOML file linting (#26806) Summary -- This PR was spun off from #26772 to handle some of the refactors to RUF200. The first non-empty commit is purely moving the implementation of RUF200 from the general `lint_pyproject_toml` function into its own rule file. In #26772, `lint_pyproject_toml` will become `lint_toml` and invoke multiple rules. The second commit drops the overly defensive range check, as noted in https://github.com/astral-sh/ruff/pull/26772#discussion_r3576662265. The third commit refactors the rule to use a `LintContext`. This not only sets up to run multiple rules in this function, but also fixes a latent bug in rule suppression for RUF200. Because the rule previously checked whether it was enabled via the `LinterSettings::rules.enabled` directly, it didn't take into account settings like `per-file-ignores` (this also affects `unfixable` and other fix-related settings for RUF201). The fourth commit contains a new mdtest for this that currently fails on main. Finally, the fifth commit moves parsing out of RUF200. We still parse into a `PyProjectToml` for now, but at least this sets us up to share parsing between rules once we find the right shared representation. Test Plan -- New mdtest mentioned above and then rebasing #26772 onto this. --- Cargo.lock | 1 - crates/ruff/src/diagnostics.rs | 7 +- .../mdtest/ruff/invalid-pyproject-toml.md | 17 +++++ crates/ruff_linter/src/checkers/ast/mod.rs | 5 ++ crates/ruff_linter/src/pyproject_toml.rs | 66 +++++-------------- crates/ruff_linter/src/rules/ruff/mod.rs | 5 +- .../ruff/rules/invalid_pyproject_toml.rs | 19 +++++- crates/ruff_mdtest/Cargo.toml | 1 - crates/ruff_mdtest/src/lib.rs | 6 +- 9 files changed, 60 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 318d7de6e5..cb32832690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3434,7 +3434,6 @@ dependencies = [ "ruff_linter", "ruff_python_ast", "ruff_ranged_value", - "ruff_source_file", "ruff_workspace", "salsa", ] diff --git a/crates/ruff/src/diagnostics.rs b/crates/ruff/src/diagnostics.rs index 8a6535a009..61311515bb 100644 --- a/crates/ruff/src/diagnostics.rs +++ b/crates/ruff/src/diagnostics.rs @@ -225,8 +225,7 @@ pub(crate) fn lint_path( return Ok(Diagnostics::from_source_error(&err, Some(path), settings)); } }; - let source_file = SourceFileBuilder::new(path.to_string_lossy(), contents).finish(); - lint_pyproject_toml(&source_file, settings) + lint_pyproject_toml(path, &contents, settings) } else { vec![] }; @@ -370,8 +369,6 @@ pub(crate) fn lint_stdin( } let path = path.unwrap(); - let source_file = - SourceFileBuilder::new(path.to_string_lossy(), contents.clone()).finish(); match fix_mode { flags::FixMode::Diff | flags::FixMode::Generate => {} @@ -379,7 +376,7 @@ pub(crate) fn lint_stdin( } return Ok(Diagnostics { - inner: lint_pyproject_toml(&source_file, &settings.linter), + inner: lint_pyproject_toml(path, &contents, &settings.linter), fixed: FixMap::from_iter([(fs::relativize_path(path), FixTable::default())]), notebook_indexes: FxHashMap::default(), }); diff --git a/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md index 47fcd4206b..033ca832e9 100644 --- a/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md +++ b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md @@ -5,6 +5,8 @@ select = ["RUF200"] ``` +## Reports an invalid `pyproject.toml` + `pyproject.toml`: ```toml @@ -20,3 +22,18 @@ error[RUF200]: Failed to parse pyproject.toml: invalid type: integer `1`, expect | ^ | ``` + +## Respects per-file ignores + +```toml +[lint] +select = ["RUF200"] +per-file-ignores = { "pyproject.toml" = ["RUF200"] } +``` + +`pyproject.toml`: + +```toml +[project] +name = 1 +``` diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index cdfa6c1bc4..afdf134f1e 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -3548,6 +3548,11 @@ impl<'a> LintContext<'a> { (self.diagnostics.into_inner(), self.source_file) } + #[inline] + pub(crate) fn into_diagnostics(self) -> Vec { + self.diagnostics.into_inner() + } + #[inline] pub(crate) fn as_mut_vec(&mut self) -> &mut Vec { self.diagnostics.get_mut() diff --git a/crates/ruff_linter/src/pyproject_toml.rs b/crates/ruff_linter/src/pyproject_toml.rs index 8b319f595c..583f099981 100644 --- a/crates/ruff_linter/src/pyproject_toml.rs +++ b/crates/ruff_linter/src/pyproject_toml.rs @@ -1,61 +1,25 @@ -use colored::Colorize; -use log::warn; -use pyproject_toml::PyProjectToml; -use ruff_text_size::{TextRange, TextSize}; +use std::path::Path; +use pyproject_toml::PyProjectToml; use ruff_db::diagnostic::Diagnostic; -use ruff_source_file::SourceFile; -use crate::registry::Rule; -use crate::rules::ruff::rules::InvalidPyprojectToml; +use crate::checkers::ast::LintContext; +use crate::codes::Rule; +use crate::rules::ruff::rules::invalid_pyproject_toml; use crate::settings::LinterSettings; -use crate::{IOError, Violation}; -/// RUF200 -pub fn lint_pyproject_toml(source_file: &SourceFile, settings: &LinterSettings) -> Vec { - let Some(err) = toml::from_str::(source_file.source_text()).err() else { - return Vec::default(); - }; +pub fn lint_pyproject_toml( + path: &Path, + contents: &str, + settings: &LinterSettings, +) -> Vec { + let context = LintContext::new(path, contents, settings); - let mut messages = Vec::new(); - let range = match err.span() { - // This is bad but sometimes toml and/or serde just don't give us spans - // TODO(konstin,micha): https://github.com/astral-sh/ruff/issues/4571 - None => TextRange::default(), - Some(range) => { - let Ok(end) = TextSize::try_from(range.end) else { - let message = format!( - "{} is larger than 4GB, but ruff assumes all files to be smaller", - source_file.name(), - ); - if settings.rules.enabled(Rule::IOError) { - let diagnostic = - IOError { message }.into_diagnostic(TextRange::default(), source_file); - messages.push(diagnostic); - } else { - warn!( - "{}{}{} {message}", - "Failed to lint ".bold(), - source_file.name().bold(), - ":".bold() - ); - } - return messages; - }; - TextRange::new( - // start <= end, so if end < 4GB follows start < 4GB - TextSize::try_from(range.start).unwrap(), - end, - ) + if let Err(err) = toml::from_str::(contents) { + if context.is_rule_enabled(Rule::InvalidPyprojectToml) { + invalid_pyproject_toml(&context, &err); } - }; - - if settings.rules.enabled(Rule::InvalidPyprojectToml) { - let toml_err = err.message().to_string(); - let diagnostic = - InvalidPyprojectToml { message: toml_err }.into_diagnostic(range, source_file); - messages.push(diagnostic); } - messages + context.into_diagnostics() } diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 68bd7b1726..a91f858996 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -13,7 +13,6 @@ mod tests { use anyhow::Result; use regex::Regex; use ruff_python_ast::PythonVersion; - use ruff_source_file::SourceFileBuilder; use rustc_hash::FxHashSet; use test_case::test_case; @@ -795,9 +794,9 @@ mod tests { .join(path) .join("pyproject.toml"); let contents = fs::read_to_string(path)?; - let source_file = SourceFileBuilder::new("pyproject.toml", contents).finish(); let messages = lint_pyproject_toml( - &source_file, + Path::new("pyproject.toml"), + &contents, &settings::LinterSettings::for_rule(Rule::InvalidPyprojectToml), ); assert_diagnostics!(snapshot, messages); diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs index 4da7ce2996..be78db98cf 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs @@ -1,6 +1,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_text_size::{TextRange, TextSize}; -use crate::{FixAvailability, Violation}; +use crate::{FixAvailability, Violation, checkers::ast::LintContext}; /// ## What it does /// Checks for any pyproject.toml that does not conform to the schema from the relevant PEPs. @@ -45,3 +46,19 @@ impl Violation for InvalidPyprojectToml { format!("Failed to parse pyproject.toml: {message}") } } + +/// RUF200 +pub(crate) fn invalid_pyproject_toml(context: &LintContext, err: &toml::de::Error) { + let range = match err.span() { + // This is bad but sometimes toml and/or serde just don't give us spans + // TODO(konstin,micha): https://github.com/astral-sh/ruff/issues/4571 + None => TextRange::default(), + Some(range) => TextRange::new( + TextSize::try_from(range.start).unwrap(), + TextSize::try_from(range.end).unwrap(), + ), + }; + + let toml_err = err.message().to_string(); + context.report_diagnostic(InvalidPyprojectToml { message: toml_err }, range); +} diff --git a/crates/ruff_mdtest/Cargo.toml b/crates/ruff_mdtest/Cargo.toml index cbf24ad06a..9209374fc5 100644 --- a/crates/ruff_mdtest/Cargo.toml +++ b/crates/ruff_mdtest/Cargo.toml @@ -20,7 +20,6 @@ ruff_db = { workspace = true, features = ["os", "testing"] } ruff_linter = { workspace = true, features = ["testing"] } ruff_python_ast = { workspace = true } ruff_ranged_value = { workspace = true } -ruff_source_file = { workspace = true } ruff_workspace = { workspace = true } anyhow = { workspace = true } diff --git a/crates/ruff_mdtest/src/lib.rs b/crates/ruff_mdtest/src/lib.rs index 1fbe0b1512..c35ced5469 100644 --- a/crates/ruff_mdtest/src/lib.rs +++ b/crates/ruff_mdtest/src/lib.rs @@ -15,7 +15,6 @@ use ruff_linter::source_kind::SourceKind; use ruff_linter::test::test_contents; use ruff_python_ast::SourceType; use ruff_ranged_value::{ValueSource, ValueSourceGuard}; -use ruff_source_file::SourceFileBuilder; use ruff_workspace::configuration::Configuration; use ruff_workspace::options::Options; @@ -128,10 +127,7 @@ fn run_test( test_contents(&source_kind, path, &settings.linter).0 } SourceType::Toml(source_type) if source_type.is_pyproject() => { - let source_file = - SourceFileBuilder::new(path.to_string_lossy(), source.as_str()) - .finish(); - lint_pyproject_toml(&source_file, &settings.linter) + lint_pyproject_toml(path, source.as_str(), &settings.linter) } SourceType::Toml(_) | SourceType::Markdown => Vec::new(), } From 466f1ec0496ee6ce759c462ef3320a705080bd8f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 07:00:01 -0700 Subject: [PATCH 091/222] Use Rust 1.95 features (#26845) ## Summary Use Rust 1.95's `cfg_select!` macro for mutually exclusive WASM and debug-assertion branches; restore an `if let` match guard that was previously avoided for the old MSRV. --- crates/ruff_linter/src/fs.rs | 8 ++--- crates/ruff_linter/src/message/sarif.rs | 12 +++---- crates/ty_python_core/src/ast_node_ref.rs | 23 ++++++------- crates/ty_python_semantic/src/types.rs | 41 ++++++++++------------- 4 files changed, 38 insertions(+), 46 deletions(-) diff --git a/crates/ruff_linter/src/fs.rs b/crates/ruff_linter/src/fs.rs index 5543d9a569..79dff331aa 100644 --- a/crates/ruff_linter/src/fs.rs +++ b/crates/ruff_linter/src/fs.rs @@ -9,12 +9,10 @@ use crate::settings::types::CompiledPerFileIgnoreList; /// /// On WASM this just returns `.`. Otherwise, defer to [`path_absolutize::path_dedot::CWD`]. pub fn get_cwd() -> &'static Path { - #[cfg(target_arch = "wasm32")] - { - Path::new(".") + cfg_select! { + target_arch = "wasm32" => Path::new("."), + _ => path_absolutize::path_dedot::CWD.as_path(), } - #[cfg(not(target_arch = "wasm32"))] - path_absolutize::path_dedot::CWD.as_path() } /// Create a set with codes matching the pattern/code pairs. diff --git a/crates/ruff_linter/src/message/sarif.rs b/crates/ruff_linter/src/message/sarif.rs index 02ee569471..1d6a6edf86 100644 --- a/crates/ruff_linter/src/message/sarif.rs +++ b/crates/ruff_linter/src/message/sarif.rs @@ -380,12 +380,12 @@ impl<'a> SarifResult<'a> { #[allow(clippy::unnecessary_wraps)] fn uri(diagnostic: &Diagnostic) -> Result { let path = normalize_path(&*diagnostic.expect_ruff_filename()); - #[cfg(not(target_arch = "wasm32"))] - return url::Url::from_file_path(&path) - .map_err(|()| anyhow::anyhow!("Failed to convert path to URL: {}", path.display())) - .map(|u| u.to_string()); - #[cfg(target_arch = "wasm32")] - return Ok(format!("file://{}", path.display())); + cfg_select! { + target_arch = "wasm32" => Ok(format!("file://{}", path.display())), + _ => url::Url::from_file_path(&path) + .map_err(|()| anyhow::anyhow!("Failed to convert path to URL: {}", path.display())) + .map(|url| url.to_string()), + } } fn from_message( diff --git a/crates/ty_python_core/src/ast_node_ref.rs b/crates/ty_python_core/src/ast_node_ref.rs index 1e81ac4b59..81161a5179 100644 --- a/crates/ty_python_core/src/ast_node_ref.rs +++ b/crates/ty_python_core/src/ast_node_ref.rs @@ -110,18 +110,17 @@ where for<'ast> &'ast T: TryFrom>, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - #[cfg(debug_assertions)] - { - f.debug_struct("AstNodeRef") - .field("kind", &self.kind) - .field("range", &self.range) - .finish() - } - - #[cfg(not(debug_assertions))] - { - // Unfortunately we have no access to the AST here. - f.debug_tuple("AstNodeRef").finish_non_exhaustive() + cfg_select! { + debug_assertions => { + f.debug_struct("AstNodeRef") + .field("kind", &self.kind) + .field("range", &self.range) + .finish() + }, + _ => { + // Unfortunately we have no access to the AST here. + f.debug_tuple("AstNodeRef").finish_non_exhaustive() + }, } } } diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 6724deb183..6ebf7dd0ae 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -2764,29 +2764,24 @@ impl<'db> Type<'db> { .. }) => self.instance_member(db, &name), - Type::LiteralValue(literal) if name == "__len__" => { - if let Some(length) = match literal.kind() { - LiteralValueTypeKind::Bytes(bytes) => Some(bytes.python_len(db)), - LiteralValueTypeKind::String(string) => Some(string.python_len(db)), - _ => None, - } && let Ok(length) = i64::try_from(length) - { - let parameters = Parameters::standard([Parameter::positional_only(Some( - Name::new_static("self"), - )) - .with_annotated_type(self)]); - Place::bound(Type::function_like_callable( - db, - Signature::new(parameters, Type::int_literal(length)), - )) - .into() - } else { - self.to_meta_type(db) - .find_name_in_mro_with_policy(db, name.as_str(), policy) - .expect( - "`Type::find_name_in_mro()` should return `Some()` when called on a meta-type", - ) - } + Type::LiteralValue(literal) + if name == "__len__" + && let Some(length) = match literal.kind() { + LiteralValueTypeKind::Bytes(bytes) => Some(bytes.python_len(db)), + LiteralValueTypeKind::String(string) => Some(string.python_len(db)), + _ => None, + } + && let Ok(length) = i64::try_from(length) => + { + let parameters = Parameters::standard([Parameter::positional_only(Some( + Name::new_static("self"), + )) + .with_annotated_type(self)]); + Place::bound(Type::function_like_callable( + db, + Signature::new(parameters, Type::int_literal(length)), + )) + .into() } // `type[Any]` (or `type[Unknown]`, etc.) has an unknown metaclass, but all From bf3f5f6d6ec4f04301376a1dfd9a804aa0887b9b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 07:17:08 -0700 Subject: [PATCH 092/222] [ty] Support own-line suppression comments (#26785) ## Summary Prior to this change, we only applied `ty: ignore` comments to diagnostics on the same line. An ignore placed after executable code on its own line was therefore reported as unused and did not suppress the following statement: ```python existing = 1 # ty: ignore[unresolved-reference] value = missing ``` We now match Ruff's placement semantics for `ty: ignore`: an ignore before a logical line covers that logical line, while an ignore inside a multiline statement covers the next non-comment physical line. These ranges can be nested, and `type: ignore` retains its existing same-line and file-level behavior. --- crates/ty/docs/rules.md | 10 +- .../mdtest/suppressions/ty_ignore.md | 143 ++++++++- .../mdtest/suppressions/type_ignore.md | 13 + crates/ty_python_semantic/src/suppression.rs | 290 ++++++++++++++---- .../src/suppression/add_ignore.rs | 2 +- .../src/suppression/unused.rs | 15 +- 6 files changed, 406 insertions(+), 67 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index cd2b7ca475..8c72575f28 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -165,7 +165,7 @@ def _(x: int): Default level: ignore · Added in 0.0.57 · Related issues · -View source +View source @@ -832,7 +832,7 @@ INITIALIZED_CONSTANT: Final[int] = 1 Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1838,7 +1838,7 @@ x: G[int] Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5069,7 +5069,7 @@ async def main() -> None: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5110,7 +5110,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments. Default level: warn · Added in 0.0.14 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md index e7971313b8..e91233f84d 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md @@ -1,8 +1,8 @@ # Suppressing errors with `ty: ignore` -Type check errors can be suppressed by a `ty: ignore` comment on the same line as the violation. The -optional `blanket-ignore-comment` rule is tested separately so that these examples can exercise bare -ignore comments. +Type check errors can be suppressed by a `ty: ignore` comment on the same line as the violation or +on a preceding own line. The optional `blanket-ignore-comment` rule is tested separately so that +these examples can exercise bare ignore comments. ```toml [rules] @@ -11,16 +11,76 @@ blanket-ignore-comment = "ignore" ## Simple `ty: ignore` +A suppression can appear at the end of the affected line: + ```py a = 4 + test # ty: ignore ``` +Or on the preceding line: + +```py +seen_code = True + +# ty: ignore +a = missing +``` + ## Suppressing a specific code ```py a = 4 + test # ty: ignore[unresolved-reference] ``` +A code-specific suppression can likewise appear on the preceding line. It applies to the following +logical line, including when other comments appear between the suppression and the statement. + +```py +seen_code = True + +# ty: ignore[unresolved-reference] +a = missing + +# ty: ignore[unresolved-reference] +# This comment explains why the suppression is necessary. +b = missing +``` + +The suppression covers every statement on the following logical line. + +```py +seen_code = True + +# ty: ignore[unresolved-reference] +first = 1; second = missing # fmt: skip +``` + +## Multiline statements + +An ignore before a multiline statement applies to the entire logical line. Suppression ranges can +also be nested. + +```py +seen_code = True + +# ty: ignore[invalid-assignment] +nested_values: tuple[int] = [ + # ty: ignore[division-by-zero] + 1 / 0, +] +``` + +Inside a multiline statement, an ignore applies only to the next non-comment physical line. + +```py +values = [ + # ty: ignore[division-by-zero] + 1 / 0, + # error: [division-by-zero] + 2 / 0, +] +``` + ## Unused suppression ```py @@ -191,6 +251,72 @@ help: Remove the unused suppression codes def test(a: f"f-string type annotation", b: unresolved_ref): ... # ty: ignore[invalid-type-form, unresolved-reference] ``` +## Nested comments + +An own-line suppression can be nested after another pragma comment: + +```py +seen_code = True + +# fmt: off # ty: ignore[division-by-zero] +value = 1 / 0 +# fmt: on +``` + +Removing an unused nested suppression is safe when another pragma precedes it, but unsafe when +removal would promote a following pragma to an own-line comment: + +```py +seen_code = True + +# snapshot +# fmt: off # ty: ignore[division-by-zero] +value = 1 +# fmt: on +``` + +```snapshot +warning[unused-ignore-comment]: Unused `ty: ignore` directive + --> src/mdtest_snippet.py:9:12 + | +9 | # fmt: off # ty: ignore[division-by-zero] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Remove the unused suppression comment + | +8 | # snapshot + - # fmt: off # ty: ignore[division-by-zero] +9 + # fmt: off +10 | value = 1 + | +``` + +```py +seen_code = True + +# snapshot +# ty: ignore[division-by-zero] # fmt: off +value = 1 +# fmt: on +``` + +```snapshot +warning[unused-ignore-comment]: Unused `ty: ignore` directive + --> src/mdtest_snippet.py:15:1 + | +15 | # ty: ignore[division-by-zero] # fmt: off + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Remove the unused suppression comment + | +14 | # snapshot + - # ty: ignore[division-by-zero] # fmt: off +15 + # fmt: off +16 | value = 1 + | +note: This is an unsafe fix and may change runtime behavior +``` + ## Can't suppress syntax errors @@ -294,6 +420,17 @@ a = 4 / 0 b = a + c # error: [unresolved-reference] ``` +## File-level suppression nested after another pragma + +File-level suppressions can be nested after another pragma comment: + +```py +# fmt: off # ty: ignore[division-by-zero] + +a = 4 / 0 +# fmt: on +``` + ## Unknown rule ```py diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md index 215d287e75..fdf33f588a 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md @@ -265,6 +265,19 @@ a = 10 / 0 # error: [division-by-zero] b = a / 0 # error: [division-by-zero] ``` +Unlike `ty: ignore`, an own-line `type: ignore` does not suppress the following line (unless it +appears before any Python statements in the file, in which case it suppresses the entire file). This +preserves the standardized semantics of `type: ignore` comments. + +```py +seen_code = True + +# error: [unused-type-ignore-comment] +# type: ignore +# error: [unresolved-reference] +value = missing +``` + ## `respect-type-ignore-comments=false` ty ignore `type-ignore` comments if `respect-type-ignore-comments` is set to false. diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 80a0b1c983..98c6fd766d 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -2,14 +2,15 @@ mod add_ignore; mod parser; mod unused; -use smallvec::SmallVec; +use smallvec::{SmallVec, smallvec}; use std::fmt; use ruff_db::diagnostic::{ Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, LintName, Severity, Span, }; use ruff_db::{files::File, parsed::parsed_module, source::source_text}; -use ruff_python_ast::token::TokenKind; +use ruff_python_ast::token::{TokenKind, Tokens}; +use ruff_python_trivia::indentation_at_offset; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use crate::diagnostic::DiagnosticGuard; @@ -96,7 +97,11 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions { if comment.kind().is_type_ignore() && !respect_type_ignore { continue; } - builder.add_comment(comment, TextRange::new(line_start, token.end())); + builder.add_comment( + comment, + TextRange::new(line_start, token.end()), + parsed.tokens(), + ); } Err(error) => match error.kind { ParseErrorKind::NotASuppression @@ -306,12 +311,23 @@ pub(crate) struct Suppressions { /// spans the entire file. file: SmallVec<[Suppression; 1]>, - /// Suppressions that apply to a specific line (or lines). + /// Suppressions that apply inline rather than to the entire file. /// /// Comments with multiple codes create multiple [`Suppression`]s that all share the same [`Suppression::comment_range`]. /// - /// The suppressions are sorted by [`Suppression::range`] (which implies [`Suppression::comment_range`]). - line: Vec, + /// The suppressions are indexed by [`Suppression::suppressed_range`] and retain source order. + /// Their range ends aren't necessarily sorted because own-line suppressions can be nested: + /// + /// ```py + /// # ty: ignore + /// value = ( + /// # ty: ignore + /// missing + /// ) + /// ``` + /// + /// The outer suppression starts before the inner suppression but ends after it. + inline: IntervalIndex, /// Suppressions with lint codes that are unknown. unknown: Vec, @@ -333,54 +349,26 @@ impl Suppressions { ) -> impl Iterator + '_ { self.file .iter() - .chain(self.line_suppressions(range)) + .chain(self.inline_suppressions(range)) .filter(move |suppression| suppression.matches(id)) } - /// Returns the line-level suppressions that apply for `range`. + /// Returns the inline suppressions that apply for `range`. /// - /// A suppression applies for the given range if it contains the range's - /// start or end offset. This means the suppression is on the same line - /// as the diagnostic's start or end. - fn line_suppressions(&self, range: TextRange) -> impl Iterator + '_ { - // First find the index of the suppression comment that ends right before the range - // starts. This allows us to skip suppressions that are not relevant for the range. - let end_offset = self - .line - .binary_search_by_key(&range.start(), |suppression| { - suppression.suppressed_range.end() - }) - .unwrap_or_else(|index| index); - - // From here, search the remaining suppression comments for one that - // contains the range's start or end offset. Stop the search - // as soon as the suppression's range and the range no longer overlap. - self.line[end_offset..] - .iter() - // Stop searching if the suppression starts after the range we're looking for. - .take_while(move |suppression| range.end() >= suppression.suppressed_range.start()) - .filter(move |suppression| { - // Don't use intersect to avoid that suppressions on inner-expression - // ignore errors for outer expressions - suppression.suppressed_range.contains(range.start()) - || suppression.suppressed_range.contains_inclusive(range.end()) - }) - } - - fn iter(&self) -> SuppressionsIter<'_> { - self.file.iter().chain(&self.line) + /// A suppression applies for the given range if it contains the range's start or end offset. + /// End-of-line suppressions cover the diagnostic's start or end line, while own-line + /// suppressions cover the following logical line. + fn inline_suppressions(&self, range: TextRange) -> impl Iterator + '_ { + self.inline.intersecting(range).filter(move |suppression| { + // Don't use intersect to avoid that suppressions on inner-expression + // ignore errors for outer expressions + suppression.suppressed_range.contains(range.start()) + || suppression.suppressed_range.contains_inclusive(range.end()) + }) } -} - -pub(crate) type SuppressionsIter<'a> = - std::iter::Chain, std::slice::Iter<'a, Suppression>>; - -impl<'a> IntoIterator for &'a Suppressions { - type Item = &'a Suppression; - type IntoIter = SuppressionsIter<'a>; - fn into_iter(self) -> Self::IntoIter { - self.iter() + fn iter(&self) -> impl Iterator { + self.file.iter().chain(self.inline.iter()) } } @@ -413,7 +401,8 @@ pub(crate) struct Suppression { comment_range: TextRange, /// The range for which this suppression applies. - /// Most of the time, this is the range of the comment's line. + /// Most of the time, this is the range of the comment's line. An own-line `ty: ignore` + /// suppression also covers the following logical line. /// However, there are few cases where the range gets expanded to /// cover multiple lines: /// * multiline strings: `expr + """multiline\nstring""" # type: ignore` @@ -435,6 +424,12 @@ impl Suppression { } } +impl Interval for Suppression { + fn interval(&self) -> TextRange { + self.suppressed_range + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, get_size2::GetSize)] enum SuppressionKind { TypeIgnore, @@ -498,7 +493,7 @@ struct SuppressionsBuilder<'a> { /// This boolean tracks if there has been any non trivia token. seen_non_trivia_token: bool, - line: Vec, + inline: Vec, file: SmallVec<[Suppression; 1]>, unknown: Vec, invalid: Vec, @@ -510,7 +505,7 @@ impl<'a> SuppressionsBuilder<'a> { source, lint_registry, seen_non_trivia_token: false, - line: Vec::new(), + inline: Vec::new(), file: SmallVec::new_const(), unknown: Vec::new(), invalid: Vec::new(), @@ -522,21 +517,20 @@ impl<'a> SuppressionsBuilder<'a> { } fn finish(mut self) -> Suppressions { - self.line.shrink_to_fit(); self.file.shrink_to_fit(); self.unknown.shrink_to_fit(); self.invalid.shrink_to_fit(); Suppressions { file: self.file, - line: self.line, + inline: IntervalIndex::from_sorted(self.inline), unknown: self.unknown, invalid: self.invalid, } } #[expect(clippy::needless_pass_by_value)] - fn add_comment(&mut self, comment: SuppressionComment, line_range: TextRange) { + fn add_comment(&mut self, comment: SuppressionComment, line_range: TextRange, tokens: &Tokens) { // ignore comments at the start of the file apply to the entire range. // > A # type: ignore comment on a line by itself at the top of a file, before any docstrings, // > imports, or other executable code, silences all errors in the file. @@ -544,9 +538,14 @@ impl<'a> SuppressionsBuilder<'a> { // > may precede the # type: ignore comment. // > https://typing.python.org/en/latest/spec/directives.html#type-ignore-comments let is_file_suppression = !self.seen_non_trivia_token; + let comment_token_start = tokens.token_range(comment.range().start()).start(); let suppressed_range = if is_file_suppression { TextRange::new(0.into(), self.source.text_len()) + } else if !comment.kind().is_type_ignore() + && indentation_at_offset(comment_token_start, self.source).is_some() + { + own_line_suppression_range(comment.range(), tokens) } else { line_range }; @@ -555,7 +554,7 @@ impl<'a> SuppressionsBuilder<'a> { if is_file_suppression { self.file.push(suppression); } else { - self.line.push(suppression); + self.inline.push(suppression); } }; @@ -624,6 +623,70 @@ impl<'a> SuppressionsBuilder<'a> { } } +/// Returns the range covered by an own-line suppression comment. +/// +/// A suppression before a logical line covers the entire logical line. A suppression inside a +/// multiline logical line covers the next non-comment physical line instead. This matches Ruff's +/// own-line suppression behavior. +fn own_line_suppression_range(range: TextRange, tokens: &Tokens) -> TextRange { + let comment_token_start = tokens.token_range(range.start()).start(); + let (before, after) = tokens.split_at(comment_token_start); + let mut end = range.end(); + + // A suppression after a logical newline precedes a new logical line: + // + // # ty: ignore + // value = ( + // missing + // ) + // + // A suppression after a non-logical newline is inside an unfinished logical line: + // + // values = [ + // # ty: ignore + // missing, + // ] + // + // Walk backwards through comments and non-logical newlines to distinguish the two cases. + let is_inner_comment = before.iter().rev().find_map(|token| match token.kind() { + TokenKind::Newline => Some(false), + TokenKind::NonLogicalNewline | TokenKind::Comment => None, + _ => Some(true), + }); + + let is_inner_comment = is_inner_comment.unwrap_or(false); + // For an inner suppression, the first non-logical newline ends the suppression's own + // physical line. Subsequent blank or comment-only lines are skipped before the range ends at + // the first physical line containing code. + let mut is_blank_or_comment_only = true; + let mut past_suppression_line = false; + + for token in after { + match token.kind() { + TokenKind::Newline => { + // A suppression preceding a logical line includes that complete logical line. + end = token.start(); + break; + } + TokenKind::Comment => {} + TokenKind::NonLogicalNewline if is_inner_comment => { + end = token.start(); + if past_suppression_line && !is_blank_or_comment_only { + break; + } + past_suppression_line = true; + is_blank_or_comment_only = true; + } + _ => { + is_blank_or_comment_only = false; + end = token.end(); + } + } + } + + TextRange::new(range.start(), end) +} + /// Suppression for an unknown lint rule. #[derive(Debug, PartialEq, Eq, get_size2::GetSize)] struct UnknownSuppression { @@ -641,3 +704,120 @@ struct InvalidSuppression { kind: SuppressionKind, error: ParseError, } + +/// A value with a source range that can be stored in an [`IntervalIndex`]. +trait Interval { + /// Returns the range indexed for this value. + fn interval(&self) -> TextRange; +} + +/// A start-sorted interval index. +/// +/// The entries form an implicit balanced binary tree. Each entry stores the maximum interval end +/// in its subtree, which allows intersection queries to skip subtrees that end before the query. +/// Intervals may overlap or nest, and queries return them in their original order. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize)] +struct IntervalIndex { + entries: Box<[IntervalEntry]>, +} + +/// An indexed value and the largest interval end in its implicit subtree. +#[derive(Debug, Eq, PartialEq, get_size2::GetSize)] +struct IntervalEntry { + value: T, + subtree_max_end: TextSize, +} + +impl IntervalIndex { + /// Builds an index from values sorted by interval start, retaining their input order. + /// + /// The caller must ensure that `values` is sorted by [`Interval::interval`] start. + fn from_sorted(values: Vec) -> Self { + debug_assert!(values.is_sorted_by_key(|value| value.interval().start())); + + let mut entries = values + .into_iter() + .map(|value| IntervalEntry { + subtree_max_end: value.interval().end(), + value, + }) + .collect::>(); + + Self::set_subtree_max_ends(&mut entries); + + Self { entries } + } + + /// Populates each entry's subtree maximum and returns the maximum end in `entries`. + fn set_subtree_max_ends(entries: &mut [IntervalEntry]) -> TextSize { + let mid = entries.len() / 2; + let (left, root_and_right) = entries.split_at_mut(mid); + let Some((root, right)) = root_and_right.split_first_mut() else { + return TextSize::default(); + }; + + let left_max_end = Self::set_subtree_max_ends(left); + let right_max_end = Self::set_subtree_max_ends(right); + root.subtree_max_end = root + .value + .interval() + .end() + .max(left_max_end) + .max(right_max_end); + root.subtree_max_end + } + + /// Returns the indexed values that intersect `query`, in input order. + /// + /// Interval endpoints are treated as inclusive so that an empty diagnostic range at an + /// interval boundary remains a candidate. Callers can apply stricter containment rules to the + /// returned values. + fn intersecting(&self, query: TextRange) -> impl Iterator { + let mut pending: SmallVec<[&[IntervalEntry]; 16]> = smallvec![self.entries.as_ref()]; + + std::iter::from_fn(move || { + while let Some(entries) = pending.pop() { + match entries { + [entry] => { + if entry.value.interval().start() <= query.end() + && entry.value.interval().end() >= query.start() + { + return Some(&entry.value); + } + } + entries => { + let mid = entries.len() / 2; + let (left, root_and_right) = entries.split_at(mid); + let Some((root, right)) = root_and_right.split_first() else { + continue; + }; + + if root.subtree_max_end < query.start() { + continue; + } + + if root.value.interval().start() > query.end() { + pending.push(left); + continue; + } + + // Push in reverse source order so the left subtree is visited first. + pending.push(right); + pending.push(std::slice::from_ref(root)); + pending.push(left); + } + } + } + + None + }) + } + + fn iter(&self) -> impl Iterator { + self.entries.iter().map(|entry| &entry.value) + } + + fn len(&self) -> usize { + self.entries.len() + } +} diff --git a/crates/ty_python_semantic/src/suppression/add_ignore.rs b/crates/ty_python_semantic/src/suppression/add_ignore.rs index cd1351613e..e0e8f89065 100644 --- a/crates/ty_python_semantic/src/suppression/add_ignore.rs +++ b/crates/ty_python_semantic/src/suppression/add_ignore.rs @@ -272,7 +272,7 @@ fn add_to_existing_suppression( offset: TextSize, ) -> Option { let mut existing_suppressions = suppressions - .line_suppressions(TextRange::empty(offset)) + .inline_suppressions(TextRange::empty(offset)) .filter(|suppression| { matches!( suppression.target, diff --git a/crates/ty_python_semantic/src/suppression/unused.rs b/crates/ty_python_semantic/src/suppression/unused.rs index ad6183e0c0..88e2228740 100644 --- a/crates/ty_python_semantic/src/suppression/unused.rs +++ b/crates/ty_python_semantic/src/suppression/unused.rs @@ -1,5 +1,6 @@ use ruff_db::source::source_text; use ruff_diagnostics::{Edit, Fix}; +use ruff_python_trivia::indentation_at_offset; use ruff_text_size::{TextLen, TextRange, TextSize}; use std::fmt::Write as _; @@ -26,12 +27,12 @@ pub(super) fn check_unused_suppressions(context: &mut CheckSuppressionsContext) let mut unused = Vec::with_capacity( all.file .len() - .saturating_add(all.line.len()) + .saturating_add(all.inline.len()) .saturating_sub(diagnostics.used_len()), ); // Collect all suppressions that are unused after type-checking. - for suppression in all { + for suppression in all.iter() { if diagnostics.is_used(suppression.id()) { continue; } @@ -211,7 +212,15 @@ fn remove_comment_fix(suppression: &Suppression, source: &str) -> Fix { if !after_comment.starts_with(['\n', '\r']) && !after_comment.is_empty() { // For example: `# ty: ignore # fmt: off` // Don't remove the trailing whitespace up to the `ty: ignore` comment - return Fix::safe_edit(Edit::range_deletion(suppression.comment_range)); + let edit = Edit::range_deletion(suppression.comment_range); + + if indentation_at_offset(comment_start, source).is_some() { + // Removing `# ty: ignore` from `# ty: ignore # fmt: off` would promote + // `# fmt: off` to the primary own-line comment. + return Fix::unsafe_edit(edit); + } + + return Fix::safe_edit(edit); } // Remove any leading whitespace before the comment From 41be4af721b50a638e794a07fff93e93c1a6254b Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 15 Jul 2026 17:05:58 +0200 Subject: [PATCH 093/222] [ty] Use `let` guards more (#26853) ## Summary Now that we bumped our MSRV to 1.95, we can use `let` guards in `match` arms to remove quite a few `.unwrap()` calls and instances of duplicated computations ## Test Plan existing tests --- crates/ty_python_semantic/src/types.rs | 24 +++--- .../ty_python_semantic/src/types/generics.rs | 18 ++--- .../ty_python_semantic/src/types/relation.rs | 75 ++++++------------- .../ty_python_semantic/src/types/subscript.rs | 38 ++++++---- 4 files changed, 67 insertions(+), 88 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 6ebf7dd0ae..3a9478c5e0 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3931,8 +3931,10 @@ impl<'db> Type<'db> { ) .into(), - Type::LiteralValue(literal) if literal.is_string() && name == "startswith" => { - let string_literal = literal.as_string().unwrap(); + Type::LiteralValue(literal) + if name == "startswith" + && let Some(string_literal) = literal.as_string() => + { Place::bound(Type::KnownBoundMethod(KnownBoundMethodType::StrStartswith( string_literal, ))) @@ -4114,9 +4116,9 @@ impl<'db> Type<'db> { } Type::LiteralValue(literal) - if literal.is_bool() && matches!(name_str, "real" | "numerator") => + if matches!(name_str, "real" | "numerator") + && let Some(bool_value) = literal.as_bool() => { - let bool_value = literal.as_bool().unwrap(); Place::bound(Type::int_literal(i64::from(bool_value))).into() } @@ -4171,15 +4173,13 @@ impl<'db> Type<'db> { Type::LiteralValue(literal) if matches!(name_str, "name" | "_name_" | "value" | "_value_") - && literal.as_enum().is_some_and(|enum_literal| { - !enums::class_defines_property( - db, - enum_literal.enum_class(db), - name_str, - ) - }) => + && let Some(enum_literal) = literal.as_enum() + && !enums::class_defines_property( + db, + enum_literal.enum_class(db), + name_str, + ) => { - let enum_literal = literal.as_enum().unwrap(); let enum_class = enum_literal.enum_class_literal(db); let is_enum_subclass = Type::ClassLiteral(enum_class.class_literal(db)) .is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)); diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index ec7a373004..8c8e509ee5 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -3003,10 +3003,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { | Type::GenericAlias(_) | Type::SubclassOf(_) | Type::Union(_)), - ) if matches!(formal_subclass.subclass_of(), SubclassOfInner::Protocol(_)) => { - let SubclassOfInner::Protocol(protocol) = formal_subclass.subclass_of() else { - return Ok(()); - }; + ) if let SubclassOfInner::Protocol(protocol) = formal_subclass.subclass_of() => { let formal_protocol = Type::ProtocolInstance(protocol); if let Type::Union(union) = actual { for element in union.elements(self.db) { @@ -3028,12 +3025,15 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } (Type::SubclassOf(subclass_of), ty) | (ty, Type::SubclassOf(subclass_of)) - if subclass_of.is_type_var() => + if let Some(type_var) = subclass_of.into_type_var() + && let Some(actual_instance) = ty.to_instance(self.db) => { - let formal_instance = Type::TypeVar(subclass_of.into_type_var().unwrap()); - if let Some(actual_instance) = ty.to_instance(self.db) { - return self.infer_map_impl(formal_instance, actual_instance, polarity, seen); - } + return self.infer_map_impl( + Type::TypeVar(type_var), + actual_instance, + polarity, + seen, + ); } ( diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 4d4d955dd4..c8c178bf0c 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1107,8 +1107,6 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { }) }; - let bound_or_constraints; - match (source, target) { // Everything is a subtype of `object`. (_, Type::NominalInstance(target)) if target.is_object() => self.always(), @@ -1395,14 +1393,10 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // And vice versa. (No special metaclass handling is needed in this direction, since // "collapse to 'object'" in this case is a sound over-approximation.) (_, Type::SubclassOf(subclass_of)) - if subclass_of.is_type_var() && source.to_instance(db).is_some() => + if let Some(type_var) = subclass_of.into_type_var() + && let Some(instance) = source.to_instance(db) => { - subclass_of - .into_type_var() - .zip(source.to_instance(db)) - .when_some_and(db, self.constraints, |(target_i, source_i)| { - self.check_type_pair(db, source_i, Type::TypeVar(target_i)) - }) + self.check_type_pair(db, instance, Type::TypeVar(type_var)) } // A gradual `ParamSpec` value (`...`) is assignability-consistent with any concrete @@ -1433,23 +1427,21 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // the union of its constraints. An unbound, unconstrained, fully static typevar has an // implicit upper bound of `object` (which is handled above). (Type::TypeVar(bound_typevar), _) - if !bound_typevar.is_inferable(db, self.inferable) && { - bound_or_constraints = bound_typevar.typevar(db).bound_or_constraints(db); - bound_or_constraints.is_some() - } => + if !bound_typevar.is_inferable(db, self.inferable) + && let Some(bound_or_constraints) = + bound_typevar.typevar(db).bound_or_constraints(db) => { match bound_or_constraints { - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + TypeVarBoundOrConstraints::UpperBound(bound) => { self.check_type_pair(db, bound, target) } - Some(TypeVarBoundOrConstraints::Constraints(typevar_constraints)) => { + TypeVarBoundOrConstraints::Constraints(typevar_constraints) => { typevar_constraints.elements(db).iter().when_all( db, self.constraints, |constraint| self.check_type_pair(db, *constraint, target), ) } - None => unreachable!(), } } @@ -1458,7 +1450,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // disjoint, which means an lhs type might be a subtype of all of the constraints. (_, Type::TypeVar(bound_typevar)) if !bound_typevar.is_inferable(db, self.inferable) - && !bound_typevar + && let constraints = bound_typevar .typevar(db) .constraints(db) .when_some_and(db, self.constraints, |constraints| { @@ -1466,21 +1458,9 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.check_type_pair(db, source, *c) }) }) - .is_never_satisfied(db) => + && !constraints.is_never_satisfied(db) => { - // TODO: The repetition here isn't great, but we really need the fallthrough logic, - // where this arm only engages if it returns true (or in the world of constraints, - // not false). Once we're using real constraint sets instead of bool, we should be - // able to simplify the typevar logic. - bound_typevar.typevar(db).constraints(db).when_some_and( - db, - self.constraints, - |constraints| { - constraints.iter().when_all(db, self.constraints, |c| { - self.check_type_pair(db, source, *c) - }) - }, - ) + constraints } (Type::TypeVar(bound_typevar), _) if bound_typevar.is_inferable(db, self.inferable) => { @@ -1953,9 +1933,8 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // A string literal `Literal["abc"]` is assignable to `str` *and* to // `Sequence[Literal["a", "b", "c"]]` because strings are sequences of their characters. (Type::LiteralValue(literal), Type::NominalInstance(instance)) - if literal.is_string() => + if let Some(value) = literal.as_string() => { - let value = literal.as_string().unwrap(); let target_class = instance.class(db); if target_class.is_known(db, KnownClass::Str) { @@ -2001,9 +1980,8 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // A bytes literal `Literal[b"abc"]` is assignable to `bytes` *and* to // `Sequence[Literal[97, 98, 99]]` because bytes are sequences of integers. (Type::LiteralValue(literal), Type::NominalInstance(instance)) - if literal.is_bytes() => + if let Some(value) = literal.as_bytes() => { - let value = literal.as_bytes().unwrap(); let target_class = instance.class(db); if target_class.is_known(db, KnownClass::Bytes) { @@ -2047,8 +2025,9 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // An instance is a subtype of an enum literal, if it is an instance of the enum class // and the enum has only one member. - (Type::NominalInstance(_), Type::LiteralValue(literal)) if literal.is_enum() => { - let target_enum_literal = literal.as_enum().unwrap(); + (Type::NominalInstance(_), Type::LiteralValue(literal)) + if let Some(target_enum_literal) = literal.as_enum() => + { if target_enum_literal.enum_class_instance(db) != source { self.never() } else { @@ -2625,26 +2604,20 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | ( other @ (Type::Callable(_) | Type::ProtocolInstance(_)), Type::SubclassOf(subclass_of), - ) if subclass_of.is_type_var() => { - let type_var = subclass_of - .subclass_of() - .with_transposed_type_var(db) - .into_type_var() - .unwrap(); - + ) if let Some(type_var) = subclass_of + .subclass_of() + .with_transposed_type_var(db) + .into_type_var() => + { self.check_type_pair(db, Type::TypeVar(type_var), other) } // `type[T]` is disjoint from a class object `A` if every instance of `T` is disjoint from an instance of `A`. (Type::SubclassOf(subclass_of), other) | (other, Type::SubclassOf(subclass_of)) - if subclass_of.is_type_var() && other.to_instance(db).is_some() => + if let Some(type_var) = subclass_of.into_type_var() + && let Some(instance) = other.to_instance(db) => { - subclass_of - .into_type_var() - .zip(other.to_instance(db)) - .when_none_or(db, self.constraints, |(this_instance, other_instance)| { - self.check_type_pair(db, Type::TypeVar(this_instance), other_instance) - }) + self.check_type_pair(db, Type::TypeVar(type_var), instance) } // A typevar is never disjoint from itself, since all occurrences of the typevar must diff --git a/crates/ty_python_semantic/src/types/subscript.rs b/crates/ty_python_semantic/src/types/subscript.rs index 981e002de6..57a99afb1b 100644 --- a/crates/ty_python_semantic/src/types/subscript.rs +++ b/crates/ty_python_semantic/src/types/subscript.rs @@ -609,8 +609,9 @@ impl<'db> Type<'db> { } // Ex) Given `("a", "b", "c", "d")[1]`, return `"b"` - (Type::NominalInstance(nominal), Type::LiteralValue(literal)) if literal.is_int() => { - let i64_int = literal.as_int().unwrap(); + (Type::NominalInstance(nominal), Type::LiteralValue(literal)) + if let Some(i64_int) = literal.as_int() => + { nominal .tuple_spec(db) .and_then(|tuple| Some((tuple, i32::try_from(i64_int).ok()?))) @@ -643,9 +644,10 @@ impl<'db> Type<'db> { }), // Ex) Given `"value"[1]`, return `"a"` - (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) if lhs_literal.is_string() && rhs_literal.is_int() => { - let literal_ty = lhs_literal.as_string().unwrap(); - let i64_int = rhs_literal.as_int().unwrap(); + (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) + if let Some(literal_ty) = lhs_literal.as_string() + && let Some(i64_int) = rhs_literal.as_int() => + { i32::try_from(i64_int).ok().map(|i32_int| { let literal_value = literal_ty.value(db); match (&mut literal_value.chars()).py_index(db, i32_int) { @@ -664,8 +666,9 @@ impl<'db> Type<'db> { } // Ex) Given `"value"[1:3]`, return `"al"` - (Type::LiteralValue(literal), Type::NominalInstance(nominal)) if literal.is_string() => { - let literal_ty = literal.as_string().unwrap(); + (Type::LiteralValue(literal), Type::NominalInstance(nominal)) + if let Some(literal_ty) = literal.as_string() => + { nominal .slice_literal(db) .map(|SliceLiteral { start, stop, step }| { @@ -696,9 +699,10 @@ impl<'db> Type<'db> { } // Ex) Given `b"value"[1]`, return `97` (i.e., `ord(b"a")`) - (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) if lhs_literal.is_bytes() && rhs_literal.is_int() => { - let literal_ty = lhs_literal.as_bytes().unwrap(); - let i64_int = rhs_literal.as_int().unwrap(); + (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) + if let Some(literal_ty) = lhs_literal.as_bytes() + && let Some(i64_int) = rhs_literal.as_int() => + { i32::try_from(i64_int).ok().map(|i32_int| { let literal_value = literal_ty.value(db); match literal_value.py_index(db, i32_int) { @@ -717,9 +721,9 @@ impl<'db> Type<'db> { } // Ex) Given `b"value"[1:3]`, return `b"al"` - (Type::LiteralValue(literal), Type::NominalInstance(nominal)) if literal.is_bytes() => + (Type::LiteralValue(literal), Type::NominalInstance(nominal)) + if let Some(literal_ty) = literal.as_bytes() => { - let literal_ty = literal.as_bytes().unwrap(); nominal .slice_literal(db) .map(|SliceLiteral { start, stop, step }| { @@ -739,15 +743,17 @@ impl<'db> Type<'db> { }, // Ex) Given `"value"[True]`, return `"a"` - (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) if (lhs_literal.is_string() || lhs_literal.is_bytes()) && rhs_literal.is_bool() => { - let bool = rhs_literal.as_bool().unwrap(); + (Type::LiteralValue(lhs_literal), Type::LiteralValue(rhs_literal)) + if (lhs_literal.is_string() || lhs_literal.is_bytes()) + && let Some(bool) = rhs_literal.as_bool() => + { Some(value_ty.subscript(db, Type::int_literal(i64::from(bool)), expr_context)) } (Type::NominalInstance(nominal), Type::LiteralValue(literal)) - if literal.is_bool() && nominal.tuple_spec(db).is_some() => + if let Some(bool) = literal.as_bool() + && nominal.tuple_spec(db).is_some() => { - let bool = literal.as_bool().unwrap(); Some(value_ty.subscript(db, Type::int_literal(i64::from(bool)), expr_context)) } From 46e214cdd3da2a358b5e3bee21c1b339aa698805 Mon Sep 17 00:00:00 2001 From: RasmusNygren Date: Wed, 15 Jul 2026 17:20:20 +0200 Subject: [PATCH 094/222] [ty] Project exact-length narrowing through TypeVars (#26852) ## Summary Support exact length narrowing also for typevars. Code like this was previously rejected. ```python T = TypeVar("T", tuple[int, int], tuple[int, int, str]) def f(x: T) -> None: if len(x) == 2: ... elif len(x) == 3: ... else: assert_never(x) # `Never` and `T@f_typevar` are not equivalent types ``` ## Test Plan New mdtests --- .../resources/mdtest/narrow/len.md | 49 +++++++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 27 ++++++++++ 2 files changed, 76 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/len.md b/crates/ty_python_semantic/resources/mdtest/narrow/len.md index ec0ccec0db..3eb3b43086 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/len.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/len.md @@ -129,6 +129,55 @@ def _(x: Literal[b"", b"a"], y: Literal["a", "ab"]): reveal_type(y) # revealed: Literal["ab"] ``` +Exact length narrowing projects through type-variable constraints and bounds while preserving the +type variable: + +```py +from typing import TypeVar, assert_never + +ConstrainedTuple = TypeVar( + "ConstrainedTuple", + tuple[int, int], + tuple[int, int, str], +) + +def constrained_tuple(value: ConstrainedTuple) -> ConstrainedTuple: + if len(value) == 2: + reveal_type(value) # revealed: ConstrainedTuple@constrained_tuple & tuple[int, int] + return value + elif len(value) == 3: + reveal_type(value) # revealed: ConstrainedTuple@constrained_tuple & tuple[int, int, str] + return value + else: + assert_never(value) + +BoundTuple = TypeVar( + "BoundTuple", + bound=tuple[int, int] | tuple[int, int, str], +) + +def bounded_tuple(value: BoundTuple) -> BoundTuple: + if len(value) == 2: + reveal_type(value) # revealed: BoundTuple@bounded_tuple & tuple[int, int] + return value + elif len(value) == 3: + reveal_type(value) # revealed: BoundTuple@bounded_tuple & tuple[int, int, str] + return value + else: + assert_never(value) + +VariableTuple = TypeVar( + "VariableTuple", + tuple[int, ...], + tuple[str], +) + +def variable_tuple(value: VariableTuple) -> VariableTuple: + if len(value) == 2: + reveal_type(value) # revealed: VariableTuple@variable_tuple & tuple[int, int] + return value +``` + Tuple subclasses are filtered using their tuple spec while preserving the subclass: ```py diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 97220a8e9e..7a7b767dd4 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -2785,6 +2785,33 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { Type::Intersection(intersection) => intersection.map_positive(db, |element| { Self::narrow_type_by_exact_len(db, *element, length, is_equality) }), + Type::TypeVar(typevar) => { + let Some(bound_or_constraints) = typevar.typevar(db).bound_or_constraints(db) + else { + return ty; + }; + + let upper_bound = bound_or_constraints.as_type(db); + let narrowed_upper_bound = match bound_or_constraints { + TypeVarBoundOrConstraints::UpperBound(bound) => { + Self::narrow_type_by_exact_len(db, bound, length, is_equality) + } + TypeVarBoundOrConstraints::Constraints(constraints) => { + UnionType::from_elements( + db, + constraints.elements(db).iter().map(|constraint| { + Self::narrow_type_by_exact_len(db, *constraint, length, is_equality) + }), + ) + } + }; + + if narrowed_upper_bound == upper_bound { + resolved + } else { + IntersectionType::from_two_elements(db, resolved, narrowed_upper_bound) + } + } _ => { if is_equality && let Some(tuple) = resolved.exact_tuple_instance_spec(db) { match tuple.resize(db, TupleLength::Fixed(length)) { From ecba8d6f83a0b4a0da79470c95012abdf0aa6852 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 15 Jul 2026 18:13:27 +0200 Subject: [PATCH 095/222] [ty] Replace type hierarchy callback with an enum (#26855) --- .../api/requests/type_hierarchy_subtypes.rs | 4 ++-- .../api/requests/type_hierarchy_supertypes.rs | 4 ++-- .../src/server/api/type_hierarchy.rs | 19 ++++++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/ty_server/src/server/api/requests/type_hierarchy_subtypes.rs b/crates/ty_server/src/server/api/requests/type_hierarchy_subtypes.rs index 6c1accf899..659311c69c 100644 --- a/crates/ty_server/src/server/api/requests/type_hierarchy_subtypes.rs +++ b/crates/ty_server/src/server/api/requests/type_hierarchy_subtypes.rs @@ -4,7 +4,7 @@ use lsp_types::{TypeHierarchyItem, TypeHierarchySubtypesParams}; use crate::server::api::traits::{ BackgroundRequestHandler, RequestHandler, RetriableRequestHandler, }; -use crate::server::api::type_hierarchy::hierarchy_handler; +use crate::server::api::type_hierarchy::{TypeHierarchyKind, hierarchy_handler}; use crate::session::SessionSnapshot; use crate::session::client::Client; @@ -28,7 +28,7 @@ impl BackgroundRequestHandler for TypeHierarchySubtypesRequestHandler { Ok(hierarchy_handler( snapshot, ¶ms.item, - ty_ide::type_hierarchy_subtypes, + TypeHierarchyKind::Subtypes, )) } } diff --git a/crates/ty_server/src/server/api/requests/type_hierarchy_supertypes.rs b/crates/ty_server/src/server/api/requests/type_hierarchy_supertypes.rs index 5a15bb791f..66fe94b023 100644 --- a/crates/ty_server/src/server/api/requests/type_hierarchy_supertypes.rs +++ b/crates/ty_server/src/server/api/requests/type_hierarchy_supertypes.rs @@ -4,7 +4,7 @@ use lsp_types::{TypeHierarchyItem, TypeHierarchySupertypesParams}; use crate::server::api::traits::{ BackgroundRequestHandler, RequestHandler, RetriableRequestHandler, }; -use crate::server::api::type_hierarchy::hierarchy_handler; +use crate::server::api::type_hierarchy::{TypeHierarchyKind, hierarchy_handler}; use crate::session::SessionSnapshot; use crate::session::client::Client; @@ -28,7 +28,7 @@ impl BackgroundRequestHandler for TypeHierarchySupertypesRequestHandler { Ok(hierarchy_handler( snapshot, ¶ms.item, - ty_ide::type_hierarchy_supertypes, + TypeHierarchyKind::Supertypes, )) } } diff --git a/crates/ty_server/src/server/api/type_hierarchy.rs b/crates/ty_server/src/server/api/type_hierarchy.rs index 1f8d9fb8d3..b228c31f25 100644 --- a/crates/ty_server/src/server/api/type_hierarchy.rs +++ b/crates/ty_server/src/server/api/type_hierarchy.rs @@ -1,6 +1,4 @@ use lsp_types::{SymbolKind, TypeHierarchyItem}; -use ruff_db::files::File; -use ruff_text_size::TextSize; use ty_project::ProjectDatabase; use crate::PositionEncoding; @@ -8,14 +6,17 @@ use crate::document::{ToRangeExt, resolve_file_uri_range}; use crate::session::SessionSnapshot; use crate::system::file_to_uri; +#[derive(Debug, Copy, Clone)] +pub(crate) enum TypeHierarchyKind { + Subtypes, + Supertypes, +} + /// The subtype and supertype implementation. -/// -/// `hierarchy_types` should be either `ty_ide::type_hierarchy_subtypes` -/// or `ty_ide::type_hierarchy_supertypes`. pub(crate) fn hierarchy_handler( snapshot: &SessionSnapshot, requested_item: &TypeHierarchyItem, - hierarchy_types: fn(&dyn ty_project::Db, File, TextSize) -> Vec, + hierarchy_kind: TypeHierarchyKind, ) -> Option> { let encoding = snapshot.position_encoding(); @@ -32,8 +33,12 @@ pub(crate) fn hierarchy_handler( ) else { continue; }; + let hierarchy_types = match hierarchy_kind { + TypeHierarchyKind::Subtypes => ty_ide::type_hierarchy_subtypes(db, file, offset), + TypeHierarchyKind::Supertypes => ty_ide::type_hierarchy_supertypes(db, file, offset), + }; items.extend( - hierarchy_types(db, file, offset) + hierarchy_types .into_iter() .filter_map(|item| convert_to_lsp_item(db, item, encoding)), ); From 4e8437822818862aabfe224b8bb6e2ad1389650f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 11:06:51 -0700 Subject: [PATCH 096/222] [ty] Centralize protocol constraint inference (#26858) ## Summary Protocol specialization currently repeats the same constraint-set inference sequence in several branches: derive or load a relation, add its type mappings, then intersect the pending constraints only when the relation is satisfiable. Each site must also preserve the deliberate stopgap behavior that an unsatisfiable protocol relation means "no inference" rather than an immediate specialization error. This centralizes that sequence in `SpecializationBuilder::infer_from_protocol_constraint_set`. The change is behavior-preserving, but makes the hybrid old/new solver semantics explicit in one place and keeps new protocol inference paths from drifting. This is split out of #26566. --- .../ty_python_semantic/src/types/generics.rs | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 8c8e509ee5..0f009e6e89 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2465,6 +2465,18 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { Ok(()) } + /// Infer type mappings from protocol constraints. + /// + /// Unsatisfiable results are treated as "no inference" instead of an immediate specialization + /// error. This matches the previous behavior, where unsatisfied comparisons simply produced no + /// type mappings, and avoids false positives while this path is still a hybrid of the old and + /// new solver logic. + fn infer_from_protocol_constraint_set(&mut self, when: ConstraintSet<'db, 'c>) { + if self.add_type_mappings_from_constraint_set(when).is_ok() { + self.pending.intersect(self.db, self.constraints, when); + } + } + /// Returns common protocol constraints for a union containing only `TypedDict`s when every /// member has the same constraints as their shared `Mapping[str, object]` fallback. fn common_typed_dict_protocol_constraints( @@ -3120,15 +3132,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { // (replacing the logic below). let when = actual.when_constraint_set_assignable_to_owned(self.db, formal); let when = self.constraints.load(self.db, &when); - // For protocol inference via constraint sets, we currently treat - // unsatisfiable results as "no inference" instead of an immediate - // specialization error. This matches the previous behavior (where - // unsatisfied comparisons simply produced no type mappings), and avoids - // false positives for callable-wrapper patterns while this path is still - // a hybrid of old and new solver logic. - if self.add_type_mappings_from_constraint_set(when).is_ok() { - self.pending.intersect(self.db, self.constraints, when); - } + self.infer_from_protocol_constraint_set(when); return Ok(()); } @@ -3173,24 +3177,14 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { .unwrap_or_else(|| { actual.when_constraint_set_assignable_to(self.db, formal, self.constraints) }); - // For protocol inference via constraint sets, keep unsatisfiable results non-fatal - // for now, matching the protocol constraint-set path in the nominal-instance - // arm above. - if self.add_type_mappings_from_constraint_set(when).is_ok() { - self.pending.intersect(self.db, self.constraints, when); - } + self.infer_from_protocol_constraint_set(when); return Ok(()); } (formal @ Type::ProtocolInstance(_), actual @ Type::TypedDict(_)) => { let when = actual.when_constraint_set_assignable_to_owned(self.db, formal); let when = self.constraints.load(self.db, &when); - // For protocol inference via constraint sets, keep unsatisfiable results non-fatal - // for now, matching the protocol constraint-set path in the nominal-instance - // arm above. - if self.add_type_mappings_from_constraint_set(when).is_ok() { - self.pending.intersect(self.db, self.constraints, when); - } + self.infer_from_protocol_constraint_set(when); return Ok(()); } From ad3cb60b26c53624f909a058c8867868a8c181b1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 12:27:32 -0700 Subject: [PATCH 097/222] [ty] Avoid recursive protocol union redundancy checks (#26689) ## Summary When simplifying a union containing two specializations of the same class-based protocol, we currently fall back from the nominal specialization relation to a full structural protocol comparison if the nominal relation fails. For recursive protocols with overloaded members, that structural fallback can recursively expand the same protocol under ever-growing specializations, causing combinatorial growth on the motivating reproducer. For non-pure redundancy, a failed nominal relation is enough to conservatively keep both union arms. We now stop there for same-origin class-based protocols instead of attempting the structural fallback. Pure redundancy is deliberately excluded so equivalence checking keeps the existing structural behavior. Related: --- .../ty_python_semantic/src/types/instance.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index e440537f2d..ffe5eb223f 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -21,7 +21,7 @@ use crate::types::protocol_class::{ ProtocolClass, has_all_protocol_members_defined, walk_protocol_interface, }; use crate::types::relation::{ - DisjointnessChecker, HasRelationToVisitor, IsDisjointVisitor, TypeRelationChecker, + DisjointnessChecker, HasRelationToVisitor, IsDisjointVisitor, TypeRelation, TypeRelationChecker, }; use crate::types::signatures::SignatureRelationVisitor; use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; @@ -527,6 +527,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { { return result; } + + // For union simplification, failing the nominal relation between two + // specializations of the same protocol class is enough to keep both union elements. + // Falling back to the structural relation can recursively compare every protocol + // member even though a failed redundancy check only means that we preserve a + // potentially redundant union arm. + if matches!(self.relation, TypeRelation::Redundancy { pure: false }) + && ty + .as_protocol_instance() + .and_then(ProtocolInstanceType::to_nominal_instance) + .is_some_and(|source_instance| { + source_instance.class(db).class_literal(db) + == nominal_instance.class(db).class_literal(db) + }) + { + return nominally_satisfied; + } } // `Generator` special case: compare the type parameters nominally. Prior to 3.13, its From b37d56b2a3e683f8e357409a488a2a9fb1e12b55 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 12:44:40 -0700 Subject: [PATCH 098/222] [ty] Separate type-mapping caches by transformation mode (#26857) ## Summary `ApplyTypeMappingVisitor` caches each transformed `Type` so recursive type mappings terminate, but all mapping modes other than direct `Top` and `Bottom` materialization currently share one `TypeTransformer`. If the same type is visited with promotion enabled and disabled, or under top and bottom specialization-plus-materialization, the second visit can reuse a result computed under the opposite transformation. This gives each mapping mode whose result can differ its own transformer cache. --- .../resources/mdtest/promotion.md | 13 ++++ .../mdtest/type_properties/materialization.md | 23 +++++++ crates/ty_python_semantic/src/types.rs | 68 +++++++++---------- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/promotion.md b/crates/ty_python_semantic/resources/mdtest/promotion.md index edc51c851a..4e945909af 100644 --- a/crates/ty_python_semantic/resources/mdtest/promotion.md +++ b/crates/ty_python_semantic/resources/mdtest/promotion.md @@ -380,6 +380,19 @@ def _(c: Consumer[Intersection[A, Not[AlwaysFalsy]]], p: Producer[Intersection[A reveal_type([p]) # revealed: list[Producer[A]] ``` +A callable can use the same type in both a contravariant parameter and a covariant return. When the +callable is promoted as a list element, these positions must be transformed independently: the +parameter keeps the narrowed type, while the return type is promoted. + +```py +type NarrowA = Intersection[A, Not[AlwaysFalsy]] + +def transform(value: NarrowA) -> NarrowA: + return value + +reveal_type([transform]) # revealed: list[(value: NarrowA) -> A] +``` + ## Literal annotations are respected Literal types that are explicitly annotated will not be promoted, even if they are initially diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md index a1ab0ac93d..f16ba7d56d 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md @@ -913,3 +913,26 @@ def _(top: Top[AliasedCallable[Any]], bottom: Bottom[AliasedCallable[Any]]) -> N reveal_type(top) # revealed: (Never, /) -> object reveal_type(bottom) # revealed: (object, /) -> Never ``` + +When a materialized class specialization is applied to an attribute, the same function-literal type +can be visited through both the parameter and return positions of a nested callable. Those positions +use opposite materialization polarities and must not share a transformation cache. + +```py +from ty_extensions import Top, Bottom +from typing import Any, Callable +from ty_extensions._internal import TypeOf + +class FunctionHolder[T]: + def shared(self, value: T) -> T: + raise NotImplementedError + + nested: Callable[[TypeOf[shared]], TypeOf[shared]] + +def _(top: Top[FunctionHolder[Any]], bottom: Bottom[FunctionHolder[Any]]) -> None: + # revealed: (def shared(self, value: object) -> Never, /) -> def shared(self, value: Never) -> object + reveal_type(top.nested) + + # revealed: (def shared(self, value: Never) -> object, /) -> def shared(self, value: object) -> Never + reveal_type(bottom.nested) +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 3a9478c5e0..c814ecd283 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -290,9 +290,7 @@ fn definition_expression_annotation<'db>( } } -struct ApplyDefaultTypeMapping; -struct ApplyTopMaterialization; -struct ApplyBottomMaterialization; +struct ApplyTypeMappingTag; struct ApplyMaterializationEquivalence; type MaterializationEquivalenceVisitor<'db> = @@ -300,13 +298,18 @@ type MaterializationEquivalenceVisitor<'db> = /// A [`TypeTransformer`] that is used in `apply_type_mapping` methods. /// -/// Materialization is the only mapping mode that needs to visit the same type under two different -/// mappings within a single recursive call chain (`Top` and `Bottom`). Keep separate cycle caches -/// for those modes so invariant checks can safely reuse one visitor. +/// Some recursive transformations visit the same type under more than one mapping mode within a +/// single call chain. Keep separate cycle caches for those modes so one transformation cannot +/// reuse the result of another. +#[derive(Default)] pub(crate) struct ApplyTypeMappingVisitor<'db> { - default: OnceCell>, - top_materialization: OnceCell>, - bottom_materialization: OnceCell>, + default: OnceCell>>, + top_materialization: OnceCell>>, + bottom_materialization: OnceCell>>, + top_specialization_materialization: OnceCell>>, + bottom_specialization_materialization: OnceCell>>, + promotion: OnceCell>>, + skip_promotion: OnceCell>>, materialization_equivalence: OnceCell>, } @@ -323,20 +326,24 @@ impl<'db> ApplyTypeMappingVisitor<'db> { type_mapping: &TypeMapping<'_, 'db>, func: impl FnOnce() -> Type<'db>, ) -> Type<'db> { - match type_mapping { - TypeMapping::Materialize(MaterializationKind::Top) => self - .top_materialization - .get_or_init(TypeTransformer::default) - .visit_type(db, ty, func), - TypeMapping::Materialize(MaterializationKind::Bottom) => self - .bottom_materialization - .get_or_init(TypeTransformer::default) - .visit_type(db, ty, func), - _ => self - .default - .get_or_init(TypeTransformer::default) - .visit_type(db, ty, func), - } + let type_transformer = match type_mapping { + TypeMapping::Materialize(MaterializationKind::Top) => &self.top_materialization, + TypeMapping::Materialize(MaterializationKind::Bottom) => &self.bottom_materialization, + TypeMapping::ApplySpecializationWithMaterialization { + materialization_kind: MaterializationKind::Top, + .. + } => &self.top_specialization_materialization, + TypeMapping::ApplySpecializationWithMaterialization { + materialization_kind: MaterializationKind::Bottom, + .. + } => &self.bottom_specialization_materialization, + TypeMapping::Promote(PromotionMode::On, _) => &self.promotion, + TypeMapping::Promote(PromotionMode::Off, _) => &self.skip_promotion, + _ => &self.default, + }; + type_transformer + .get_or_init(Box::default) + .visit_type(db, ty, func) } pub(crate) fn is_equivalent_to_materialization( @@ -357,21 +364,8 @@ impl<'db> ApplyTypeMappingVisitor<'db> { debug_assert!(was_empty.is_ok()); Self { - default: OnceCell::new(), - top_materialization: OnceCell::new(), - bottom_materialization: OnceCell::new(), materialization_equivalence, - } - } -} - -impl Default for ApplyTypeMappingVisitor<'_> { - fn default() -> Self { - Self { - default: OnceCell::new(), - top_materialization: OnceCell::new(), - bottom_materialization: OnceCell::new(), - materialization_equivalence: OnceCell::new(), + ..Self::default() } } } From 8c3856f52cc0dcbdedced8f7e31b6d43cc02b885 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 13:30:14 -0700 Subject: [PATCH 099/222] [ty] Use attrs for frozen project benchmark (#26867) ## Summary Use attrs instead of DateType as the representative real-world project for the frozen-input benchmark. (DateType is super Protocol-heavy and not a great "sample" project.) --- crates/ruff_benchmark/benches/ty.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index 8b403f1647..599e190589 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1860,6 +1860,10 @@ fn attrs(criterion: &mut Criterion) { ); bench_project(&benchmark, criterion); + + // Keep one real-world benchmark frozen to catch regressions from newly added inputs. + let frozen_benchmark = benchmark.freeze_inputs(); + bench_project_named(&frozen_benchmark, criterion, "attrs (frozen inputs)"); } fn anyio(criterion: &mut Criterion) { @@ -1894,10 +1898,6 @@ fn datetype(criterion: &mut Criterion) { ); bench_project(&benchmark, criterion); - - // Keep one cheap real-world benchmark frozen to catch regressions from newly added inputs. - let frozen_benchmark = benchmark.freeze_inputs(); - bench_project_named(&frozen_benchmark, criterion, "DateType (frozen inputs)"); } criterion_group!(check_file, benchmark_cold, benchmark_incremental); From 0ced7436cfdf02eda019335dfd040bd4e963e289 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 13:40:38 -0700 Subject: [PATCH 100/222] Restore the fuzz workspace lockfile (#26868) ## Summary Restore the fuzz workspace's committed Cargo lockfile and stop ignoring it. The fuzz workspace currently resolves dependencies from scratch on every CI run. This allowed the `get-size2` 0.10.2 release to select `compact_str` 0.10 alongside Ruff's `compact_str` 0.9 dependency, so `GetSize` was implemented for a different `CompactString` type and `cargo fuzz build` stopped compiling. The lockfile was originally committed when the fuzz harness was introduced, then removed in #5008 without a documented rationale. Keeping it committed makes fuzz dependency updates explicit and keeps the workspace on the compatible `get-size2` 0.10.1 / `compact_str` 0.9.1 pair for now. --- fuzz/.gitignore | 1 - fuzz/Cargo.lock | 2791 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2791 insertions(+), 1 deletion(-) create mode 100644 fuzz/Cargo.lock diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 41cc193fba..535350df39 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -1,3 +1,2 @@ artifacts/ corpus/ -Cargo.lock diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000000..58817ab358 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,2791 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drop_bomb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "get-size-derive2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1da24fbda09ec01bca7cfa1797c0e520e75123bccb01dcdf9041f8aa65183bc2" +dependencies = [ + "attribute-derive", + "quote", + "syn", +] + +[[package]] +name = "get-size2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "823645bc6404ae2915707777061a47d3a031a9ee0bff51b34ec973df3d8d2990" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.17.1", + "indexmap", + "ordermap", + "smallvec", + "thin-vec", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imperative" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e1d0bd9c575c52e59aad8e122a11786e852a154678d0c86e9e243d55273970" +dependencies = [ + "phf 0.13.1", + "rust-stemmers", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "intrusive-collections" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b719c59241cfaac1042a6d26787e28ed7ee4a4e21a5a907786f54222d1b0062" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libcst" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aea7143e4a0ed59b87a1ee71e198500889f8b005311136be15e84c97a6fcd8d" +dependencies = [ + "annotate-snippets", + "libcst_derive", + "memchr", + "paste", + "peg", + "regex", + "thiserror 2.0.18", +] + +[[package]] +name = "libcst_derive" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0903173ea316c34a44d0497161e04d9210af44f5f5e89bf2f55d9a254c9a0e8d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "git+https://github.com/rust-fuzz/libfuzzer#719e4efb9b8857ebaa782ae59376c8cbb78fed0f" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "newtype-uuid" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" +dependencies = [ + "uuid", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordermap" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7476a5b122ff1fce7208e7ee9dccd0a516e835f5b8b19b8f3c98a34cf757c1" +dependencies = [ + "indexmap", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "path-slash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "peg" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" + +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width", + "unscanny", + "version-ranges", +] + +[[package]] +name = "pep508_rs" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faee7227064121fcadcd2ff788ea26f0d8f2bd23a0574da11eca23bc935bcc05" +dependencies = [ + "boxcar", + "indexmap", + "itertools 0.13.0", + "once_cell", + "pep440_rs", + "regex", + "rustc-hash", + "serde", + "smallvec", + "thiserror 1.0.69", + "unicode-width", + "url", + "urlencoding", + "version-ranges", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyproject-toml" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d755483ad14b49e76713b52285235461a5b4f73f17612353e11a5de36a5fd2" +dependencies = [ + "indexmap", + "pep440_rs", + "pep508_rs", + "serde", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "quick-junit" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8216516c957e00535b3c91770524c219dd7df90dec439a182547f750dfe41591" +dependencies = [ + "chrono", + "indexmap", + "newtype-uuid", + "quick-xml", + "strip-ansi-escapes", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ruff-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "ruff_db", + "ruff_linter", + "ruff_python_ast", + "ruff_python_codegen", + "ruff_python_formatter", + "ruff_python_parser", + "ruff_text_size", + "salsa", + "similar", + "tracing", + "ty_module_resolver", + "ty_python_core", + "ty_python_semantic", + "ty_vendored", +] + +[[package]] +name = "ruff_annotate_snippets" +version = "0.0.4" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "ruff_cache" +version = "0.0.4" +dependencies = [ + "filetime", + "glob", + "globset", + "itertools 0.15.0", + "regex", + "seahash", +] + +[[package]] +name = "ruff_db" +version = "0.0.4" +dependencies = [ + "anstyle", + "arc-swap", + "camino", + "compact_str", + "dashmap", + "dunce", + "filetime", + "get-size2", + "matchit", + "path-slash", + "pathdiff", + "quick-junit", + "ruff_annotate_snippets", + "ruff_diagnostics", + "ruff_memory_usage", + "ruff_notebook", + "ruff_python_ast", + "ruff_python_parser", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "salsa", + "serde", + "serde_json", + "similar", + "supports-hyperlinks", + "thiserror 2.0.18", + "tracing", + "ty_static", + "web-time", + "zip", +] + +[[package]] +name = "ruff_diagnostics" +version = "0.0.4" +dependencies = [ + "get-size2", + "is-macro", + "ruff_text_size", + "serde", +] + +[[package]] +name = "ruff_formatter" +version = "0.0.4" +dependencies = [ + "drop_bomb", + "ruff_cache", + "ruff_macros", + "ruff_text_size", + "rustc-hash", + "serde", + "static_assertions", + "tracing", + "unicode-width", +] + +[[package]] +name = "ruff_index" +version = "0.0.4" +dependencies = [ + "get-size2", + "ruff_macros", + "salsa", +] + +[[package]] +name = "ruff_linter" +version = "0.15.21" +dependencies = [ + "aho-corasick", + "anyhow", + "bitflags 2.13.0", + "colored", + "compact_str", + "fern", + "glob", + "globset", + "hashbrown 0.17.1", + "imperative", + "is-macro", + "itertools 0.15.0", + "jiff", + "libcst", + "log", + "memchr", + "natord", + "path-absolutize", + "pep440_rs", + "pyproject-toml", + "regex", + "ruff_cache", + "ruff_db", + "ruff_diagnostics", + "ruff_macros", + "ruff_notebook", + "ruff_python_ast", + "ruff_python_codegen", + "ruff_python_importer", + "ruff_python_index", + "ruff_python_literal", + "ruff_python_parser", + "ruff_python_semantic", + "ruff_python_stdlib", + "ruff_python_trivia", + "ruff_ranged_value", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "serde", + "serde_json", + "similar", + "smallvec", + "strum", + "strum_macros", + "thiserror 2.0.18", + "toml 1.1.3+spec-1.1.0", + "typed-arena", + "unicode-normalization", + "unicode-width", + "unicode_names2", + "url", +] + +[[package]] +name = "ruff_macros" +version = "0.0.4" +dependencies = [ + "heck", + "itertools 0.15.0", + "proc-macro2", + "quote", + "regex", + "ruff_python_trivia", + "syn", +] + +[[package]] +name = "ruff_memory_usage" +version = "0.0.4" +dependencies = [ + "get-size2", +] + +[[package]] +name = "ruff_notebook" +version = "0.0.4" +dependencies = [ + "anyhow", + "rand 0.10.2", + "ruff_diagnostics", + "ruff_source_file", + "ruff_text_size", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "ruff_python_ast" +version = "0.0.4" +dependencies = [ + "aho-corasick", + "arrayvec", + "bitflags 2.13.0", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "ruff_cache", + "ruff_macros", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "salsa", + "serde", + "thin-vec", + "thiserror 2.0.18", +] + +[[package]] +name = "ruff_python_codegen" +version = "0.0.4" +dependencies = [ + "ruff_python_ast", + "ruff_python_literal", + "ruff_python_parser", + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "ruff_python_formatter" +version = "0.0.4" +dependencies = [ + "anyhow", + "clap", + "countme", + "itertools 0.15.0", + "memchr", + "regex", + "ruff_cache", + "ruff_db", + "ruff_formatter", + "ruff_macros", + "ruff_python_ast", + "ruff_python_parser", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "salsa", + "serde", + "smallvec", + "static_assertions", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "ruff_python_importer" +version = "0.0.4" +dependencies = [ + "anyhow", + "ruff_diagnostics", + "ruff_python_ast", + "ruff_python_codegen", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "ruff_python_index" +version = "0.0.4" +dependencies = [ + "ruff_python_ast", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "ruff_python_literal" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "icu_properties", + "itertools 0.15.0", + "ruff_python_ast", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "compact_str", + "get-size2", + "memchr", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_text_size", + "rustc-hash", + "static_assertions", + "thin-vec", + "unicode-ident", + "unicode-normalization", + "unicode_names2", +] + +[[package]] +name = "ruff_python_semantic" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "is-macro", + "ruff_cache", + "ruff_index", + "ruff_macros", + "ruff_python_ast", + "ruff_python_parser", + "ruff_python_stdlib", + "ruff_text_size", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "ruff_python_stdlib" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "unicode-ident", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.4" +dependencies = [ + "itertools 0.15.0", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "unicode-ident", +] + +[[package]] +name = "ruff_ranged_value" +version = "0.0.4" +dependencies = [ + "ruff_db", + "ruff_text_size", + "serde", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.4" +dependencies = [ + "get-size2", + "memchr", + "ruff_text_size", + "serde", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.4" +dependencies = [ + "get-size2", + "serde", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63ad5919b18c2deaf18921fffd5c84d6e41416d1ef80b6b541d629aa30ce8556" +dependencies = [ + "boxcar", + "compact_str", + "crossbeam-queue", + "crossbeam-utils", + "hashbrown 0.17.1", + "hashlink", + "indexmap", + "intrusive-collections", + "inventory", + "ordermap", + "parking_lot", + "portable-atomic", + "rustc-hash", + "salsa-macro-rules", + "salsa-macros", + "smallvec", + "thin-vec", + "tracing", + "typeid", +] + +[[package]] +name = "salsa-macro-rules" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50068b1f8b1ac7567a4a70eb6cc15daa9dc997b73a6b5aa3248dd960755cddb" + +[[package]] +name = "salsa-macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b26cb1c61fc424f7ff36e0606491429f58d1738bb0917cd999d680baae0c52c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "similar" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +dependencies = [ + "bstr", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" +dependencies = [ + "serde", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ty_combine" +version = "0.0.4" +dependencies = [ + "ordermap", + "ruff_db", + "ruff_python_ast", + "ruff_ranged_value", +] + +[[package]] +name = "ty_module_resolver" +version = "0.0.4" +dependencies = [ + "anyhow", + "camino", + "compact_str", + "get-size2", + "regex", + "regex-syntax", + "ruff_db", + "ruff_memory_usage", + "ruff_python_ast", + "ruff_python_stdlib", + "rustc-hash", + "salsa", + "strum", + "strum_macros", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "ty_python_core" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "bitvec", + "get-size2", + "hashbrown 0.17.1", + "itertools 0.15.0", + "ruff_db", + "ruff_index", + "ruff_memory_usage", + "ruff_python_ast", + "ruff_python_parser", + "ruff_text_size", + "rustc-hash", + "salsa", + "smallvec", + "static_assertions", + "thin-vec", + "tracing", + "ty_combine", + "ty_module_resolver", + "ty_site_packages", + "ty_vendored", +] + +[[package]] +name = "ty_python_semantic" +version = "0.0.4" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "drop_bomb", + "get-size2", + "indexmap", + "itertools 0.15.0", + "memchr", + "ordermap", + "rayon", + "ruff_db", + "ruff_diagnostics", + "ruff_index", + "ruff_macros", + "ruff_memory_usage", + "ruff_python_ast", + "ruff_python_literal", + "ruff_python_parser", + "ruff_python_stdlib", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash", + "salsa", + "smallvec", + "static_assertions", + "strum", + "strum_macros", + "thiserror 2.0.18", + "tracing", + "ty_module_resolver", + "ty_python_core", + "ty_site_packages", +] + +[[package]] +name = "ty_site_packages" +version = "0.0.4" +dependencies = [ + "camino", + "colored", + "get-size2", + "indexmap", + "ruff_annotate_snippets", + "ruff_db", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "strum", + "strum_macros", + "tracing", + "ty_static", +] + +[[package]] +name = "ty_static" +version = "0.0.4" +dependencies = [ + "ruff_macros", +] + +[[package]] +name = "ty_vendored" +version = "0.0.4" +dependencies = [ + "path-slash", + "ruff_db", + "static_assertions", + "walkdir", + "zip", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand 0.8.7", +] + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version-ranges" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e9bd4e9c9ff6a2a9b5969462ba26216af3e010df0377dad8320ab515262ef8" +dependencies = [ + "smallvec", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From 3adf133618a7f98bbb9c4430e22210360808576b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 13:58:02 -0700 Subject: [PATCH 101/222] [ty] Handle recursive protocols in redundant-cast checks (#26708) ## Summary We suppress `redundant-cast` when either side contains a dynamic type other than `Any`, because equivalence involving `Unknown` or `Todo` is not enough to prove that the cast is useless. Determining that currently traverses lazy type attributes, including class-based protocol interfaces. A protocol interface can recursively produce a different specialization on every step: ```python from typing import Protocol, cast class GrowingProtocol[T](Protocol): value: T next: "GrowingProtocol[list[T]]" def check(value: GrowingProtocol[int]) -> None: cast(GrowingProtocol[int], value) ``` Because each nested type is distinct, the existing exact-type recursion guard never fires and the check eventually overflows the stack. This PR gives the redundant-cast traversal three outcomes: absent, present, or indeterminate. It continues through aliases, type-variable bounds, specialization arguments, and protocol interfaces. Exact recursive cycles add no new information, while re-entering the same class-based protocol under a different specialization is indeterminate. We only emit `redundant-cast` when both types are proven free of non-`Any` dynamic content. Related: --- .../resources/mdtest/protocols.md | 138 +++++++++++++++++- .../ty_python_semantic/src/types/function.rs | 11 +- .../ty_python_semantic/src/types/instance.rs | 8 + .../src/types/protocol_class.rs | 52 +++++++ .../ty_python_semantic/src/types/visitor.rs | 118 ++++++++++++++- 5 files changed, 314 insertions(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 88fe91a7dd..0944681925 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -4685,9 +4685,8 @@ def f(x: PGconn): ### Recursive protocols used as the first argument to `cast()` -These caused issues in an early version of our `Protocol` implementation due to the fact that we use -a recursive function in our `cast()` implementation to check whether a type contains `Unknown` or -`Todo`. Recklessly recursing into a type causes stack overflows if the type is recursive: +A redundant cast is reported only if neither type contains `Unknown` nor `Todo`. Inspecting protocol +members for these types must terminate when a protocol refers back to itself. ```toml [environment] @@ -4705,6 +4704,139 @@ def f(value: Iterator[Any]): cast(Iterator[Any], value) # error: [redundant-cast] ``` +### Protocol methods and properties in `cast()` + +The `Iterator` example above also ensures that the implicit `self` parameter of an ordinary method +does not make the protocol appear recursive. The method's return type must still be checked. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, cast + +from ty_extensions import Unknown + +class UnknownMethod[T](Protocol): + def method(self) -> Unknown: ... + +def method(value: UnknownMethod[int]) -> None: + cast(UnknownMethod[int], value) +``` + +Property getters follow the same rule: their implicit receiver is ignored, but their return type is +checked. + +```py +from typing import Protocol, cast + +from ty_extensions import Unknown + +class IntProperty[T](Protocol): + @property + def value(self) -> int: ... + +class UnknownProperty[T](Protocol): + @property + def value(self) -> Unknown: ... + +def properties(known: IntProperty[int], unknown: UnknownProperty[int]) -> None: + cast(IntProperty[int], known) # error: [redundant-cast] + cast(UnknownProperty[int], unknown) +``` + +### Specialized protocol type parameters in `cast()` + +A type variable's bound does not remain part of a specialized protocol. Here, the `Unknown` bound +has been replaced by `int`, so the cast is redundant. + +```py +from typing import Protocol, TypeVar, cast + +from ty_extensions import Unknown + +T = TypeVar("T", bound=Unknown) + +class BoundedProtocol(Protocol[T]): + value: T + +def bounded(value: BoundedProtocol[int]) -> None: + cast(BoundedProtocol[int], value) # error: [redundant-cast] +``` + +### Recursive protocol specializations in `cast()` + +A protocol can refer to itself with a different type argument on every step. Since the sequence +`Linked[int]`, `Linked[list[int]]`, and so on never repeats exactly, the inspection stops when it +sees the same protocol definition again. The diagnostic is not reported because a later +specialization could expose `Unknown`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Protocol, cast + +class Linked[T](Protocol): + value: T + next: "Linked[list[T]]" + +def linked(value: Linked[int]) -> None: + cast(Linked[int], value) +``` + +An explicit `self` annotation is part of the method's type, so recursion through that annotation +must also terminate. + +```py +from typing import Protocol, cast + +class ExplicitReceiver[T](Protocol): + def method(self: "ExplicitReceiver[list[T]]") -> int: ... + +def explicit_receiver(value: ExplicitReceiver[int]) -> None: + cast(ExplicitReceiver[int], value) +``` + +The diagnostic must be withheld because member lookup can depend on the type argument. In this +example, descriptor overload resolution exposes `Unknown` only through the nested protocol. + +```py +from typing import Protocol, cast, overload + +from ty_extensions import Unknown + +class Descriptor: + @overload + def __get__( + self, + instance: "DescriptorProtocol[list[int]]", + owner: type["DescriptorProtocol[list[int]]"], + ) -> Unknown: ... + @overload + def __get__(self, instance: object, owner: type[object]) -> int: ... + def __get__(self, instance: object, owner: type[object]) -> object: + return object() + +def descriptor(_function: object) -> Descriptor: + return Descriptor() + +class DescriptorProtocol[T](Protocol): + marker: T + next: "DescriptorProtocol[list[T]]" + + @descriptor + def value(self) -> object: ... + +def descriptor_specialization(value: DescriptorProtocol[int]) -> None: + reveal_type((value.value, value.next.value)) # revealed: tuple[int, Unknown] + cast(DescriptorProtocol[int], value) +``` + ### Recursive generic protocols This snippet caused us to stack overflow on an early version of diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 1965980781..aded2fc3fe 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -86,10 +86,10 @@ use crate::types::narrow::ClassInfoConstraintFunction; use crate::types::relation::TypeRelationChecker; use crate::types::signatures::{CallableSignature, ReturnCallableTypeVarScope, Signature}; use crate::types::variance::{TypeVarVariance, VarianceInferable}; -use crate::types::visitor::any_over_type; +use crate::types::visitor::non_any_dynamic_content; use crate::types::{ ApplyTypeMappingVisitor, BoundMethodType, BoundTypeVarIdentity, BoundTypeVarInstance, - CallableType, ClassBase, ClassLiteral, ClassType, DynamicType, FindLegacyTypeVarsVisitor, + CallableType, ClassBase, ClassLiteral, ClassType, FindLegacyTypeVarsVisitor, IntersectionBuilder, KnownClass, KnownInstanceType, SpecialFormType, SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, UnionBuilder, UnionType, definition_expression_type, walk_signature, @@ -2373,12 +2373,9 @@ impl KnownFunction { return; }; let casted_type = casted_type.project_type_form(db); - let contains_unknown_or_todo = |ty: Type<'_>| { - ty.is_dynamic() && !matches!(ty, Type::Dynamic(DynamicType::Any)) - }; if source_type.is_equivalent_to(db, casted_type) - && !any_over_type(db, *source_type, true, contains_unknown_or_todo) - && !any_over_type(db, casted_type, true, contains_unknown_or_todo) + && non_any_dynamic_content(db, *source_type).is_absent() + && non_any_dynamic_content(db, casted_type).is_absent() { if let Some(builder) = context.report_lint(&REDUNDANT_CAST, call_expression) { let source_display = source_type.display(db).to_string(); diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index ffe5eb223f..ab9068d1ca 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -839,6 +839,14 @@ impl<'db> ProtocolInstanceType<'db> { } } + /// Return the class backing a class-based protocol instance. + pub(super) fn as_class_based(self) -> Option> { + match self.inner { + Protocol::FromClass(class) => Some(class), + Protocol::Synthesized(_) => None, + } + } + /// If this is a class-based protocol, convert the protocol-instance into a nominal instance. /// /// If this is a synthesized protocol that does not correspond to a class definition diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 3d89667d82..136aeef336 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -28,6 +28,7 @@ use crate::{ constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, diagnostic::report_undeclared_protocol_member, + signatures::walk_signature, }, }; use ty_python_core::{definition::Definition, place::ScopedPlaceId, place_table, use_def_map}; @@ -205,6 +206,57 @@ pub(super) fn walk_protocol_interface<'db, V: super::visitor::TypeVisitor<'db> + } } +/// Walk the member types exposed through an instance of a protocol. +/// +/// This binds inferred method receivers and property accessors to `receiver_ty`, while leaving +/// explicit receiver annotations in place because they can affect which overload is exposed. +/// For example, walking `P[int]` visits the return type `int`, but not the inferred receiver type: +/// +/// ```python +/// class P[T](Protocol): +/// def method(self) -> T: ... +/// ``` +pub(super) fn walk_protocol_instance_interface< + 'db, + V: super::visitor::TypeVisitor<'db> + ?Sized, +>( + db: &'db dyn Db, + interface: ProtocolInterface<'db>, + receiver_ty: Type<'db>, + visitor: &V, +) { + for member in interface.members(db) { + match member.data.kind { + ProtocolMemberKind::Method(method, _) => { + let Type::Callable(callable) = method.ty() else { + visitor.visit_type(db, method.ty()); + continue; + }; + for signature in callable.signatures(db) { + if signature.has_implicit_positional_receiver_annotation() { + let signature = signature.bind_self(db, Some(receiver_ty)); + walk_signature(db, &signature, visitor); + } else { + walk_signature(db, signature, visitor); + } + } + } + ProtocolMemberKind::Property { read, write } => { + for member_type in [read, write].into_iter().flatten() { + if let Some(ty) = member_type.bind_self(db, receiver_ty) { + visitor.visit_type(db, ty); + } + } + } + ProtocolMemberKind::Attribute(attribute) => { + if let Some(ty) = attribute.bind_self(db, receiver_ty) { + visitor.visit_type(db, ty); + } + } + } + } +} + impl<'db> ProtocolInterface<'db> { /// Synthesize a new protocol interface with the given members. /// diff --git a/crates/ty_python_semantic/src/types/visitor.rs b/crates/ty_python_semantic/src/types/visitor.rs index f2d25c1d65..0e55d2fb90 100644 --- a/crates/ty_python_semantic/src/types/visitor.rs +++ b/crates/ty_python_semantic/src/types/visitor.rs @@ -1,3 +1,4 @@ +use std::cell::{Cell, RefCell}; use std::hash::Hash; use rustc_hash::{FxBuildHasher, FxHashSet}; @@ -8,16 +9,19 @@ use crate::{ types::{ BoundMethodType, BoundSuperType, BoundTypeVarInstance, CallableType, EnumComplementType, GenericAlias, IntersectionType, KnownBoundMethodType, KnownInstanceType, - NominalInstanceType, PropertyInstanceType, ProtocolInstanceType, SubclassOfType, Type, - TypeAliasType, TypeFormType, TypeGuardType, TypeIsType, TypedDictType, UnionType, + NominalInstanceType, PropertyInstanceType, ProtocolInstanceType, StaticClassLiteral, + SubclassOfType, Type, TypeAliasType, TypeFormType, TypeGuardType, TypeIsType, + TypedDictType, UnionType, bound_super::walk_bound_super_type, callable::walk_callable_type, class::walk_generic_alias, + cyclic::ActiveRecursionDetector, function::{FunctionType, walk_function_type}, instance::{walk_nominal_instance_type, walk_protocol_instance_type}, known_instance::walk_known_instance_type, method::{walk_bound_method_type, walk_method_wrapper_type}, newtype::{NewType, walk_newtype_instance_type}, + protocol_class::walk_protocol_instance_interface, set_theoretic::{walk_intersection_type, walk_union}, subclass_of::walk_subclass_of_type, type_alias::walk_type_alias_type, @@ -27,7 +31,6 @@ use crate::{ walk_property_instance_type, walk_typeguard_type, walk_typeis_type, }, }; -use std::cell::{Cell, RefCell}; /// A visitor trait that recurses into nested types. /// @@ -369,6 +372,115 @@ impl SmallSet { } } +/// Whether a type contains a non-`Any` dynamic type. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(super) enum DynamicContent { + /// The type was fully inspected and contains no non-`Any` dynamic type. + Absent, + /// The type contains a non-`Any` dynamic type. + Present, + /// Recursive specialization prevented the type from being fully inspected. + Indeterminate, +} + +impl DynamicContent { + pub(super) const fn is_absent(self) -> bool { + matches!(self, Self::Absent) + } +} + +/// Determine whether `ty` contains a dynamic type other than `Any`. +/// +/// Class-based protocol interfaces can be recursively specialized. An exact recursive cycle adds +/// no new information, but revisiting the same protocol definition under a different +/// specialization may expose different members and is therefore indeterminate. +/// +/// ```python +/// class Exact[T](Protocol): +/// next: Exact[T] +/// +/// class Growing[T](Protocol): +/// next: Growing[list[T]] +/// ``` +/// +/// Walking `Exact[int]` can skip its exact back-edge. Walking `Growing[int]` is indeterminate +/// because each recursive edge creates a new specialization. +pub(super) fn non_any_dynamic_content<'db>(db: &'db dyn Db, ty: Type<'db>) -> DynamicContent { + struct DynamicContentVisitor<'db> { + recursion_guard: TypeCollector<'db>, + active_class_protocols: ActiveRecursionDetector>, + content: Cell, + } + + impl DynamicContentVisitor<'_> { + fn record(&self, content: DynamicContent) { + debug_assert!(self.content.get().is_absent()); + debug_assert!(!content.is_absent()); + self.content.set(content); + } + } + + impl<'db> TypeVisitor<'db> for DynamicContentVisitor<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + true + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if !self.content.get().is_absent() { + return; + } + + if ty.is_dynamic() && !matches!(ty, Type::Dynamic(crate::types::DynamicType::Any)) { + self.record(DynamicContent::Present); + return; + } + + walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); + } + + fn visit_protocol_instance_type( + &self, + db: &'db dyn Db, + protocol: ProtocolInstanceType<'db>, + ) { + let protocol_ty = Type::ProtocolInstance(protocol); + let Some(class) = protocol.as_class_based() else { + walk_protocol_instance_interface(db, protocol.interface(db), protocol_ty, self); + return; + }; + let Some((origin, specialization)) = class.static_class_literal(db) else { + walk_protocol_instance_interface(db, protocol.interface(db), protocol_ty, self); + return; + }; + + if let Some(specialization) = specialization { + // Bounds and defaults in the generic context do not describe the specialized + // instance; only inspect the types assigned to its parameters. + for ty in specialization.types(db) { + self.visit_type(db, *ty); + if !self.content.get().is_absent() { + return; + } + } + } + + self.active_class_protocols.visit( + &origin, + || self.record(DynamicContent::Indeterminate), + || walk_protocol_instance_interface(db, protocol.interface(db), protocol_ty, self), + ); + } + } + + let visitor = DynamicContentVisitor { + recursion_guard: TypeCollector::default(), + active_class_protocols: ActiveRecursionDetector::default(), + content: Cell::new(DynamicContent::Absent), + }; + visitor.visit_type(db, ty); + visitor.content.get() +} + /// Implementation for `any_over_type` and `find_over_type`. fn any_over_type_impl<'db, F, T>( db: &'db dyn Db, From e087358ac6befebbdab5dbcd86602a0bf34f2006 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 14:19:13 -0700 Subject: [PATCH 102/222] [ty] Preserve constraints from bound receivers (#26776) ## Summary Binding a method currently removes its receiver parameter and loses any relationship between the concrete receiver and an explicit receiver annotation. For a method like `[T](self: T, value: T) -> T`, that lets later callable comparisons choose `T = int` even when the method is bound to an unrelated class. This PR retains that receiver relation on the bound signature while the signature is freshened, specialized, and compared. The relation participates in existential callable-local inference, and the runtime receiver remains distinct from the type used to replace `typing.Self`, which is necessary for class methods and constructors. For example, we previously accepted this assignment: ```python from typing import Callable class C: def method[T](self: T, value: T) -> T: return self method: Callable[[int], int] = C().method ``` Binding `C().method` requires `C` to be assignable to `T`. We can therefore no longer choose `T = int` merely to satisfy the target callable, and now report this as an invalid assignment (This pass supports unbounded receiver TypeVars, but declared bounds and constraints are deferred for now... Tacking those on led to ecosystem failures around recursive protocols.) --- .../mdtest/generics/pep695/callables.md | 35 +++ .../resources/mdtest/liskov.md | 21 +- .../resources/mdtest/overloads.md | 8 +- .../resources/mdtest/protocols.md | 78 ++++- .../ty_python_semantic/src/types/callable.rs | 12 +- crates/ty_python_semantic/src/types/class.rs | 10 +- .../src/types/constraints.rs | 56 +++- crates/ty_python_semantic/src/types/method.rs | 33 +- .../src/types/protocol_class.rs | 35 ++- .../src/types/signatures.rs | 292 ++++++++++++++++-- 10 files changed, 523 insertions(+), 57 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md index fafe66a649..3dfad84c6a 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md @@ -51,6 +51,41 @@ reveal_type(generic_context(C)) reveal_type(C(1)) ``` +Explicit generic receiver annotations constrain a bound method's callable type: + +```py +from typing import Callable + +class GenericReceiver: + def method[T](self: T, value: T) -> T: + return self + +receiver = GenericReceiver() + +# Binding adds `GenericReceiver <= T`. `T = object` satisfies that constraint, but `T = int` does +# not. +accepts_object: Callable[[object], object] = receiver.method +accepts_int: Callable[[int], int] = receiver.method # error: [invalid-assignment] +``` + +Declared bounds and constraints on receiver TypeVars are not yet enforced when comparing a bound +method with another callable: + +```py +from typing import Callable + +class InvalidBoundedReceiver: + # TODO: Binding should reject this method because the receiver is outside `T`'s declared bound. + def method[T: int](self: T) -> None: ... + +class InvalidConstrainedReceiver: + # TODO: Binding should reject this method because the receiver is outside `T`'s constraints. + def method[T: (int, str)](self: T) -> None: ... + +invalid_bound: Callable[[], None] = InvalidBoundedReceiver().method +invalid_constraints: Callable[[], None] = InvalidConstrainedReceiver().method +``` + When we coerce a generic callable into a `Callable` type, it remembers that it is generic: ```py diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md index a44526bef8..f282e19aa8 100644 --- a/crates/ty_python_semantic/resources/mdtest/liskov.md +++ b/crates/ty_python_semantic/resources/mdtest/liskov.md @@ -898,6 +898,14 @@ class B2(A2): class C2(A2): def method[T: int](self, x: T) -> T: ... +class ExplicitReceiverBase: + def method(self, x: int) -> int: ... + +class ExplicitReceiverChild(ExplicitReceiverBase): + # Binding this method adds `ExplicitReceiverChild <= T`, which is incompatible with the + # `T = int` specialization needed to implement the base method. + def method[T](self: T, x: T) -> T: ... # error: [invalid-method-override] + class D2(A2): # The type variable is bound to a type disjoint from `int`, # so the method will not accept integers, and therefore this is an invalid override @@ -932,16 +940,13 @@ class G3(A3): def method(self: object) -> Self: ... # fine class H3(A3): - # TODO: we should emit `invalid-method-override` here - # (`A3.method()` can be called on any instance of `A3`, - # but `H3.method()` can only be called on objects that are - # instances of `str`) - def method(self: str) -> Self: ... + # `A3.method()` can be called on any subtype of `A3`, but `H3.method()` can only be called on + # objects that are subtypes of `str`. + def method(self: str) -> Self: ... # error: [invalid-method-override] class I3(A3): - # TODO: we should emit `invalid-method-override` here - # (`I3.method()` cannot be called with any inhabited type!) - def method(self: Never) -> Self: ... + # `I3.method()` cannot be called with any inhabited type. + def method(self: Never) -> Self: ... # error: [invalid-method-override] class A4: def method[T: int](self, x: T) -> T: ... diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index d256ad8571..f3afc268dc 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -295,9 +295,8 @@ class ReceiverGeneric[T]: def method(self, value: object) -> object: return value -# TODO: `Signature::can_bind_self_to` currently reduces receiver matching to a boolean. Instead, -# each retained overload should preserve its receiver constraints so later specialization can apply -# them. +# Receiver constraints are preserved for later relation checks, but are not yet solved into the +# displayed bound signature. # TODO: revealed: Overload[(value: str) -> str, (value: bytes) -> bytes] reveal_type(ReceiverGeneric[str]().method) # revealed: Overload[[S](value: S) -> S, (value: bytes) -> bytes] ``` @@ -337,8 +336,7 @@ class ProtocolSelfImplementation(BaseWithProtocolSelf): reveal_type(ProtocolSelfImplementation().method) # revealed: Overload[[ProtocolSelfT]() -> ProtocolSelfT, () -> bytes] good_protocol_receiver: Callable[[], bytes] = ProtocolSelfImplementation().method -# TODO: error: [invalid-assignment] -bad_protocol_receiver: Callable[[], int] = ProtocolSelfImplementation().method +bad_protocol_receiver: Callable[[], int] = ProtocolSelfImplementation().method # error: [invalid-assignment] ``` ## Constructor diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 0944681925..d47e23a4b4 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -3055,7 +3055,7 @@ python-version = "3.12" ``` ```py -from typing import final +from typing import Any, final, overload from typing_extensions import TypeVar, Self, Protocol from ty_extensions import static_assert from ty_extensions._internal import is_equivalent_to, is_assignable_to, is_subtype_of @@ -3154,6 +3154,47 @@ class NominalReturningOtherClass: def g(self) -> Other: raise NotImplementedError +class ConcreteMethod(Protocol): + def f(self, input: int) -> int: ... + +class GenericReceiver: + def f[T](self: T, input: T) -> T: + return self + +class GradualReceiverProtocol(Protocol): + def method(self: list[Any]) -> None: ... + +class GradualReceiverImplementation(list[int]): + def method(self: list[Any]) -> None: ... + +class ExplicitReceiverProtocol(Protocol): + def method(self: "ExplicitReceiverProtocol") -> None: ... + +class StructuralExplicitReceiver: + def method(self: ExplicitReceiverProtocol) -> None: ... + +class OverloadedExplicitReceiverProtocol(Protocol): + def overloaded(self: str, value: int | str) -> int: ... + +class OverloadedExplicitReceiverImplementation: + @overload + def overloaded(self, value: int) -> int: ... + @overload + def overloaded(self, value: str) -> int: ... + def overloaded(self, value: int | str) -> int: + return 1 + +class ReceiverOnly(Protocol): + def method(self) -> None: ... + +class InvalidBoundedReceiver: + # TODO: Use `BoundTypeVarInstance::valid_specializations` to reject this receiver. + def method[T: int](self: T) -> None: ... + +class InvalidConstrainedReceiver: + # TODO: Use `BoundTypeVarInstance::valid_specializations` to reject this receiver. + def method[T: (int, str)](self: T) -> None: ... + static_assert(is_equivalent_to(LegacyFunctionScoped, NewStyleFunctionScoped)) static_assert(is_assignable_to(NominalNewStyle, NewStyleFunctionScoped)) static_assert(is_assignable_to(NominalNewStyle, LegacyFunctionScoped)) @@ -3185,6 +3226,31 @@ static_assert(not is_assignable_to(NominalReturningSelfNotGeneric, UsesSelf)) # static_assert(not is_assignable_to(NominalReturningOtherClass, UsesSelf)) +# Binding `GenericReceiver.f` adds the constraint `GenericReceiver <= T`. It cannot choose +# `T = int`, so the resulting bound method does not satisfy `ConcreteMethod.f`. +static_assert(not is_assignable_to(GenericReceiver, ConcreteMethod)) +static_assert(not is_subtype_of(GenericReceiver, ConcreteMethod)) + +# Specializing the receiver constraint to `GradualReceiverImplementation` must preserve the +# assignability relation that produced it; `list[int]` is assignable to, but not a subtype of, +# `list[Any]`. +static_assert(is_assignable_to(GradualReceiverImplementation, GradualReceiverProtocol)) + +# Checking the receiver constraint requires the same protocol relation that is already in +# progress. The recursive check should terminate and establish the structural relation. +static_assert(is_assignable_to(StructuralExplicitReceiver, ExplicitReceiverProtocol)) +static_assert(is_subtype_of(StructuralExplicitReceiver, ExplicitReceiverProtocol)) + +# Aggregating the implementation's overloads covers the visible `int | str` parameter, but the +# implementation's concrete receiver does not satisfy the protocol's explicit `str` receiver. +static_assert(not is_assignable_to(OverloadedExplicitReceiverImplementation, OverloadedExplicitReceiverProtocol)) +static_assert(not is_subtype_of(OverloadedExplicitReceiverImplementation, OverloadedExplicitReceiverProtocol)) + +static_assert(is_assignable_to(InvalidBoundedReceiver, ReceiverOnly)) +static_assert(is_subtype_of(InvalidBoundedReceiver, ReceiverOnly)) +static_assert(is_assignable_to(InvalidConstrainedReceiver, ReceiverOnly)) +static_assert(is_subtype_of(InvalidConstrainedReceiver, ReceiverOnly)) + # These test cases are taken from the typing conformance suite: class ShapeProtocolImplicitSelf(Protocol): def set_scale(self, scale: float) -> Self: ... @@ -3272,11 +3338,20 @@ class FactoryProtocol(Protocol): @classmethod def make(cls) -> Self: ... +class ExplicitReceiverFactoryProtocol(Protocol): + @classmethod + def make(cls: type[Self]) -> Self: ... + class Factory: @classmethod def make(cls) -> Self: return cls() +class ExplicitReceiverFactory: + @classmethod + def make(cls: type[Self]) -> Self: + return cls() + class BadFactory: @classmethod def make(cls) -> int: @@ -3288,6 +3363,7 @@ class ClassObjectFactory: return cls static_assert(not is_assignable_to(TypeOf[Factory], FactoryProtocol)) +static_assert(not is_assignable_to(TypeOf[ExplicitReceiverFactory], ExplicitReceiverFactoryProtocol)) static_assert(not is_assignable_to(TypeOf[BadFactory], FactoryProtocol)) static_assert(is_assignable_to(type[ClassObjectFactory], FactoryProtocol)) diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index d01a6d259f..d25b57fc36 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -592,9 +592,19 @@ impl<'db> CallableType<'db> { } pub(crate) fn apply_self(self, db: &'db dyn Db, self_type: Type<'db>) -> CallableType<'db> { + self.apply_self_with_receiver(db, self_type, self_type) + } + + pub(crate) fn apply_self_with_receiver( + self, + db: &'db dyn Db, + receiver_type: Type<'db>, + self_type: Type<'db>, + ) -> CallableType<'db> { CallableType::new( db, - self.signatures(db).apply_self(db, self_type), + self.signatures(db) + .apply_self_with_receiver(db, receiver_type, self_type), self.kind(db), self.provenance(db), ) diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 68c394a13e..2484b5a6db 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2108,7 +2108,7 @@ impl<'db> ClassType<'db> { let instance_ty = Type::instance(db, self); let dunder_new_bound_method = CallableType::new( db, - dunder_new_signature.bind_self(db, Some(instance_ty)), + dunder_new_signature.bind_self_with_receiver(db, Some(self_ty), Some(instance_ty)), CallableTypeKind::Regular, CallableFunctionProvenance::None, ); @@ -2158,7 +2158,7 @@ impl<'db> ClassType<'db> { .is_none_or(|bound_typevar| !bound_typevar.typevar(db).is_self(db)) }); let return_type = self_annotation.unwrap_or(correct_return_type); - let instance_ty = self_annotation.unwrap_or_else(|| Type::instance(db, self)); + let instance_ty = Type::instance(db, self); let generic_context = GenericContext::merge_optional( db, class_generic_context, @@ -2170,7 +2170,11 @@ impl<'db> ClassType<'db> { return_type, ) .with_definition(signature.definition()) - .bind_self(db, Some(instance_ty)) + .bind_self_with_receiver( + db, + Some(instance_ty), + Some(instance_ty), + ) }; let synthesized_dunder_init_signature = CallableSignature::from_overloads( diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index e51afe2750..86c684999e 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -273,6 +273,16 @@ impl<'db> OwnedConstraintSet<'db> { } } + /// Returns `true` if this constraint set's root is the `always` terminal. + /// + /// This is only a cheap sufficient check. A nonterminal constraint set can also be always + /// satisfied, so `false` does not prove that the set is not always satisfied. Call + /// [`ConstraintSet::is_always_satisfied`] through [`Self::query`] when false negatives are not + /// acceptable. + pub(crate) fn is_trivially_always_satisfied(&self) -> bool { + self.node == ALWAYS_TRUE + } + /// Loads this constraint set into a new builder, invokes a callback with that builder, and /// returns the result. /// @@ -292,6 +302,16 @@ impl<'db> OwnedConstraintSet<'db> { let set = ConstraintSet::from_node(&builder, self.node); f(&builder, set) } + + pub(crate) fn types(&self) -> impl Iterator> + '_ { + self.inner.iter().flat_map(|inner| { + inner.constraints.iter().flat_map(|constraint| { + std::iter::once(Type::TypeVar(constraint.typevar)) + .chain(constraint.bounds.lower) + .chain(constraint.bounds.upper) + }) + }) + } } impl OwnedConstraintSetInner<'_> { @@ -582,7 +602,6 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { } /// Applies a type mapping to every constraint in this constraint set. - #[cfg_attr(not(test), expect(dead_code, reason = "used by a stacked follow-up"))] pub(crate) fn apply_type_mapping_impl( self, db: &'db dyn Db, @@ -657,13 +676,19 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Constraint::new_node_with_bounds(db, builder, typevar, lower, upper) } else { let lower_holds = lower.map_or(ALWAYS_TRUE, |lower| { - lower - .when_constraint_set_assignable_to(db, subject, builder) + builder + .load( + db, + &lower.when_constraint_set_assignable_to_owned(db, subject), + ) .node }); let upper_holds = upper.map_or(ALWAYS_TRUE, |upper| { - subject - .when_constraint_set_assignable_to(db, upper, builder) + builder + .load( + db, + &subject.when_constraint_set_assignable_to_owned(db, upper), + ) .node }); lower_holds.and_with_offset(builder, upper_holds) @@ -1032,6 +1057,27 @@ impl<'db> ConstraintSetBuilder<'db> { .as_ref() .expect("storage-free owned constraint sets must have terminal roots"); + if inner.nodes.len() == 1 { + let old_interior = inner.nodes[inner.retained_node_index(other.node)]; + let old_constraint = + inner.constraints[inner.retained_constraint_index(old_interior.constraint)]; + let condition = Constraint::new_node_with_bounds( + db, + self, + old_constraint.typevar, + old_constraint.bounds.lower, + old_constraint.bounds.upper, + ) + .with_adjusted_source_order(self, old_interior.source_order.saturating_sub(1)); + let node = condition.ite_uncertain( + self, + old_interior.if_true, + old_interior.if_uncertain, + old_interior.if_false, + ); + return ConstraintSet::from_node(self, node); + } + // Load all of the constraints into the this builder first, to maximize the chance that the // constraints and typevars will appear in the same order. (This is important because many // of our mdtests try to force a particular ordering, to test that our algorithms are all diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 1e50496b97..f9f97a5a6e 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -87,6 +87,7 @@ impl<'db> BoundMethodType<'db> { pub(crate) fn bound_signatures(self, db: &'db dyn Db) -> CallableSignature<'db> { let function_signature = self.function(db).signature(db); let typing_self_type = self.typing_self_type(db); + let receiver_type = self.self_instance(db); let [signature] = function_signature.overloads.as_slice() else { if !function_signature @@ -94,25 +95,37 @@ impl<'db> BoundMethodType<'db> { .iter() .any(Signature::has_explicit_positional_receiver_annotation) { - return CallableSignature::from_overloads( - function_signature - .overloads - .iter() - .map(|signature| signature.bind_self(db, Some(typing_self_type))), - ); + return CallableSignature::from_overloads(function_signature.overloads.iter().map( + |signature| { + signature.bind_self_with_receiver( + db, + Some(receiver_type), + Some(typing_self_type), + ) + }, + )); } - let self_instance = self.self_instance(db); return CallableSignature::from_overloads( function_signature .overloads .iter() - .filter(|signature| signature.can_bind_self_to(db, self_instance)) - .map(|signature| signature.bind_self(db, Some(typing_self_type))), + .filter(|signature| signature.can_bind_self_to(db, receiver_type)) + .map(|signature| { + signature.bind_self_with_receiver( + db, + Some(receiver_type), + Some(typing_self_type), + ) + }), ); }; - CallableSignature::single(signature.bind_self(db, Some(typing_self_type))) + CallableSignature::single(signature.bind_self_with_receiver( + db, + Some(receiver_type), + Some(typing_self_type), + )) } pub(super) fn recursive_type_normalized_impl( diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 136aeef336..be49d0bde3 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -1220,6 +1220,13 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { ) } + fn is_class_method(&self) -> bool { + matches!( + self.data.kind, + ProtocolMemberKind::Method(_, ProtocolMethodKind::Class) + ) + } + fn is_property(&self) -> bool { matches!(self.data.kind, ProtocolMemberKind::Property { .. }) } @@ -1806,6 +1813,16 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .to_instance(db) .or_else(|| ty.literal_fallback_instance(db)) .unwrap_or(ty); + let implementation_receiver_binding_ty = if member.is_class_method() { + implementation_self_binding_ty.to_meta_type(db) + } else { + implementation_self_binding_ty + }; + let protocol_receiver_binding_ty = if member.is_class_method() { + protocol_self_binding_ty.to_meta_type(db) + } else { + protocol_self_binding_ty + }; // Checking a class object against a protocol's instance capabilities can expose the // property descriptor itself rather than the value returned by its getter. Compatibility @@ -1829,9 +1846,17 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.check_callables_vs_callable( db, &callables.map(|callable| { - callable.apply_self(db, implementation_self_binding_ty) + callable.apply_self_with_receiver( + db, + implementation_receiver_binding_ty, + implementation_self_binding_ty, + ) }), - required_callable.apply_self(db, protocol_self_binding_ty), + required_callable.apply_self_with_receiver( + db, + protocol_receiver_binding_ty, + protocol_self_binding_ty, + ), ) }) } else if member.is_instance_method() { @@ -1870,7 +1895,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.check_type_pair( db, attribute_type, - Type::Callable(required_callable.apply_self(db, protocol_self_binding_ty)), + Type::Callable(required_callable.apply_self_with_receiver( + db, + protocol_receiver_binding_ty, + protocol_self_binding_ty, + )), ) } else { required_ty diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 5f96abfbb0..e15e71b801 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -21,7 +21,7 @@ use smallvec::{SmallVec, smallvec_inline}; use super::{DynamicType, Type, TypeVarVariance, UnionType, semantic_index}; use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; use crate::types::constraints::{ - ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, + ConstraintSet, ConstraintSetBuilder, IteratorConstraintsExtension, OwnedConstraintSet, }; use crate::types::cyclic::ActiveRecursionDetector; use crate::types::generics::{ @@ -108,6 +108,31 @@ pub struct CallableSignature<'db> { pub(crate) overloads: SmallVec<[Signature<'db>; 1]>, } +fn merge_receiver_constraints<'db>( + db: &'db dyn Db, + first: Option<&OwnedConstraintSet<'db>>, + second: Option<&OwnedConstraintSet<'db>>, +) -> Option> { + // Only discard sets whose root is the `always` terminal. A false negative from this cheap check + // merely falls through to the merge below. Retaining such a nonterminal set is also important: + // its presence makes signature comparison use lazy typevar evaluation. + match ( + first.filter(|constraints| !constraints.is_trivially_always_satisfied()), + second.filter(|constraints| !constraints.is_trivially_always_satisfied()), + ) { + (None, None) => None, + (Some(constraints), None) | (None, Some(constraints)) => Some((*constraints).clone()), + (Some(first), Some(second)) => { + let constraints = ConstraintSetBuilder::new(); + Some(constraints.into_owned(|builder| { + builder + .load(db, first) + .and(db, builder, || builder.load(db, second)) + })) + } + } +} + /// The per-overload information needed to synthesize one reduced signature for /// `functools.partial(...)`. #[derive(Clone, Debug)] @@ -297,6 +322,12 @@ impl<'db> CallableSignature<'db> { type_mapping.update_signature_generic_context(db, context) }), definition: self_signature.definition, + receiver_constraints: self_signature.map_receiver_constraints( + db, + type_mapping, + tcx, + visitor, + ), parameters, return_ty: self_signature.return_ty.apply_type_mapping_impl( db, @@ -319,6 +350,19 @@ impl<'db> CallableSignature<'db> { }), ), definition: signature.definition, + receiver_constraints: { + let mapped = self_signature.map_receiver_constraints( + db, + type_mapping, + tcx, + visitor, + ); + merge_receiver_constraints( + db, + signature.receiver_constraints.as_ref(), + mapped.as_ref(), + ) + }, parameters: signature.parameters().with_prefix( prefix_parameters.iter().map(|param| { param.apply_type_mapping_impl(db, type_mapping, tcx, visitor) @@ -389,11 +433,27 @@ impl<'db> CallableSignature<'db> { /// provided, we will replace any occurrences of `typing.Self` in the parameter and return /// annotations with that type. pub(crate) fn bind_self(&self, db: &'db dyn Db, self_type: Option>) -> Self { + self.bind_self_with_receiver(db, self_type, self_type) + } + + /// Binds the receiver using its runtime type while using `typing_self_type` to replace + /// occurrences of `typing.Self`. + /// + /// These differ for class methods: the runtime receiver is a class object, while + /// `typing.Self` denotes an instance of that class. + pub(crate) fn bind_self_with_receiver( + &self, + db: &'db dyn Db, + receiver_type: Option>, + typing_self_type: Option>, + ) -> Self { Self { overloads: self .overloads .iter() - .map(|signature| signature.bind_self(db, self_type)) + .map(|signature| { + signature.bind_self_with_receiver(db, receiver_type, typing_self_type) + }) .collect(), } } @@ -404,15 +464,19 @@ impl<'db> CallableSignature<'db> { .any(|signature| !signature.parameters().as_slice().is_empty()) } - /// Replaces any occurrences of `typing.Self` in the parameter and return annotations with the - /// given type. (Does not bind the `self` parameter; to do that, use - /// [`bind_self`][Self::bind_self].) - pub(crate) fn apply_self(&self, db: &'db dyn Db, self_type: Type<'db>) -> Self { + /// Replaces `typing.Self` while supplying the runtime receiver for a previously bound + /// explicit receiver annotation. This does not bind a visible receiver parameter. + pub(crate) fn apply_self_with_receiver( + &self, + db: &'db dyn Db, + receiver_type: Type<'db>, + self_type: Type<'db>, + ) -> Self { Self { overloads: self .overloads .iter() - .map(|signature| signature.apply_self(db, self_type)) + .map(|signature| signature.apply_self_with_receiver(db, receiver_type, self_type)) .collect(), } } @@ -487,6 +551,9 @@ pub struct Signature<'db> { /// This is useful for locating and extracting docstring information for the signature. pub(crate) definition: Option>, + /// The constraint introduced by binding an explicitly annotated receiver, if any. + receiver_constraints: Option>, + /// Parameters, in source order. /// /// The ordering of parameters in a valid signature must be: first positional-only parameters, @@ -555,6 +622,9 @@ pub(super) fn walk_signature<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( if let Some(generic_context) = &signature.generic_context { walk_generic_context(db, *generic_context, visitor); } + for ty in signature.receiver_constraint_types() { + visitor.visit_type(db, ty); + } // By default we usually don't visit the type of the default value, // as it isn't relevant to most things for parameter in &signature.parameters { @@ -621,6 +691,7 @@ impl<'db> Signature<'db> { Self { generic_context: None, definition: None, + receiver_constraints: None, parameters, return_ty, } @@ -634,6 +705,7 @@ impl<'db> Signature<'db> { Self { generic_context, definition: None, + receiver_constraints: None, parameters, return_ty, } @@ -644,6 +716,7 @@ impl<'db> Signature<'db> { Signature { generic_context: None, definition: None, + receiver_constraints: None, parameters: Parameters::gradual_form(), return_ty: signature_type, } @@ -696,6 +769,7 @@ impl<'db> Signature<'db> { Self { generic_context, definition: Some(definition), + receiver_constraints: None, parameters, return_ty, } @@ -768,6 +842,7 @@ impl<'db> Signature<'db> { Self { generic_context: self.generic_context, definition: self.definition, + receiver_constraints: self.receiver_constraints.clone(), parameters, return_ty, } @@ -797,6 +872,7 @@ impl<'db> Signature<'db> { Some(Self { generic_context: self.generic_context, definition: self.definition, + receiver_constraints: self.receiver_constraints.clone(), parameters, return_ty, }) @@ -814,6 +890,7 @@ impl<'db> Signature<'db> { .generic_context .map(|context| type_mapping.update_signature_generic_context(db, context)), definition: self.definition, + receiver_constraints: self.map_receiver_constraints(db, type_mapping, tcx, visitor), parameters: self .parameters .apply_type_mapping_impl(db, type_mapping, tcx, visitor), @@ -853,6 +930,7 @@ impl<'db> Signature<'db> { std::iter::once(parameter.annotated_type()).chain(parameter.default_type()) }); let types = typevars + .chain(self.receiver_constraint_types()) .chain(parameters) .chain(std::iter::once(self.return_ty)); @@ -866,6 +944,9 @@ impl<'db> Signature<'db> { typevars: &mut FxOrderSet>, visitor: &FindLegacyTypeVarsVisitor<'db>, ) { + for ty in self.receiver_constraint_types() { + ty.find_legacy_typevars_impl(db, binding_context, typevars, visitor); + } for param in &self.parameters { param.annotated_type().find_legacy_typevars_impl( db, @@ -945,7 +1026,24 @@ impl<'db> Signature<'db> { } pub(crate) fn bind_self(&self, db: &'db dyn Db, self_type: Option>) -> Self { + self.bind_self_with_receiver(db, self_type, self_type) + } + + /// Binds the receiver while preserving the relation between its runtime type and annotation. + /// + /// `typing_self_type` is used separately to replace `typing.Self`; it differs from + /// `receiver_type` for class methods. + pub(crate) fn bind_self_with_receiver( + &self, + db: &'db dyn Db, + receiver_type: Option>, + typing_self_type: Option>, + ) -> Self { let removed_receiver = self.parameters.get(0).is_some_and(Parameter::is_positional); + let explicit_receiver = self + .parameters + .get(0) + .filter(|parameter| parameter.is_positional() && !parameter.inferred_annotation); // TODO: Theoretically, for a signature like `f(*args: *tuple[MyClass, int, *tuple[str, ...]])` with // a variadic first parameter, we should also "skip the first parameter" by modifying the tuple type. @@ -956,7 +1054,31 @@ impl<'db> Signature<'db> { }; let mut return_ty = self.return_ty; let binding_context = self.definition.map(BindingContext::Definition); - if let Some(self_type) = self_type + let receiver_constraint = explicit_receiver.map(|parameter| { + let receiver = receiver_type.unwrap_or_else(|| { + Type::TypeVar(BoundTypeVarInstance::synthetic_self( + db, + Type::object(), + BindingContext::Synthetic, + )) + }); + let annotation = if let Some(typing_self_type) = typing_self_type { + let mapping = + TypeMapping::BindSelf(SelfBinding::new(db, typing_self_type, binding_context)); + parameter + .annotated_type() + .apply_type_mapping(db, &mapping, TypeContext::default()) + } else { + parameter.annotated_type() + }; + receiver.when_constraint_set_assignable_to_owned(db, annotation) + }); + let receiver_constraints = merge_receiver_constraints( + db, + self.receiver_constraints.as_ref(), + receiver_constraint.as_deref(), + ); + if let Some(self_type) = typing_self_type && self.needs_self_mapping(db, removed_receiver) { let self_mapping = @@ -974,6 +1096,7 @@ impl<'db> Signature<'db> { .generic_context .map(|generic_context| generic_context.remove_self(db, binding_context)), definition: self.definition, + receiver_constraints, parameters, return_ty, } @@ -1058,33 +1181,124 @@ impl<'db> Signature<'db> { .is_some_and(|parameter| parameter.is_positional() && parameter.inferred_annotation) } - pub(crate) fn apply_self(&self, db: &'db dyn Db, self_type: Type<'db>) -> Self { + fn apply_self_with_receiver( + &self, + db: &'db dyn Db, + receiver_type: Type<'db>, + self_type: Type<'db>, + ) -> Self { + let binding_context = self.definition.map(BindingContext::Definition); + let receiver_mapping = TypeMapping::BindSelf(SelfBinding::new( + db, + receiver_type, + Some(BindingContext::Synthetic), + )); + let self_mapping = TypeMapping::BindSelf(SelfBinding::new(db, self_type, binding_context)); + let receiver_visitor = ApplyTypeMappingVisitor::default(); + let self_visitor = ApplyTypeMappingVisitor::default(); + let receiver_constraints = self + .map_receiver_constraints( + db, + &receiver_mapping, + TypeContext::default(), + &receiver_visitor, + ) + .map(|constraints| { + Self::map_constraints( + db, + &constraints, + &self_mapping, + TypeContext::default(), + &self_visitor, + ) + }) + .filter(|constraints| { + !constraints.query(|_builder, constraints| constraints.is_always_satisfied(db)) + }); if !self.needs_self_mapping(db, false) { - return self.clone(); + return Self { + receiver_constraints, + ..self.clone() + }; } - let self_mapping = TypeMapping::BindSelf(SelfBinding::new( - db, - self_type, - self.definition.map(BindingContext::Definition), - )); let parameters = self.parameters.apply_type_mapping_impl( db, &self_mapping, TypeContext::default(), - &ApplyTypeMappingVisitor::default(), + &self_visitor, + ); + let return_ty = self.return_ty.apply_type_mapping_impl( + db, + &self_mapping, + TypeContext::default(), + &self_visitor, ); - let return_ty = - self.return_ty - .apply_type_mapping(db, &self_mapping, TypeContext::default()); Self { generic_context: self.generic_context, definition: self.definition, + receiver_constraints, parameters, return_ty, } } + fn receiver_constraints_when_satisfied<'c>( + &self, + checker: &TypeRelationChecker<'_, 'c, 'db>, + db: &'db dyn Db, + ) -> ConstraintSet<'db, 'c> { + let Some(constraints) = self.receiver_constraints.as_ref() else { + return checker.always(); + }; + checker.constraints.load(db, constraints) + } + + fn map_receiver_constraints( + &self, + db: &'db dyn Db, + type_mapping: &TypeMapping<'_, 'db>, + tcx: TypeContext<'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> Option> { + let constraints = Self::map_constraints( + db, + self.receiver_constraints.as_ref()?, + type_mapping, + tcx, + visitor, + ); + (!constraints.query(|_builder, constraints| constraints.is_always_satisfied(db))) + .then_some(constraints) + } + + fn map_constraints( + db: &'db dyn Db, + constraints: &OwnedConstraintSet<'db>, + type_mapping: &TypeMapping<'_, 'db>, + tcx: TypeContext<'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> OwnedConstraintSet<'db> { + if !constraints + .types() + .any(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor) != ty) + { + return constraints.clone(); + } + + let builder = ConstraintSetBuilder::new(); + builder.into_owned(|builder| { + let constraints = builder.load(db, constraints); + constraints.apply_type_mapping_impl(db, type_mapping, tcx, visitor) + }) + } + + fn receiver_constraint_types(&self) -> impl Iterator> + '_ { + self.receiver_constraints + .iter() + .flat_map(OwnedConstraintSet::types) + } + /// Returns this signature with the given specialization applied to parameters and return type. pub(crate) fn apply_specialization( &self, @@ -1459,6 +1673,17 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { source_signatures: &[Signature<'db>], target_signature: &Signature<'db>, ) -> Option> { + // Aggregation summarizes visible parameters and return types, but receiver bindings are + // additional per-signature obligations. Leave those signatures to the ordinary relation, + // which checks each receiver binding before comparing the visible signature. + if target_signature.receiver_constraints.is_some() + || source_signatures + .iter() + .any(|signature| signature.receiver_constraints.is_some()) + { + return None; + } + let single_required_positional_parameter_type = |signature: &Signature<'db>| { if signature.parameters().len() != 1 { return None; @@ -1772,9 +1997,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let inferable = self.inferable.merge(db, signature_inferable); // `inner` will create a constraint set that references these newly inferable typevars. - let checker = self.with_inferable_typevars(inferable); + let mut checker = self.with_inferable_typevars(inferable); + // Every nonterminal receiver constraint constrains at least one typevar. Terminal `always` + // sets are discarded when receiver constraints are merged, so presence alone is enough to + // require lazy typevar evaluation here. + if source.receiver_constraints.is_some() || target.receiver_constraints.is_some() { + checker.typevar_evaluation = TypeVarEvaluation::Lazy; + } let when = checker.with_signature_recursion_guard(source, target, || { - checker.check_signature_pair_inner(db, source, target) + source + .receiver_constraints_when_satisfied(&checker, db) + .and(db, self.constraints, || { + target + .receiver_constraints_when_satisfied(&checker, db) + .and(db, self.constraints, || { + checker.check_signature_pair_inner(db, source, target) + }) + }) }); // But the caller does not need to consider those extra typevars. Whatever constraint set @@ -4607,6 +4846,17 @@ mod tests { } } + #[test] + fn always_satisfied_receiver_constraints_are_discarded() { + let db = setup_db(); + assert!( + merge_receiver_constraints(&db, Some(&OwnedConstraintSet::always()), None).is_none() + ); + assert!( + merge_receiver_constraints(&db, None, Some(&OwnedConstraintSet::always())).is_none() + ); + } + #[test] fn empty() { let mut db = setup_db(); From 29344141c46abeb9fa0c01f3159f8b8b45bff58e Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Wed, 15 Jul 2026 17:48:49 -0400 Subject: [PATCH 103/222] [ty] Implement unified generic call inference (#26141) Generic call inference faces the problem of how to provide unspecialized parameter types as type context to call arguments. We currently specialize parameter types based on the annotated type of the call expression. However, for unannotated calls, we fail to account for constraints contributed by sibling arguments, for example: ```py def f[T](x: T, y: list[T]) -> T: return x def _(x: int | str, y: int): f(x, [y]) # error[invalid-argument-type]: Expected `list[int | str]`, found `list[int]` ``` Or a more complex example, which pyright is also unable to solve: ```py def f[T](x: T, y: list[T], z: list[T]) -> T: return x def _(x: int | str, y: int, z: int | str | None): f(x, [y], [z]) # error[invalid-argument-type]: Expected `list[int | str | None]`, found `list[int]` ``` We also fail to provide useful type coverage for lambda expressions in generic signatures: ```py x = map(lambda x: reveal_type(x) + 2, [1, 2, 3]) # revealed: Unknown reveal_type(x) # revealed: map[Unknown] ``` This PR extends generic call inference to perform fixpoint iteration, allowing inferred argument types to contribute type context constraints to future iterations, until convergence. This allows us solve all the examples above, as well as handle complex chains of dependencies such as the following, which no other type checker currently supports: ```py def chain[A, B, C, D]( first: Callable[[C], D], second: Callable[[B], C], third: Callable[[A], B], source: list[A], ) -> D: return first(second(third(source[0]))) reveal_type(chain( # revealed: int lambda c: reveal_type(c) + 1, # revealed: int lambda b: reveal_type(b) + 1, # revealed: int lambda a: reveal_type(a) + 1, # revealed: int [1, 2, 3], )) ``` Resolves https://github.com/astral-sh/ty/issues/2521, which I split into a few sub-issues that remain as limitations of the fixpoint scheme. This also resolves https://github.com/astral-sh/ty/issues/3469. To avoid the overhead of multi-inference during fixpoint iteration, this PR extends our local expression caching to support caching entire inference region results, including diagnostics. This allows results from speculative inference rounds to be reused during the final round of fixpoint iteration. As a result, this PR is able to resolve https://github.com/astral-sh/ty/issues/3597 with relatively minimal overhead, which accounts for some of the new previously-suppressed ecosystem diagnostics. This also removes a significant part of the code added in https://github.com/astral-sh/ruff/pull/24934, as the `ParamSpec` re-inference path now naturally falls out of fixpoint iteration. I also ended up reverting https://github.com/astral-sh/ruff/pull/26252 as part of this change, as the performance implications seemed negligible, and I don't think it warrants the extra complexity that it grew into (which accounts for the small memory usage regression). --- crates/ty_ide/src/completion.rs | 25 + .../resources/mdtest/bidirectional.md | 370 ++++- .../resources/mdtest/call/overloads.md | 91 ++ .../mdtest/generics/pep695/paramspec.md | 25 +- .../src/types/call/arguments.rs | 23 +- .../ty_python_semantic/src/types/call/bind.rs | 550 +++++--- .../src/types/call/bind/constructor.rs | 97 +- .../ty_python_semantic/src/types/context.rs | 7 + .../src/types/ide_support.rs | 2 + crates/ty_python_semantic/src/types/infer.rs | 3 + .../src/types/infer/builder.rs | 1235 ++++++++++++----- 11 files changed, 1807 insertions(+), 621 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 0013eb60e9..3485689c01 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -7537,6 +7537,31 @@ func("") ); } + #[test] + fn string_literal_completions_overloaded_generic_function_argument() { + let builder = completion_test_builder( + r#" +from typing import Literal, overload + +@overload +def func[T](value: T, mode: Literal["r"]) -> T: ... +@overload +def func[T](value: T, mode: Literal["w"]) -> T: ... +def func(value: object, mode: str) -> object: ... + +func(1, "") +"#, + ); + + assert_snapshot!( + builder.skip_keywords().skip_builtins().skip_auto_import().type_signatures().build().snapshot(), + @r#" + r :: Literal["r"] + w :: Literal["w"] + "#, + ); + } + #[test] fn string_literal_completions_annotated_assignment() { let builder = completion_test_builder( diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 8dd89f190e..f3f6df06bc 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -592,7 +592,7 @@ x5: ObjectCallback | IntCallback = make_callback(lambda value: consume(value.bit But not in a way that leads to assignability errors: ```py -from typing import TypedDict, Any +from typing import Any, Sequence, TypedDict class TD2(TypedDict): x: str @@ -621,6 +621,15 @@ def _(dt: dict[str, Any], key: str): x8: TD2 | None = dt.get(key, {"x": 0}) reveal_type(x8) # revealed: TD2 | None + +def as_sequence[T](x: T, y: list[T], z: list[T]) -> Sequence[T]: + return [x] + +def _(x: int, z: list[int]): + x1: Sequence[int] = as_sequence(x, [x], z) + + # TODO: A covariant type context should not cause us to unnecessarily widen call arguments. + x2: Sequence[int | str] = as_sequence(x, [x], z) # error: [invalid-argument-type] ``` Partially specialized type context is not ignored: @@ -651,43 +660,43 @@ def two_dicts_default(x: dict[U | int, Any], y: dict[U | str, Any]) -> U: raise NotImplementedError def _(): - # revealed: list[int | X] - # revealed: list[str | X] + # revealed: list[X | int] + # revealed: list[X | str] x1 = two_lists(reveal_type(lst(X())), reveal_type(lst(X()))) reveal_type(x1) # revealed: X - # revealed: list[int | X] - # revealed: list[str | X] + # revealed: list[X | int] + # revealed: list[X | str] x2 = two_lists(reveal_type([X()]), reveal_type([X()])) reveal_type(x2) # revealed: X - # revealed: list[int | X] - # revealed: list[str | X] + # revealed: list[X | int] + # revealed: list[X | str] x3 = two_lists_default(reveal_type(lst(X())), reveal_type(lst(X()))) reveal_type(x3) # revealed: X - # revealed: list[int | X] - # revealed: list[str | X] + # revealed: list[X | int] + # revealed: list[X | str] x4 = two_lists_default(reveal_type([X()]), reveal_type([X()])) reveal_type(x4) # revealed: X - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] + # revealed: dict[X | int, Any] + # revealed: dict[X | str, Any] x5 = two_dicts(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) reveal_type(x5) # revealed: X - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] + # revealed: dict[X | int, Any] + # revealed: dict[X | str, Any] x6 = two_dicts(reveal_type({X(): X()}), reveal_type({X(): X()})) reveal_type(x6) # revealed: X - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] + # revealed: dict[X | int, Any] + # revealed: dict[X | str, Any] x7 = two_dicts_default(reveal_type(dct(X(), X())), reveal_type(dct(X(), X()))) reveal_type(x7) # revealed: X - # revealed: dict[int | X, Any] - # revealed: dict[str | X, Any] + # revealed: dict[X | int, Any] + # revealed: dict[X | str, Any] x8 = two_dicts_default(reveal_type({X(): X()}), reveal_type({X(): X()})) reveal_type(x8) # revealed: X ``` @@ -1265,8 +1274,7 @@ def f1(x: list[int | str], y: str) -> str: ... def f1(x, y) -> int | str: raise NotImplementedError -# TODO: We should reveal `list[int]` here. -x1 = f1(reveal_type([1]), 1) # revealed: list[int] +x1 = f1(reveal_type([1]), 1) # revealed: list[int | None] reveal_type(x1) # revealed: int x2 = f1(reveal_type([1]), int_or_str()) # revealed: list[int] @@ -1293,8 +1301,7 @@ def f3(x: TD, y: int) -> int: ... def f3(x: TD2, y: str) -> str: ... def f3(x, y) -> object: ... -# TODO: We should reveal `TD2` here. -x4 = f3(reveal_type({"x": [1]}), "1") # revealed: dict[str, list[int]] +x4 = f3(reveal_type({"x": [1]}), "1") # revealed: TD2 reveal_type(x4) # revealed: str x5 = f3(reveal_type({"x": [1]}), int_or_str()) # revealed: dict[str, list[int]] @@ -1335,7 +1342,7 @@ def list_or_set2[T, U](x: T, y: U) -> list[T] | set[U]: # TODO: We should not error here. # error: [no-matching-overload] -x8 = f6(reveal_type(list_or_set2(1, 1))) # revealed: list[int] | set[int] +x8 = f6(reveal_type(list_or_set2(1, 1))) # revealed: list[int | None] | set[int] reveal_type(x8) # revealed: Unknown @overload @@ -1345,8 +1352,7 @@ def f7[T](y: list[T]) -> list[T]: ... def f7(y: object) -> object: raise NotImplementedError -# TODO: We should reveal `list[int | str]` here. -x9 = f7(reveal_type(["Sheet1"])) # revealed: list[str] +x9 = f7(reveal_type(["Sheet1"])) # revealed: list[int | str] reveal_type(x9) # revealed: list[int | str] def f8(xs: tuple[str, ...]) -> tuple[str, ...]: @@ -1570,10 +1576,9 @@ reveal_type(f10) # revealed: (x: str, y: int, z: str) -> tuple[str, int, str] f11: Callable[[*tuple[int, ...]], tuple[int, ...]] = lambda *args: reveal_type(args) # revealed: tuple[Unknown, ...] reveal_type(f11) # revealed: (*args) -> tuple[Unknown, ...] -# TODO: Better generic call inference. def _(x: list[int]): - f12 = list(map(lambda y: y + 1, x)) - reveal_type(f12) # revealed: list[Unknown] + f12 = list(map(lambda y: reveal_type(y) + 1, x)) # revealed: int + reveal_type(f12) # revealed: list[int] def _() -> Callable[[int], int]: return id(lambda x: reveal_type(x)) # revealed: int @@ -1604,6 +1609,317 @@ _: list[int | str] = f12() # error: [invalid-assignment] reveal_type(f12) # revealed: () -> list[int] ``` +## Unified call inference + +Generic call arguments are inferred under fixpoint iteration, allowing constraints from call +arguments to contribute type context to sibling arguments within a given generic call, until +convergence. + +```py +from typing import Any, Callable, Literal, Sequence, TypedDict, TypeVar, overload + +def combine[T](x: T, y: list[T], z: list[T]) -> T: + return x + +def combine_reversed[T](x: T, z: list[T], y: list[T]) -> T: + return x + +def _(x: int, y: int | str, z: int | str | None): + x1: int | str | None = combine(y, [x], [z]) + reveal_type(x1) # revealed: int | str | None + + x2 = combine(y, [x], [z]) + reveal_type(x2) # revealed: int | str | None + + x3 = combine_reversed(y, [z], [x]) + reveal_type(x3) # revealed: int | str | None + +def collection_pair[T](pair: tuple[T, list[T]]) -> T: + return pair[0] + +x = collection_pair((1, [True])) +reveal_type(x) # revealed: int + +def callable_pair[T](pair: tuple[Callable[[T], int], list[T]]) -> None: + function, values = pair + function(values[0]) + +callable_pair((lambda value: reveal_type(value) + 1, [1])) # revealed: int + +def nested_pair[T](pair: tuple[T, list[T]]) -> T: + return pair[0] + +x = nested_pair(("value", [None])) +reveal_type(x) # revealed: str | None +``` + +```py +class A(TypedDict): + a: int + b: int + +def pair_with_list[T](x: T, y: list[T]) -> T: + return x + +def pair_with_sequence[T](x: T, y: Sequence[T]) -> T: + return x + +def list_pair[T](x: list[T], y: list[T]) -> T: + return x[0] + +def pair[T](x: T, y: T) -> T: + return x + +def _(a: A, b: list[A]): + x1: A = pair_with_list(a, [{"a": 1, "b": 2}]) + reveal_type(x1) # revealed: A + + # TODO: This should solve to `A`. + x2 = pair_with_list(a, [{"a": 1, "b": 2}]) + reveal_type(x2) # revealed: A | dict[str, int] + + x3 = pair_with_sequence(a, [{"a": 1, "b": 2}]) + reveal_type(x3) # revealed: A + + # TODO: This should solve to `A`. + x4 = list_pair(b, [{"a": 1, "b": 2}]) # error: [invalid-argument-type] + reveal_type(x4) # revealed: A | dict[str, int] + + x5 = pair({"a": 1, "b": 2}, a) + reveal_type(x5) # revealed: A + + x6 = pair(a, {"a": 1, "b": 2}) + reveal_type(x6) # revealed: A +``` + +```py +from typing import TypedDict, reveal_type + +class TD(TypedDict): + x: int + +def f[T](x: T, y: T) -> T: + return x + +def _(td: TD): + # revealed: TD + x = reveal_type(f(td, reveal_type({"x": 1}))) # revealed: TD + + # TODO: Generic call narrowing on `reveal_type` happens to choose + # the `dict` constraint here instead of `TD`, failing to narrow the + # dictionary literal. + x = f(td, reveal_type({"x": 1})) # revealed: dict[str, int] + reveal_type(x) # revealed: TD | dict[str, int] +``` + +```py +class ActiveInitializer[T]: + def __new__(cls, *args: object) -> "ActiveInitializer[T]": + return super().__new__(cls) + + def __init__(self, value: T, values: list[T]) -> None: + pass + +x = ActiveInitializer(1, [True]) +reveal_type(x) # revealed: ActiveInitializer[int] + +class InactiveInitializer: + def __new__[T](cls, value: T, values: list[T]) -> T: + return value + + def __init__(self) -> None: + pass + +x = InactiveInitializer(1, [True]) +reveal_type(x) # revealed: int +``` + +```py +def consume_and_produce[T, R]( + consumer: Callable[[T], R], + producer: Callable[[], T], + value: T, +) -> T: + produced = producer() + consumer(produced) + consumer(value) + return produced + +x = consume_and_produce( + lambda x: reveal_type(x), # revealed: str | int + lambda: "s", + 1, +) + +reveal_type(x) # revealed: Literal["s", 1] +``` + +```py +def nested_callable[T]( + value: T, + callbacks: Sequence[Callable[[Callable[[T], None]], None]], +) -> None: + pass + +nested_callable( + 1, + [lambda callable: print(reveal_type(callable))], # revealed: (int, /) -> None +) +``` + +```py +class Base: ... +class Dog(Base): ... +class Cat(Base): ... + +BaseType = TypeVar("BaseType", bound=Base) + +def register_handlers(handlers: dict[str, type[BaseType]]) -> None: ... + +register_handlers({"dog": Dog, "cat": Cat}) + +class X: ... + +def accept_classes[T: X](classes: list[type[T]]) -> None: ... + +accept_classes([X]) +``` + +```py +FloatDtype = type[float] | Literal["float"] + +@overload +def overloaded_call(data: Sequence[str], dtype: object) -> str: ... +@overload +def overloaded_call(data: list[Any], dtype: FloatDtype) -> float: ... +@overload +def overloaded_call[T](data: Sequence[T], dtype: Literal["generic"]) -> T: ... +def overloaded_call(data: object, dtype: object) -> object: + return data + +def _(dtype: FloatDtype): + x = overloaded_call([1.0], dtype) + reveal_type(x) # revealed: int | float +``` + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class TakesInt(Protocol): + def __call__( + self, + tag: Literal["int"], + callback: Callable[[int], int], + ) -> int: ... + +@runtime_checkable +class TakesStr(Protocol): + def __call__( + self, + tag: Literal["str"], + callback: Callable[[str], str], + ) -> str: ... + +def _(callback: TakesInt) -> None: + if isinstance(callback, TakesStr): + reveal_type(callback) # revealed: TakesInt & TakesStr + + # TODO: Perform fixpoint iteration when evaluating callable intersections. + x1 = callback("int", lambda value: reveal_type(value) + 1) # revealed: Unknown + reveal_type(x1) # revealed: int + + # TODO: Perform fixpoint iteration when evaluating callable intersections. + x2 = callback("str", lambda value: reveal_type(value) + "!") # revealed: Unknown + reveal_type(x2) # revealed: str +``` + +Note that long chains of callables with constraint dependencies in reverse source-order may require +multiple fixpoint iterations. + +```py +from typing import Callable + +def chain[A, B, C, D]( + first: Callable[[C], D], + second: Callable[[B], C], + third: Callable[[A], B], + source: list[A], +) -> D: + return first(second(third(source[0]))) + +x = chain( + lambda c: c + 1, + lambda b: b + 1, + lambda a: a + 1, + [1, 2, 3], +) +reveal_type(x) # revealed: int +``` + +The upper bound on iterations is calculated based on the number of independent occurences of +inferable type variables, not the number of arguments. + +```py +from typing import Callable + +def list_to_callable[T](values: list[T]) -> Callable[[T], T]: + raise NotImplementedError + +def propagate[A, B, C, D]( + first: Callable[[C], D], + second: Callable[[B], C], + third: Callable[[A], B], + source: list[A], +) -> D: + return first(second(third(source[0]))) + +def propagate_tuple[A, B, C, D]( + arguments: tuple[ + Callable[[C], D], + Callable[[B], C], + Callable[[A], B], + list[A], + ], +) -> D: + raise NotImplementedError + +def _(seed: int): + x = propagate( + list_to_callable([]), + list_to_callable([]), + list_to_callable([]), + [seed], + ) + reveal_type(x) # revealed: int + + x = propagate_tuple(( + list_to_callable([]), + list_to_callable([]), + list_to_callable([]), + [seed], + )) + reveal_type(x) # revealed: int +``` + +Only diagnostics from the final round of iteration are preserved: + +```py +def diagnostic_pair[T](value: T, values: list[T]) -> T: + return value + +# error: [unresolved-reference] +diagnostic_pair(missing_name, [1]) +diagnostic_pair(suppressed_missing, [1]) # ty: ignore[unresolved-reference] + +def non_generic(value: int) -> int: + return value + +# error: [unresolved-reference] +diagnostic_pair(non_generic(missing_argument), [1]) +diagnostic_pair(non_generic(suppressed_argument), [1]) # ty: ignore[unresolved-reference] +``` + ## Dunder Calls The key and value parameters types are used as type context for `__setitem__` dunder calls: diff --git a/crates/ty_python_semantic/resources/mdtest/call/overloads.md b/crates/ty_python_semantic/resources/mdtest/call/overloads.md index fb2ea5059a..9bdfbc6145 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/call/overloads.md @@ -1166,6 +1166,22 @@ def _(t: tuple[int, str] | tuple[int, str, int]) -> None: reveal_type(m(*t)) # revealed: Literal[1, 2] ``` +### Retry from parameter matching with type context + +When retrying, arguments are inferred with the correct type context: + +```py +from typing import Callable, overload + +@overload +def n(callback: Callable[[int], int], value: int, /) -> int: ... +@overload +def n(callback: Callable[[str], str], first: str, second: str, /) -> str: ... +def n(*args: object) -> object: ... +def _(values: tuple[int] | tuple[str, str]): + reveal_type(n(lambda value: value, *values)) # revealed: int | str +``` + ## Filtering based on variadic arguments This is step 4 of the overload call evaluation algorithm which specifies that: @@ -2143,6 +2159,81 @@ x = f({"y": 1}, "a") reveal_type(x) # revealed: str ``` +Only the types and diagnostics produced by inference against matching overloads are preserved: + +```py +from collections.abc import Callable +from typing import TypedDict, overload + +@overload +def transform(value: str, callback: Callable[[int], str], /) -> str: ... +@overload +def transform(value: bytes, callback: Callable[[int], bytes], /) -> bytes: ... +def transform(value: str | bytes, callback: Callable[..., str | bytes], /) -> str | bytes: + return value + +string_result = transform( + "", + reveal_type(lambda x: str(x) * x), # revealed: (x: int) -> str +) +reveal_type(string_result) # revealed: str +bytes_result = transform( + b"", + reveal_type(lambda x: x.to_bytes(1)), # revealed: (x: int) -> bytes +) +reveal_type(bytes_result) # revealed: bytes + +class Payload(TypedDict): + name: str + count: int + +@overload +def select_payload(payload: Payload, discriminator: int, /) -> int: ... +@overload +def select_payload(payload: dict[str, object], discriminator: str, /) -> str: ... +def select_payload(payload: Payload | dict[str, object], discriminator: int | str, /) -> int | str: + return discriminator + +# error: [missing-typed-dict-key] "Missing required key 'count' in TypedDict `Payload` constructor" +# error: [no-matching-overload] +select_payload({"name": "missing count"}, 1) + +# error: [invalid-argument-type] +# error: [no-matching-overload] +select_payload({"name": "bad count", "count": "not an int"}, 1) + +# error: [invalid-key] +# error: [no-matching-overload] +select_payload({"name": "extra key", "count": 1, "extra": None}, 1) + +select_payload({"extra": None}, "plain dictionary") + +@overload +def select_generic_payload[T](value: T, payload: Payload, discriminator: int, /) -> T: ... +@overload +def select_generic_payload[T](value: T, payload: dict[str, object], discriminator: str, /) -> T: ... +def select_generic_payload[T]( + value: T, + payload: Payload | dict[str, object], + discriminator: int | str, + /, +) -> T: + return value + +selected_generic_payload = select_generic_payload( + 1, + reveal_type({"name": "generic", "count": 1}), # revealed: Payload + 1, +) +reveal_type(selected_generic_payload) # revealed: Literal[1] + +# error: [missing-typed-dict-key] "Missing required key 'count' in TypedDict `Payload` constructor" +# error: [no-matching-overload] +select_generic_payload(1, {"name": "generic"}, 1) + +select_generic_payload(1, {"extra": None}, "plain dictionary") +``` + ```py from typing import SupportsRound, overload diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index cab9664d3d..68196af71c 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -827,9 +827,8 @@ def overloaded_put_object(*, TagSet: object, func: object = None) -> None: ... to_thread_like_keyword(TagSet=reveal_type([{"Key": "k", "Value": "v"}]), func=overloaded_put_object) # revealed: list[Tag] ``` -ParamSpec forwarding should not use raw unspecialized parameter types from a wrapped generic -callable as argument context. The forwarded list literals should be inferred the same way as in the -equivalent direct generic call, not with a raw `list[T]` context from the wrapped callable. +Generic callable arguments participate in fixpoint inference, allowing the inferred type of sibling +`ParamSpec` arguments to constrain the type of the callable. ```py from typing import Callable @@ -845,14 +844,22 @@ def to_thread_like[**P, R](func: Callable[P, R], /, *args: P.args, **kwargs: P.k # TODO: This should not error once the call-expression type context specializes the generic # wrapped callable before we infer forwarded `ParamSpec` arguments. # error: [invalid-assignment] -union_list_result: list[int | str] = to_thread_like(generic_identity_list, reveal_type([1])) # revealed: list[int] +union_list_result: list[int | str] = to_thread_like( + generic_identity_list, + reveal_type([1]), # revealed: list[int] +) -# error: [invalid-argument-type] -# error: [invalid-argument-type] -to_thread_like(generic_pair, [1], reveal_type([""])) # revealed: list[str] +to_thread_like( + generic_pair, + reveal_type([1]), # revealed: list[str | int] + reveal_type([""]), # revealed: list[int | str] +) -# error: [invalid-argument-type] -to_thread_like(generic_pair_with_container, 1, reveal_type([""])) # revealed: list[str] +to_thread_like( + generic_pair_with_container, + 1, + reveal_type([""]), # revealed: list[Literal[1] | str] +) ``` ### Specializing `ParamSpec` with another `ParamSpec` diff --git a/crates/ty_python_semantic/src/types/call/arguments.rs b/crates/ty_python_semantic/src/types/call/arguments.rs index fed6161815..fa2574733c 100644 --- a/crates/ty_python_semantic/src/types/call/arguments.rs +++ b/crates/ty_python_semantic/src/types/call/arguments.rs @@ -49,7 +49,7 @@ struct CallArgument<'a, 'db> { /// /// Note that a single argument may produce multiple distinct inferred types when inferred /// with type context across multiple bindings. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct CallArgumentTypes<'db> { fallback_type: Option>, types: FxHashMap, Type<'db>>, @@ -195,6 +195,12 @@ impl<'a, 'db> CallArguments<'a, 'db> { self.items.len() } + pub(crate) fn is_variadic(&self, index: usize) -> bool { + self.items.get(index).is_some_and(|argument| { + matches!(argument.argument, Argument::Variadic | Argument::Keywords) + }) + } + pub(crate) fn argument_types(&self, index: usize) -> Option<&CallArgumentTypes<'db>> { self.items.get(index).map(|item| &item.types) } @@ -212,10 +218,25 @@ impl<'a, 'db> CallArguments<'a, 'db> { .insert(tcx, ty); } + pub(crate) fn clear_types(&mut self, index: usize) { + self.items + .get_mut(index) + .expect("argument index should be valid") + .types = CallArgumentTypes::default(); + } + pub(crate) fn iter_types(&self) -> impl Iterator> + '_ { self.items.iter().map(|item| &item.types) } + /// Returns `true` if the inferred types are equal for the given set of argument indices. + pub(crate) fn inferred_types_equal_at(&self, other: &Self, argument_indices: &[usize]) -> bool { + argument_indices.iter().all(|&index| { + self.items.get(index).map(|item| &item.types) + == other.items.get(index).map(|item| &item.types) + }) + } + /// Prepend an optional extra synthetic argument (for a `self` or `cls` parameter) to the front /// of this argument list. (If `bound_self` is none, we return the argument list /// unmodified.) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 6b6cf02e1d..a738bf326d 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -12,7 +12,7 @@ mod constructor; mod enum_property; use std::borrow::Cow; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::fmt; @@ -59,7 +59,9 @@ use crate::types::signatures::{ use crate::types::tuple::{TupleLength, TupleSpec, TupleType}; use crate::types::typed_dict::{TypedDictOpenness, extract_unpacked_typed_dict_from_value_type}; use crate::types::typevar::{BoundTypeVarIdentity, TypeVarNonceGenerator}; -use crate::types::visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}; +use crate::types::visitor::{ + TypeCollector, TypeKind, TypeVisitor, walk_non_atomic_type, walk_type_with_recursion_guard, +}; use crate::types::{ BindingContext, BoundMethodType, BoundTypeVarInstance, CallableType, CallableTypes, ClassLiteral, DATACLASS_FLAGS, DataclassFlags, DataclassParams, DynamicType, GenericAlias, @@ -222,13 +224,14 @@ impl<'db> CallableItem<'db> { constraints: &ConstraintSetBuilder<'db>, argument_types: &CallArguments<'_, 'db>, call_expression_tcx: TypeContext<'db>, + mode: CheckTypesMode, ) { match self { CallableItem::Regular(binding) => { binding.check_types(db, constraints, argument_types, call_expression_tcx); } CallableItem::Constructor(binding) => { - binding.check_types(db, constraints, argument_types, call_expression_tcx); + binding.check_types(db, constraints, argument_types, call_expression_tcx, mode); } } } @@ -400,9 +403,10 @@ impl<'db> BindingsElement<'db> { constraints: &ConstraintSetBuilder<'db>, call_arguments: &CallArguments<'_, 'db>, call_expression_tcx: TypeContext<'db>, + mode: CheckTypesMode, ) { for item in &mut self.items { - item.check_types(db, constraints, call_arguments, call_expression_tcx); + item.check_types(db, constraints, call_arguments, call_expression_tcx, mode); } } @@ -492,6 +496,41 @@ pub(crate) struct Bindings<'db> { enclosing_binding_contexts: Option]>>, } +/// The set of overload candidates at a given call-site, before argument type inference. +/// +/// This set is kept stable across fixpoint iterations during generic call inference, ensuring +/// that all inferred argument types are available during overload evaluation. +pub(crate) type OverloadSet = SmallVec<[SmallVec<[usize; 1]>; 1]>; + +/// Returns whether overload evaluation is required for the given set of overload candidates. +pub(crate) fn requires_overload_evaluation(candidates: &OverloadSet) -> bool { + // TODO: This only recognizes overloads within the same callable binding, ignoring intersections + // that may need to be evaluated. `OverloadSet` should preserve the union-of-intersections + // representation of callables. + candidates.iter().any(|indices| indices.len() > 1) +} + +/// Controls the behavior of a given call to [`Bindings::check_types`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CheckTypesMode { + /// After checking, retain only the callable bindings that contribute to the call + /// evaluation. + Finalize, + + /// Preserve all callable bindings, regardless of whether they evaluate successfully. + /// + /// Generic call inference may perform fixpoint iteration in order to unify + /// type context across call arguments, and so all callable bindings should + /// remain candidates until the final round is finalized. + Provisional, +} + +impl CheckTypesMode { + pub(crate) fn is_provisional(self) -> bool { + matches!(self, Self::Provisional) + } +} + impl<'db> Bindings<'db> { fn as_result(&self, db: &'db dyn Db) -> Result<(), CallErrorKind> { let mut all_ok = true; @@ -815,6 +854,33 @@ impl<'db> Bindings<'db> { } } + /// Visits the given set of overload candidates, invoking the provided callback for each + /// binding. + pub(crate) fn visit_overload_set<'a>( + &'a self, + candidates: &'a OverloadSet, + visit: &mut impl FnMut(&'a Binding<'db>, &'a CallableBinding<'db>), + ) { + let mut candidate_overloads = candidates.iter(); + self.visit_type_context_callables(&mut |binding| { + let overloads = candidate_overloads + .next() + .expect("checked bindings are stable across fixpoint iterations"); + + if overloads.is_empty() { + // If there is a single non-matching overload, we infer against it for better + // diagnostics. + if let [overload] = binding.overloads() { + visit(overload, binding); + } + } else { + for &overload in overloads { + visit(&binding.overloads()[overload], binding); + } + } + }); + } + /// Returns `true` if every element of the union contains an intersection element with a matching /// overload that satisfies the provided closure, or `false` otherwise. pub(crate) fn satisfies(&self, f: impl Fn(&Binding<'db>) -> bool) -> bool { @@ -1041,6 +1107,7 @@ impl<'db> Bindings<'db> { call_arguments, call_expression_tcx, dataclass_field_specifiers, + CheckTypesMode::Finalize, ) { Ok(()) => Ok(self), Err(err) => Err(CallError(err, Box::new(self))), @@ -1054,10 +1121,17 @@ impl<'db> Bindings<'db> { call_arguments: &CallArguments<'_, 'db>, call_expression_tcx: TypeContext<'db>, dataclass_field_specifiers: &[Type<'db>], + mode: CheckTypesMode, ) -> Result<(), CallErrorKind> { // Check types for each element (union variant) for element in &mut self.elements { - element.check_types(db, constraints, call_arguments, call_expression_tcx); + element.check_types(db, constraints, call_arguments, call_expression_tcx, mode); + } + + // Generic call inference must maintain a stable set of overloads until the final round + // of fixpoint iteration. + if mode.is_provisional() { + return Ok(()); } self.evaluate_known_cases(db, call_arguments, dataclass_field_specifiers); @@ -1083,6 +1157,35 @@ impl<'db> Bindings<'db> { self.as_result(db) } + /// Finalize the bindings after a provisional check, retaining only those that contribute + /// to the final call evaluation. + pub(crate) fn finalize_argument_inference( + &mut self, + db: &'db dyn Db, + call_arguments: &CallArguments<'_, 'db>, + dataclass_field_specifiers: &[Type<'db>], + ) -> Result<(), CallErrorKind> { + self.evaluate_known_cases(db, call_arguments, dataclass_field_specifiers); + + for constructor in self.iter_constructor_items_mut() { + if constructor.discard_downstream_constructor(db) + && let Some(downstream) = constructor.downstream_constructor_mut() + { + let _ = downstream.finalize_argument_inference( + db, + call_arguments, + dataclass_field_specifiers, + ); + } + } + + for element in &mut self.elements { + element.retain_successful(db); + } + + self.as_result(db) + } + /// Returns true if this is a single callable (not a union or intersection). pub(crate) fn is_single(&self) -> bool { match &*self.elements { @@ -3273,15 +3376,7 @@ impl<'db> CallableBinding<'db> { // `*arg` where `arg` is a union of a 2-tuple and a 3-tuple, we shouldn't eliminate any // overload for arity reasons before trying argument expansion. let (should_retry_after_provisional_arity, overloads_for_expansion) = - if self.overloads.len() > 1 - && self.matching_overload_index().len() < self.overloads.len() - && call_arguments.iter().any(|(argument, argument_types)| { - matches!(argument, Argument::Variadic) - && argument_types - .get_default() - .is_some_and(|argument_type| is_expandable_type(db, argument_type)) - }) - { + if self.should_retry_after_provisional_arity(db, call_arguments.as_ref()) { // We will retry all overloads after argument expansion. (true, (0..self.overloads.len()).collect()) } else { @@ -3595,6 +3690,38 @@ impl<'db> CallableBinding<'db> { snapshotter.restore(self, post_evaluation_snapshot); } + /// Returns the set of overload candidates that may contribute to the call evaluation. + /// + /// Overloads removed by provisional arity matching are typically ignored. However, they remain + /// candidates in the presence of an expandable `*args` argument, that may lead to overload + /// evaluation retrying with the expanded argument. + pub(crate) fn candidate_overload_indices( + &self, + db: &'db dyn Db, + call_arguments: &CallArguments<'_, 'db>, + ) -> SmallVec<[usize; 1]> { + if self.should_retry_after_provisional_arity(db, call_arguments) { + (0..self.overloads.len()).collect() + } else { + self.matching_overloads().map(|(index, _)| index).collect() + } + } + + fn should_retry_after_provisional_arity( + &self, + db: &'db dyn Db, + call_arguments: &CallArguments<'_, 'db>, + ) -> bool { + self.overloads.len() > 1 + && self.matching_overloads().count() < self.overloads.len() + && call_arguments.iter().any(|(argument, argument_types)| { + matches!(argument, Argument::Variadic) + && argument_types + .get_default() + .is_some_and(|argument_type| is_expandable_type(db, argument_type)) + }) + } + /// Filter overloads based on variadic argument to variadic parameter match. /// /// This is the step 4 of the [overload call evaluation algorithm][1]. @@ -3908,6 +4035,13 @@ impl<'db> CallableBinding<'db> { .filter(|(_, overload)| !overload.has_errors_affecting_overload_resolution()) } + /// Returns the overload which call arguments should be inferred against, if every overload is + /// non-matching. + pub(crate) fn best_failing_overload(&self) -> Option<&Binding<'db>> { + self.best_failing_overload_index(FailingOverloadSelection::AffectsOverloadResolution) + .and_then(|index| self.overloads.get(index)) + } + /// Returns an iterator over all the mutable overloads that matched for this call binding. pub(crate) fn matching_overloads_mut( &mut self, @@ -4040,9 +4174,7 @@ impl<'db> CallableBinding<'db> { // If multiple overloads passed arity check but only one matched types // (possibly with semantic errors), report its errors directly instead // of the generic "no matching overload" message. - if let MatchingOverloadIndex::Single(matching_overload_index) = - self.matching_overload_index() - { + if let Ok((matching_overload_index, _)) = self.matching_overloads().exactly_one() { let callable_description = CallableDescription::new(context.db(), self.signature_type); let matching_overload = @@ -4201,16 +4333,6 @@ pub(crate) enum MatchingOverloadIndex { Multiple(Vec), } -impl MatchingOverloadIndex { - pub(crate) fn len(&self) -> usize { - match self { - MatchingOverloadIndex::None => 0, - MatchingOverloadIndex::Single(_) => 1, - MatchingOverloadIndex::Multiple(indexes) => indexes.len(), - } - } -} - #[derive(Default, Clone, Copy)] struct ParameterInfo { matched: bool, @@ -5502,8 +5624,9 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { ); }; - match callable_binding.matching_overload_index() { - MatchingOverloadIndex::None => { + let mut matching_overloads = callable_binding.matching_overloads(); + match (matching_overloads.next(), matching_overloads.next()) { + (None, _) => { if let [binding] = callable_binding.overloads() { // This is not an overloaded function, so we can propagate its errors to the // outer bindings. @@ -5519,12 +5642,12 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { extend_errors(&callable_binding.overloads()[index].errors); } } - MatchingOverloadIndex::Single(index) => { + (Some((_, binding)), None) => { // TODO: We should also update the specialization for the `ParamSpec` to reflect the // matching overload here. - extend_errors(&callable_binding.overloads()[index].errors); + extend_errors(&binding.errors); } - MatchingOverloadIndex::Multiple(_) => { + (Some(_), Some(_)) => { if !matches!( callable_binding.overload_call_return_type, Some(OverloadCallReturnType::ArgumentTypeExpansion(_)) @@ -5693,8 +5816,8 @@ impl<'db> MatchedArgument<'db> { } } -/// The type context to use when inferring a call-site argument. -#[derive(Clone, Copy, Debug)] +/// The type context to use when inferring a call-site argument, for a given binding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ArgumentTypeContext<'db> { Standard { /// The raw parameter type from the overload signature. @@ -5703,6 +5826,7 @@ pub(crate) enum ArgumentTypeContext<'db> { /// declared type. parameter_type: Type<'db>, }, + ParamSpec { /// The `P.args` or `P.kwargs` parameter that this argument is bound to. paramspec_parameter_type: Type<'db>, @@ -5766,7 +5890,7 @@ impl<'db> ArgumentTypeContext<'db> { /// For a forwarded `ParamSpec` argument, the expression is inferred against the specialized /// wrapped parameter but may still be looked up through the original `P.args` or `P.kwargs` /// annotation during the outer wrapper call check. - pub(crate) fn insert_inferred_type( + pub(crate) fn insert_inferred_type_into( self, arguments_types: &mut CallArguments<'_, 'db>, argument_index: usize, @@ -5810,6 +5934,58 @@ struct ParamSpecArgumentContext<'a, 'call, 'db> { call_expression_tcx: TypeContext<'db>, } +/// Returns the number of occurrences of inferable type variables in the provided type. +fn inferable_typevar_occurrences<'db>( + db: &'db dyn Db, + ty: Type<'db>, + inferable: InferableTypeVars<'db>, +) -> usize { + struct InferableTypeVarVisitor<'db> { + inferable: InferableTypeVars<'db>, + count: Cell, + stack: RefCell; 8]>>, + } + + impl<'db> TypeVisitor<'db> for InferableTypeVarVisitor<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if let Type::TypeVar(typevar) = ty { + let identity = if typevar.is_paramspec(db) { + typevar.without_paramspec_attr(db).identity(db) + } else { + typevar.identity(db) + }; + if identity.is_inferable(db, self.inferable) { + self.count.set(self.count.get() + 1); + } + return; + } + + let TypeKind::NonAtomic(non_atomic_type) = TypeKind::from(ty) else { + return; + }; + if self.stack.borrow().contains(&ty) { + return; + } + + self.stack.borrow_mut().push(ty); + walk_non_atomic_type(db, non_atomic_type, self); + self.stack.borrow_mut().pop(); + } + } + + let visitor = InferableTypeVarVisitor { + inferable, + count: Cell::new(0), + stack: RefCell::default(), + }; + visitor.visit_type(db, ty); + visitor.count.get() +} + /// Binding information for one of the overloads of a callable. #[derive(Debug, Clone)] pub(crate) struct Binding<'db> { @@ -5897,6 +6073,36 @@ impl<'db> Binding<'db> { .get(argument_index + usize::from(binding.bound_type.is_some())) } + /// Returns the number of occurrences of inferable type variables in the parameter(s) matching the + /// provided argument index. + pub(crate) fn typevar_occurrences_for_parameter( + &self, + db: &'db dyn Db, + binding: &CallableBinding<'db>, + argument_index: usize, + ) -> usize { + let Some(generic_context) = self.signature.generic_context else { + return 0; + }; + let Some(argument) = self.matched_argument_for_call_argument(binding, argument_index) + else { + return 0; + }; + + let inferable_typevars = generic_context.inferable_typevars(db); + argument + .parameters + .iter() + .map(|parameter| { + inferable_typevar_occurrences( + db, + self.signature.parameters()[parameter.index].annotated_type(), + inferable_typevars, + ) + }) + .sum() + } + /// Returns source argument indices matched to the `ParamSpec` component. /// /// The returned indices are relative to the original call site, excluding any synthetic bound @@ -5931,57 +6137,6 @@ impl<'db> Binding<'db> { .collect() } - /// Infers the callable value that a `ParamSpec` maps to for this overload. - /// - /// The wrapper's current arguments are rechecked against this single overload so that a binder - /// argument such as `func: Callable[P, R]` can specialize `P` before forwarded arguments are - /// inferred. - /// - /// ```py - /// def put_tags(tags: list[Tag], /) -> None: ... - /// def wrapper[**P, R](func: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: ... - /// wrapper(put_tags, [{"Key": "k", "Value": "v"}]) # infers `P` from `put_tags` - /// ``` - fn inferred_paramspec_callable( - &self, - db: &'db dyn Db, - constraints: &ConstraintSetBuilder<'db>, - binding: &CallableBinding<'db>, - paramspec: BoundTypeVarInstance<'db>, - arguments_types: &CallArguments<'_, 'db>, - call_expression_tcx: TypeContext<'db>, - ) -> Option> { - let mut specialized_binding = - CallableBinding::from_overloads(self.signature_type, [self.signature.clone()]); - if let Some(bound_type) = binding.bound_type { - specialized_binding = specialized_binding.with_bound_type(bound_type); - } - - // Reuse the normal binding checker to infer `P` from the arguments that have already - // been inferred. This avoids duplicating specialization logic here; the current - // `P.args`/`P.kwargs` argument is still uninferred, so it only contributes `Unknown`. - let mut specialized_bindings = - Bindings::from(specialized_binding).match_parameters(db, arguments_types); - let _ = specialized_bindings.check_types_impl( - db, - constraints, - arguments_types, - call_expression_tcx, - &[], - ); - - let Type::Callable(callable) = specialized_bindings - .single_element() - .and_then(|binding| binding.matching_overloads().exactly_one().ok()) - .and_then(|(_, overload)| overload.specialization(db)) - .and_then(|specialization| specialization.get(db, paramspec))? - else { - return None; - }; - - (callable.kind(db) == CallableTypeKind::ParamSpecValue).then_some(callable) - } - /// Returns the specialized wrapped-call parameter type for a forwarded argument. /// /// The forwarded outer arguments are first projected into a sub-call against the callable stored @@ -6017,7 +6172,12 @@ impl<'db> Binding<'db> { self.signature_type, callable.signatures(db).iter().cloned(), ); - let sub_arguments = arguments_types.select(¶mspec_argument_indices); + + let mut sub_arguments = arguments_types.select(¶mspec_argument_indices); + // Clear the previously inferred type for this argument, if it was inferred in the previous + // fixpoint iteration. + sub_arguments.clear_types(sub_argument_index); + let mut specialized_bindings = Bindings::from(specialized_binding).match_parameters(db, &sub_arguments); let _ = specialized_bindings.check_types_impl( @@ -6026,6 +6186,7 @@ impl<'db> Binding<'db> { &sub_arguments, call_expression_tcx, &[], + CheckTypesMode::Finalize, ); let specialized_binding = specialized_bindings.single_element()?; @@ -6045,14 +6206,6 @@ impl<'db> Binding<'db> { let parameter_type = specialized_overload.signature.parameters() [specialized_parameter.index] .annotated_type(); - // A context like `list[Unknown]` or `list[T@g]` is worse than no context here: - // it can erase the concrete literal type that should later specialize the wrapped - // callable. Check the parameter before applying the sub-call specialization so an - // earlier forwarded argument cannot turn `list[T@g]` into apparently-safe `list[int]`. - if parameter_type.has_dynamic(db) || parameter_type.has_typevar_or_typevar_instance(db) { - return None; - } - let parameter_type = specialized_overload .specialization(db) .map_or(parameter_type, |specialization| { @@ -6063,52 +6216,18 @@ impl<'db> Binding<'db> { .then_some(parameter_type) } - /// Returns the wrapper parameter type that can bind the `ParamSpec` used by forwarded arguments. + /// Returns the type context to use for bidirectional inference of a source call argument, + /// using the provided argument specialization. /// - /// This is used before normal source-order inference so a keyword forwarded through `P.kwargs` - /// can still use a later binder argument such as `func=put_object`. - /// - /// ```py - /// def put_object(*, TagSet: list[Tag]) -> None: ... - /// def wrapper[**P, R](func: Callable[P, R], **kwargs: P.kwargs) -> R: ... - /// wrapper(TagSet=[{"Key": "k", "Value": "v"}], func=put_object) - /// ``` - pub(crate) fn paramspec_binder_parameter_type( - &self, - db: &'db dyn Db, - binding: &CallableBinding<'db>, - argument_index: usize, - ) -> Option> { - let (prefix, paramspec) = self.signature.parameters().as_paramspec_with_prefix()?; - let [parameter] = self - .matched_argument_for_call_argument(binding, argument_index)? - .parameters - .as_slice() - else { - return None; - }; - - if parameter.index >= prefix.len() { - return None; - } - - let parameter_type = self.signature.parameters()[parameter.index].annotated_type(); - parameter_type - .references_typevar(db, paramspec.typevar(db).identity(db)) - .then_some(parameter_type) - } - - /// Returns the type context to use for bidirectional inference of a source call argument. - /// - /// This covers the normal parameter annotation path, generic return-context specialization, and - /// the `ParamSpec` forwarding path where a wrapper argument receives context from the wrapped - /// callable's parameter. + /// This method also handles `ParamSpec` forwarding, where a wrapper argument receives context + /// from the wrapped callable's parameter. /// /// ```py /// def put_tags(tags: list[Tag], /) -> None: ... /// def wrapper[**P, R](func: Callable[P, R], /, *args: P.args) -> R: ... /// wrapper(put_tags, [{"Key": "k", "Value": "v"}]) # forwarded list gets `list[Tag]` /// ``` + #[expect(clippy::too_many_arguments)] pub(crate) fn argument_type_context( &self, db: &'db dyn Db, @@ -6117,6 +6236,7 @@ impl<'db> Binding<'db> { arguments_types: &CallArguments<'_, 'db>, argument_index: usize, call_expression_tcx: TypeContext<'db>, + specialization: Option>, ) -> Option> { let argument_matches = self.matched_argument_for_call_argument(binding, argument_index)?; let [parameter] = argument_matches.parameters.as_slice() else { @@ -6126,6 +6246,19 @@ impl<'db> Binding<'db> { let parameter = &self.signature.parameters()[parameter.index]; let mut parameter_type = parameter.annotated_type(); let original_parameter_type = parameter_type; + let paramspec_callable = |paramspec| { + let Type::Callable(callable) = self + .specialization(db) + .and_then(|specialization| specialization.get(db, paramspec)) + .or_else(|| { + specialization.and_then(|specialization| specialization.get(db, paramspec)) + })? + else { + return None; + }; + + (callable.kind(db) == CallableTypeKind::ParamSpecValue).then_some(callable) + }; // If the parameter is a single non-ParamSpec type variable with an upper bound, // e.g., `typing.Self`, use the upper bound as type context. ParamSpec components @@ -6142,9 +6275,8 @@ impl<'db> Binding<'db> { )); } - // If this is a generic call, attempt to specialize the parameter type using the - // declared type context, propagating the declared types of any type variables. - if let Some(generic_context) = self.signature.generic_context { + // If this is a generic call, specialize the parameter type using the computed constraints. + if self.signature.generic_context.is_some() { let paramspec = if let Type::TypeVar(typevar) = original_parameter_type && typevar.is_paramspec(db) && typevar.paramspec_attr(db).is_some() @@ -6154,70 +6286,10 @@ impl<'db> Binding<'db> { None }; - let mut return_type_solutions: FxHashMap, Type<'db>> = - FxHashMap::default(); - if let Some(declared_return_ty) = call_expression_tcx.annotation { - let return_ty = self - .normalized_constructor_return(db) - .unwrap_or(self.signature.return_ty); - let path_bounds = return_ty.assignable_solutions_with_inferable( - db, - declared_return_ty, - generic_context.inferable_typevars(db), - ); - - if let Solutions::Constrained(solutions) = path_bounds.solve(db, constraints) { - for solution in solutions { - for binding in solution { - let identity = binding.bound_typevar.identity(db); - return_type_solutions - .entry(identity) - .and_modify(|existing| { - *existing = UnionType::from_two_elements( - db, - *existing, - binding.solution, - ); - }) - .or_insert(binding.solution); - } - } - } - } - - // Default specialize any type variables to a marker type, which will be ignored - // during argument inference, allowing the concrete subset of the parameter - // type to still affect argument inference. - // - // TODO: Eventually, we want to "tie together" the typevars of the two calls - // so that we can infer their specializations at the same time — or at least, for - // the specialization of one to influence the specialization of the other. It's - // not yet clear how we're going to do that. (We might have to start inferring - // constraint sets for each expression, instead of simple types?) - let unspecialized = Type::Dynamic(DynamicType::UnspecializedTypeVar); - let specialization = generic_context.specialize_recursive( - db, - generic_context.variables(db).map(|typevar| { - Some( - return_type_solutions - .get(&typevar.identity(db)) - .copied() - .unwrap_or(unspecialized), - ) - }), - ); - - // A `P.args`/`P.kwargs` parameter has a useful context only after another - // argument, usually a `Callable[P, R]`, specializes `P`. + // A `P.args`/`P.kwargs` parameter receives context from the `ParamSpec` specialization + // checked during the previous fixpoint round. if let Some(paramspec) = paramspec - && let Some(callable) = self.inferred_paramspec_callable( - db, - constraints, - binding, - paramspec, - arguments_types, - call_expression_tcx, - ) + && let Some(callable) = paramspec_callable(paramspec) && let Some(specialized_parameter_type) = self.paramspec_argument_context(ParamSpecArgumentContext { db, @@ -6235,7 +6307,7 @@ impl<'db> Binding<'db> { )); } - parameter_type = parameter_type.apply_specialization(db, specialization); + parameter_type = parameter_type.apply_optional_specialization(db, specialization); } Some(ArgumentTypeContext::standard( @@ -6244,6 +6316,82 @@ impl<'db> Binding<'db> { )) } + /// Returns the specialization that should be applied to the parameters of this overload for + /// type context. + /// + /// Parameter types are specialized based on the constraints from the declared type of the + /// call expression, as well as the argument types inferred from the previous round of + /// fixpoint iteration. + pub(crate) fn argument_type_context_specialization( + &self, + db: &'db dyn Db, + constraints: &ConstraintSetBuilder<'db>, + call_expression_tcx: TypeContext<'db>, + ) -> Option> { + let generic_context = self.signature.generic_context?; + + let mut return_type_solutions: FxHashMap, Type<'db>> = + FxHashMap::default(); + if let Some(declared_return_ty) = call_expression_tcx.annotation { + let normalized_return_ty = self + .normalized_constructor_return(db) + .unwrap_or(self.signature.return_ty); + let path_bounds = normalized_return_ty.assignable_solutions_with_inferable( + db, + declared_return_ty, + generic_context.inferable_typevars(db), + ); + + if let Solutions::Constrained(solutions) = path_bounds.solve(db, constraints) { + for solution in solutions { + for binding in solution { + let identity = binding.bound_typevar.identity(db); + return_type_solutions + .entry(identity) + .and_modify(|existing| { + *existing = + UnionType::from_two_elements(db, *existing, binding.solution); + }) + .or_insert(binding.solution); + } + } + } + } + + // TODO: Note that specializing parameter types for type context using this specialization is + // not strictly correct, as it requires eagerly choosing a solution for a given type variable, + // which may conflate upper and lower bounds when applied transitively to parameter types + // in which the type variable may be in invariant or contravariant position. A more correct + // approach would be to propagate constraint sets as type context, making bidirectional + // inference constraint-set-aware, or to infer constraint sets directly for argument types, + // and avoid the need to construct type context before call inference has completed. + Some(generic_context.specialize_recursive( + db, + generic_context.variables(db).map(|typevar| { + let identity = typevar.identity(db); + + let call_expression_constraints = return_type_solutions.get(&identity).copied(); + let argument_constraints = self + .specialization(db) + .and_then(|specialization| specialization.get(db, typevar)) + .filter(|ty| !ty.has_dynamic(db)) + .map(|ty| ty.promote(db)); + + // TODO: We should similarly combine both the call expression and argument constraints + // here. We currently only rely on argument constraints when there is no explicit declared + // type for the call expression. + Some( + call_expression_constraints + .or(argument_constraints) + // Default specialize any type variables to a marker type, which will be ignored + // during argument inference, allowing the concrete subset of the parameter + // type to still affect argument inference. + .unwrap_or(Type::Dynamic(DynamicType::UnspecializedTypeVar)), + ) + }), + )) + } + /// Records the overload's source definition index for later diagnostics. fn with_source_overload_index(mut self, source_overload_index: usize) -> Self { self.source_overload_index = source_overload_index; diff --git a/crates/ty_python_semantic/src/types/call/bind/constructor.rs b/crates/ty_python_semantic/src/types/call/bind/constructor.rs index 9559234094..6b922afa01 100644 --- a/crates/ty_python_semantic/src/types/call/bind/constructor.rs +++ b/crates/ty_python_semantic/src/types/call/bind/constructor.rs @@ -1,4 +1,4 @@ -use super::{Binding, Bindings, CallableBinding, CallableItem}; +use super::{Binding, Bindings, CallableBinding, CallableItem, CheckTypesMode}; use crate::db::Db; use crate::types::call::arguments::CallArguments; use crate::types::constraints::ConstraintSetBuilder; @@ -76,58 +76,78 @@ impl<'db> ConstructorBinding<'db> { } } - /// Check types for this constructor method, and then decide (based on the resolved return - /// types) whether we should continue considering downstream constructors or discard them. + /// Check types for all bindings in this constructor. + /// + /// If `CheckTypesMode::Finalize` is provided, inactive downstream constructors will be + /// discarded. Otherwise, all constructor bindings are preserved after the check. pub(super) fn check_types( &mut self, db: &'db dyn Db, constraints: &ConstraintSetBuilder<'db>, argument_types: &CallArguments<'_, 'db>, call_expression_tcx: TypeContext<'db>, + mode: CheckTypesMode, ) { - /// For constructors which may have downstreams (that is, metaclass `__call__` or `__new__`), - /// analyze their overloads to determine whether to check downstream constructors. - /// - /// We analyze overloads individually rather than just relying on the resolved return type of - /// the overall callable, because in multiple-matching-overload cases where the overload - /// resolution algorithm might just collapse to `Unknown`, we want to make a more informed - /// decision based on whether all overloads return instance types, or not. - fn should_check_downstream<'db>( - binding: &ConstructorBinding<'db>, - db: &'db dyn Db, - ) -> bool { - let constructor_kind = binding.constructor_kind(); - if constructor_kind.is_init() || binding.downstream_constructor().is_none() { - return false; - } - - let callable = binding.callable(); + self.entry + .check_types(db, constraints, argument_types, call_expression_tcx); - if callable.as_result().is_err() { - return false; + // Now that we've fully checked our own callable, we can determine whether downstream + // constructors should be checked or not. + if mode.is_provisional() { + if let Some(downstream) = self.downstream_constructor_mut() { + let _ = downstream.check_types_impl( + db, + constraints, + argument_types, + call_expression_tcx, + &[], + mode, + ); } + } else if !self.should_check_downstream(db) { + // If not, we can discard the downstream constructor bindings entirely. + self.downstream_constructor = None; + } + } - let constructed_instance_type = binding.constructed_instance_type(); - let constructor_class_literal = binding.constructed_class_literal(db); + /// For constructors which may have downstreams (that is, metaclass `__call__` or `__new__`), + /// analyze their overloads to determine whether to check downstream constructors. + /// + /// We analyze overloads individually rather than just relying on the resolved return type of + /// the overall callable, because in multiple-matching-overload cases where the overload + /// resolution algorithm might just collapse to `Unknown`, we want to make a more informed + /// decision based on whether all overloads return instance types, or not. + fn should_check_downstream(&self, db: &'db dyn Db) -> bool { + let constructor_kind = self.constructor_kind(); + if constructor_kind.is_init() || self.downstream_constructor().is_none() { + return false; + } - // If any matching overload returns the constructed instance type itself, or an instance of - // the constructed class, we need to check downstream constructors. - callable.matching_overloads().any(|(_, overload)| { - overload.return_ty == constructed_instance_type - || constructor_class_literal.is_some_and(|class_literal| { - constructor_returns_instance(db, class_literal, overload.return_ty) - }) - }) + let callable = self.callable(); + if callable.as_result().is_err() { + return false; } - self.entry - .check_types(db, constraints, argument_types, call_expression_tcx); + let constructed_instance_type = self.constructed_instance_type(); + let constructor_class_literal = self.constructed_class_literal(db); + + // If any matching overload returns the constructed instance type itself, or an instance of + // the constructed class, we need to check downstream constructors. + callable.matching_overloads().any(|(_, overload)| { + overload.return_ty == constructed_instance_type + || constructor_class_literal.is_some_and(|class_literal| { + constructor_returns_instance(db, class_literal, overload.return_ty) + }) + }) + } - // Now that we've fully checked our own callable, we can determine whether downstream - // constructors should be checked or not. - if !should_check_downstream(self, db) { - // If not, we can discard the downstream constructor bindings entirely. + /// Discards an inactive downstream constructor. + pub(super) fn discard_downstream_constructor(&mut self, db: &'db dyn Db) -> bool { + if self.should_check_downstream(db) { + true + } else { self.downstream_constructor = None; + false } } @@ -149,6 +169,7 @@ impl<'db> ConstructorBinding<'db> { argument_types, call_expression_tcx, dataclass_field_specifiers, + CheckTypesMode::Finalize, ); } } diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index b437b813b4..0eaa06d914 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -235,6 +235,13 @@ impl<'db, 'ast> InferContext<'db, 'ast> { diagnostics.shrink_to_fit(); diagnostics } + + /// Consume this context without compacting its diagnostics. + #[must_use] + pub(crate) fn finish_uncompacted(mut self) -> TypeCheckDiagnostics { + self.bomb.defuse(); + self.diagnostics.into_inner() + } } impl fmt::Debug for InferContext<'_, '_> { diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index c5f8813b8f..5b29a3c505 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::FxIndexSet; use crate::place::builtins_module_scope; use crate::reachability::is_range_reachable; +use crate::types::call::bind::CheckTypesMode; use crate::types::call::{CallArguments, CallError, MatchedArgument}; use crate::types::class::{DynamicClassAnchor, DynamicEnumAnchor, DynamicNamedTupleAnchor}; use crate::types::constraints::ConstraintSetBuilder; @@ -777,6 +778,7 @@ pub fn call_signature_details<'db>( &call_arguments, TypeContext::default(), &[], + CheckTypesMode::Finalize, ); // Extract signature details from all callable bindings diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 4ee85ba16b..4218d57812 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -1537,6 +1537,9 @@ struct ExpressionInferenceExtra<'db> { /// The diagnostics for this region. diagnostics: TypeCheckDiagnostics, + /// Functions called while inferring this expression. + called_functions: Box<[FunctionType<'db>]>, + /// The fallback type for missing expressions/bindings/declarations or recursive type inference. cycle_recovery: Option>, } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 06d7c9e2c0..9c14d27983 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1,4 +1,5 @@ use std::cell::{OnceCell, RefCell}; +use std::collections::hash_map; use std::rc::Rc; use compact_str::CompactString; @@ -23,11 +24,12 @@ use ty_python_core::ast_ids::HasScopedUseId; use ty_python_core::statement::StatementInner; use super::{ - DeferredAndUndecorated, DefinitionInference, DefinitionInferenceExtra, DefinitionTypes, - ExpressionInference, ExpressionInferenceExtra, FrozenMap, FrozenSet, FrozenValueMap, - FunctionDecoratorInference, InferenceRegion, OtherDefinitionInferenceExtra, ScopeInference, - ScopeInferenceExtra, infer_deferred_types, infer_definition_types, infer_expression_types, - infer_same_file_expression_type, infer_unpack_types, + CollectionUseConstraints, DeferredAndUndecorated, DefinitionInference, + DefinitionInferenceExtra, DefinitionTypes, ExpressionInference, ExpressionInferenceExtra, + FrozenMap, FrozenSet, FrozenValueMap, FunctionDecoratorInference, InferenceRegion, + OtherDefinitionInferenceExtra, ScopeInference, ScopeInferenceExtra, infer_deferred_types, + infer_definition_types, infer_expression_types, infer_same_file_expression_type, + infer_unpack_types, }; use crate::diagnostic::format_enumeration; use crate::place::{ @@ -41,7 +43,9 @@ use crate::place::{ use crate::reachability::{ReachabilityEvaluationCache, evaluate_reachability_with_cache}; use crate::types::add_inferred_python_version_hint_to_diagnostic; use crate::types::attribute_write::{AssignmentAttributeMembers, assignment_attribute_members}; -use crate::types::call::bind::MatchingOverloadIndex; +use crate::types::call::bind::{ + ArgumentTypeContext, CheckTypesMode, OverloadSet, requires_overload_evaluation, +}; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; use crate::types::class::{ClassLiteral, CodeGeneratorKind, MethodDecorator}; @@ -54,8 +58,8 @@ use crate::types::diagnostic::{ INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, INVALID_LEGACY_TYPE_VARIABLE, INVALID_NEWTYPE, INVALID_PARAMSPEC, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_MISSING_IMPLICIT_CALL, - POSSIBLY_MISSING_SUBMODULE, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, - UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, + POSSIBLY_MISSING_SUBMODULE, TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, + UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, hint_if_stdlib_attribute_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_delattr_call, report_bad_dunder_delete_call, report_call_to_abstract_method, report_cannot_pop_required_field_on_typed_dict, report_invalid_assignment, @@ -359,9 +363,6 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> { dataclass_field_specifiers: SmallVec<[Type<'db>; NUM_FIELD_SPECIFIERS_INLINE]>, } -/// An expression cache shared across builders during multi-inference. -type ExpressionCache<'db> = FxHashMap<(ExpressionNodeKey, TypeContext<'db>), Type<'db>>; - fn transparent_callable_decorator_result<'db>( db: &'db dyn Db, bindings: &Bindings<'db>, @@ -659,6 +660,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(extra) = &inference.extra { self.context.extend(&extra.diagnostics); self.extend_cycle_recovery(extra.cycle_recovery); + self.called_functions + .extend(extra.called_functions.iter().copied()); self.string_annotations .extend(extra.string_annotations.iter().copied()); self.expected_types @@ -683,6 +686,48 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + fn extend_expression_cache_entry(&mut self, inference: &FullExpressionCacheEntry<'db>) { + #[cfg(debug_assertions)] + assert_eq!(self.scope, inference.scope); + + self.expressions + .extend(inference.expressions.iter().map(|(key, ty)| (*key, *ty))); + self.context.extend(&inference.diagnostics); + self.extend_cycle_recovery(inference.cycle_recovery); + self.called_functions + .extend(inference.called_functions.iter().copied()); + self.string_annotations + .extend(inference.string_annotations.iter().copied()); + self.expected_types + .extend(inference.expected_types.iter().map(|(key, ty)| (*key, *ty))); + self.type_expression_flags.extend( + inference + .type_expression_flags + .iter() + .map(|(key, flags)| (*key, *flags)), + ); + + #[expect( + clippy::iter_over_hash_type, + reason = "constraints for distinct collection definitions are merged independently" + )] + for (collection_def, constraints) in &inference.collection_use_constraints { + self.collection_use_constraints + .entry(*collection_def) + .and_modify(|this| this.extend(constraints)) + .or_insert(constraints.clone()); + } + + if !matches!(self.region, InferenceRegion::Scope(..)) { + self.bindings.extend( + inference + .bindings + .iter() + .map(|(definition, ty)| (*definition, *ty)), + ); + } + } + fn extend_scope(&mut self, inference: &ScopeInference<'db>) { self.expressions.extend(inference.expressions.iter()); @@ -787,7 +832,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if self.expression_cache.is_some() { false } else { - self.expression_cache = Some(Rc::new(RefCell::new(FxHashMap::default()))); + self.expression_cache = Some(Rc::new(RefCell::new(ExpressionCache::default()))); true } } @@ -4979,11 +5024,67 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ) -> Result<(), CallErrorKind> { let db = self.db(); let constraints = ConstraintSetBuilder::new(); + let initial_argument_types = argument_types.clone(); + + // Keep track of which arguments match generic parameters. + let mut generic_arguments = SmallVec::<[bool; 8]>::with_capacity(argument_types.len()); + generic_arguments.resize(argument_types.len(), false); + + let mut max_typevar_occurrences = 0; + let mut has_generic_context = false; + let mut overload_candidates = OverloadSet::new(); - let has_generic_context = bindings - .iter_flat() - .flat_map(CallableBinding::overloads) - .any(|overload| overload.signature.generic_context.is_some()); + // Compute the upper bound on fixpoint iteration, based on the maximum number of inferable + // typevar occurrences across all overload candidates. Note that the set of overload candidates + // stays stable across all iterations. + bindings.visit_type_context_callables(&mut |binding| { + let candidate_overload_indices = binding.candidate_overload_indices(db, argument_types); + + has_generic_context |= candidate_overload_indices.iter().any(|&overload_index| { + binding.overloads()[overload_index] + .signature + .generic_context + .is_some() + }); + + for overload_index in &candidate_overload_indices { + let overload = &binding.overloads()[*overload_index]; + if overload.signature.generic_context.is_none() { + continue; + } + + let mut overload_typevar_occurrences = 0; + for (argument_index, is_generic) in generic_arguments.iter_mut().enumerate() { + if argument_types.is_variadic(argument_index) { + continue; + } + + let typevar_occurrences = + overload.typevar_occurrences_for_parameter(db, binding, argument_index); + *is_generic |= typevar_occurrences > 0; + overload_typevar_occurrences += typevar_occurrences; + } + + max_typevar_occurrences = max_typevar_occurrences.max(overload_typevar_occurrences); + } + + overload_candidates.push(candidate_overload_indices); + }); + + let generic_arguments: SmallVec<_> = generic_arguments + .into_iter() + .enumerate() + .filter_map(|(index, is_generic)| is_generic.then_some(index)) + .collect(); + + // Enable the expression cache if we are going to perform multi-inference. + let teardown_expression_cache = if !generic_arguments.is_empty() + || requires_overload_evaluation(&overload_candidates) + { + self.setup_expression_cache() + } else { + false + }; // If the type context is a union, attempt to narrow to a specific element. let narrow_targets = call_expression_tcx @@ -5014,27 +5115,39 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut speculative_bindings = bindings.clone(); let mut speculative_builder = self.speculate(); + let mut speculative_argument_types = initial_argument_types.clone(); // Attempt to infer the argument types using the narrowed type context. - speculative_builder.infer_all_argument_types( - ast_arguments.clone(), - argument_types, - infer_argument_ty, - bindings, - narrowed_tcx, - ); - - // Ensure the argument types match their annotated types. - if speculative_bindings - .check_types_impl( - db, + // + // If there are matching generic parameters on any overload, we perform fixpoint + // iteration to allow call arguments to contribute type context constraints to + // other siblings. + let result = if !generic_arguments.is_empty() { + speculative_builder.infer_and_check_argument_types_unified( + &ast_arguments, + &mut speculative_argument_types, + infer_argument_ty, + &mut speculative_bindings, &constraints, - argument_types, narrowed_tcx, - &self.dataclass_field_specifiers, + &generic_arguments, + max_typevar_occurrences, + &overload_candidates, ) - .is_err() - { + } else { + speculative_builder.infer_and_check_argument_types_simple( + ast_arguments.clone(), + &mut speculative_argument_types, + &initial_argument_types, + infer_argument_ty, + &mut speculative_bindings, + &constraints, + narrowed_tcx, + &overload_candidates, + ) + }; + + if result.is_err() { return None; } @@ -5051,135 +5164,438 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // Successfully narrowed to an element of the union. + *bindings = speculative_bindings; + *argument_types = speculative_argument_types; self.extend(speculative_builder); - Some(bindings.check_types_impl( - db, - &constraints, - argument_types, - narrowed_tcx, - &self.dataclass_field_specifiers, - )) + + Some(result) }; // Prefer the declared type of generic classes or callables when narrowing. // // Splitting up this loop is not necessary for correctness, but leads to a slight // performance improvement. - for narrowed_ty in narrow_targets - .iter() - .filter(|ty| ty.may_prefer_declared_type(db)) - { - if let Some(result) = try_narrow(*narrowed_ty) { - return result; - } - } - for narrowed_ty in narrow_targets - .iter() - .filter(|ty| !ty.may_prefer_declared_type(db)) - { + for narrowed_ty in std::iter::chain( + narrow_targets + .iter() + .filter(|ty| ty.may_prefer_declared_type(db)), + narrow_targets + .iter() + .filter(|ty| !ty.may_prefer_declared_type(db)), + ) { if let Some(result) = try_narrow(*narrowed_ty) { + if teardown_expression_cache { + self.teardown_expression_cache(); + } + return result; } } + *argument_types = initial_argument_types.clone(); + // Infer against the entire union as a fallback. // // TODO: We could also attempt an inference without type context, but this // leads to similar performance issues. + let result = if !generic_arguments.is_empty() { + self.infer_and_check_argument_types_unified( + &ast_arguments, + argument_types, + infer_argument_ty, + bindings, + &constraints, + call_expression_tcx, + &generic_arguments, + max_typevar_occurrences, + &overload_candidates, + ) + } else { + self.infer_and_check_argument_types_simple( + ast_arguments, + argument_types, + &initial_argument_types, + infer_argument_ty, + bindings, + &constraints, + call_expression_tcx, + &overload_candidates, + ) + }; + + if teardown_expression_cache { + self.teardown_expression_cache(); + } + + result + } + + #[expect(clippy::too_many_arguments)] + fn infer_and_check_argument_types_simple<'call>( + &mut self, + ast_arguments: ArgumentsIter<'_>, + argument_types: &mut CallArguments<'call, 'db>, + baseline_argument_types: &CallArguments<'call, 'db>, + infer_argument_ty: &mut dyn FnMut(&mut Self, ArgExpr<'db, '_>) -> Type<'db>, + bindings: &mut Bindings<'db>, + constraints: &ConstraintSetBuilder<'db>, + call_expression_tcx: TypeContext<'db>, + candidates: &OverloadSet, + ) -> Result<(), CallErrorKind> { + let requires_overload_evaluation = requires_overload_evaluation(candidates); + let arguments_tcx = self.collect_call_arguments_type_context( + baseline_argument_types, + bindings, + requires_overload_evaluation.then_some(candidates), + constraints, + call_expression_tcx, + ); + + // If we are not inferring against multiple overloads, we can infer the arguments + // and check the binding directly. + if !requires_overload_evaluation { + self.infer_all_argument_types( + ast_arguments, + argument_types, + &arguments_tcx, + infer_argument_ty, + CallArgumentInferenceMode::Commit, + ); + + return bindings.check_types_impl( + self.db(), + constraints, + argument_types, + call_expression_tcx, + &self.dataclass_field_specifiers, + CheckTypesMode::Finalize, + ); + } + + // Otherwise, we first infer the argument types speculatively. + let mut speculative_builder = self.speculate(); + speculative_builder.infer_all_argument_types( + ast_arguments.clone(), + argument_types, + &arguments_tcx, + infer_argument_ty, + // If there are multiple matching overloads, we will re-infer with the final set + // of matching overloads after overload evaluation, and so can avoid the default + // inference here. + CallArgumentInferenceMode::Speculate, + ); + + let result = bindings.check_types_impl( + self.db(), + constraints, + argument_types, + call_expression_tcx, + &self.dataclass_field_specifiers, + CheckTypesMode::Finalize, + ); + + let checked_argument_types = argument_types.clone(); + *argument_types = baseline_argument_types.clone(); + + // And re-infer argument types after overload evaluation, ensuring that only + // inferred types and diagnostics from matching overloads are preserved. + let arguments_tcx = self.collect_call_arguments_type_context( + &checked_argument_types, + bindings, + None, + constraints, + call_expression_tcx, + ); self.infer_all_argument_types( ast_arguments, argument_types, + &arguments_tcx, infer_argument_ty, + CallArgumentInferenceMode::Commit, + ); + self.union_expected_types(&speculative_builder.expected_types); + + result + } + + /// Infer generic call arguments under fixpoint iteration, allowing arguments to contribute + /// type context constraints to other siblings. + #[expect(clippy::too_many_arguments)] + fn infer_and_check_argument_types_unified( + &mut self, + ast_arguments: &ArgumentsIter<'_>, + argument_types: &mut CallArguments<'_, 'db>, + infer_argument_ty: &mut dyn FnMut(&mut Self, ArgExpr<'db, '_>) -> Type<'db>, + bindings: &mut Bindings<'db>, + constraints: &ConstraintSetBuilder<'db>, + call_expression_tcx: TypeContext<'db>, + generic_arguments: &SmallVec<[usize; 4]>, + typevar_occurrences: usize, + candidates: &OverloadSet, + ) -> Result<(), CallErrorKind> { + let db = self.db(); + let requires_overload_evaluation = requires_overload_evaluation(candidates); + + let mut arguments_tcx = self.collect_call_arguments_type_context( + argument_types, bindings, + Some(candidates), + constraints, call_expression_tcx, ); - bindings.check_types_impl( + let mut iteration = 0; + let mut next_bindings = bindings.clone(); + let mut prev_argument_types = argument_types.clone(); + + let (converged_builder, converged_argument_types) = loop { + let mut next_argument_types = argument_types.clone(); + + // Infer the argument types for the current iteration. + let mut speculative_builder = self.speculate(); + speculative_builder.infer_all_argument_types( + ast_arguments.clone(), + &mut next_argument_types, + &arguments_tcx, + infer_argument_ty, + if requires_overload_evaluation { + // If there are multiple matching overloads, we will re-infer with the final set + // of matching overloads after overload evaluation, and so can avoid the default + // inference here. + CallArgumentInferenceMode::Speculate + } else { + CallArgumentInferenceMode::Commit + }, + ); + + let inferred_types_converged = next_argument_types + .inferred_types_equal_at(&prev_argument_types, generic_arguments); + + // If the inferred types have converged, and already evaluated the bindings from the + // previous iteration, we are done. + if iteration > 0 && inferred_types_converged { + break (speculative_builder, next_argument_types); + } + + // Otherwise, we have to evaluate the bindings against the newly inferred types. + next_bindings = bindings.clone(); + let _ = next_bindings.check_types_impl( + db, + constraints, + &next_argument_types, + call_expression_tcx, + &self.dataclass_field_specifiers, + CheckTypesMode::Provisional, + ); + + // The number of occurrences of inferable typevars forms an upper bound for the number + // of fixpoint iterations, and so if the types have converged, or we have reached the + // upper bound, we are done. + if inferred_types_converged || iteration == typevar_occurrences { + break (speculative_builder, next_argument_types); + } + + // Collect the argument constraints based on the newly inferred types. + let next_arguments_tcx = self.collect_call_arguments_type_context( + &next_argument_types, + &next_bindings, + Some(candidates), + constraints, + call_expression_tcx, + ); + + // If the argument constraints have converged, the inferred types will be identical, + // and so we can exit early. + if generic_arguments + .iter() + .all(|&index| arguments_tcx.get(index) == next_arguments_tcx.get(index)) + { + break (speculative_builder, next_argument_types); + } + + iteration += 1; + arguments_tcx = next_arguments_tcx; + prev_argument_types = next_argument_types; + }; + + // Discard any non-matching constructors overloads now that the inferred types have converged. + let result = next_bindings.finalize_argument_inference( db, - &constraints, - argument_types, - call_expression_tcx, + &converged_argument_types, &self.dataclass_field_specifiers, - ) + ); + + // If the set of candidate bindings contained multiple matching overloads, re-infer the argument + // types against the final set of matching overloads, such that only the relevant diagnostics + // and inferred types are preserved. + if requires_overload_evaluation { + let arguments_tcx = self.collect_call_arguments_type_context( + &converged_argument_types, + &next_bindings, + None, + constraints, + call_expression_tcx, + ); + + self.infer_all_argument_types( + ast_arguments.clone(), + argument_types, + &arguments_tcx, + infer_argument_ty, + CallArgumentInferenceMode::Commit, + ); + + self.union_expected_types(&converged_builder.expected_types); + } else { + // Otherwise, we can simply use the newly inferred types. + *argument_types = converged_argument_types; + self.extend(converged_builder); + } + + *bindings = next_bindings; + result } - /// Infer the argument types for all bindings. - /// - /// Note that this method may infer the type of a given argument expression multiple times with - /// distinct type context. The provided `MultiInferenceState` can be used to dictate multi-inference - /// behavior. - fn infer_all_argument_types<'bindings>( - &mut self, - ast_arguments: ArgumentsIter<'_>, - arguments_types: &mut CallArguments<'_, 'db>, - infer_argument_ty: &mut dyn FnMut(&mut Self, ArgExpr<'db, '_>) -> Type<'db>, + /// Collects the type contexts used to infer the arguments of a call expression. + fn collect_call_arguments_type_context<'bindings>( + &self, + argument_types: &CallArguments<'_, 'db>, bindings: &'bindings Bindings<'db>, + candidates: Option<&'bindings OverloadSet>, + constraints: &ConstraintSetBuilder<'db>, call_expression_tcx: TypeContext<'db>, - ) { + ) -> Vec>> { + type OverloadsWithBinding<'a, 'db> = Vec<( + &'a Binding<'db>, + &'a CallableBinding<'db>, + Option>, + )>; + fn add_overloads_from_binding<'a, 'db>( - overloads_with_binding: &mut Vec<(&'a Binding<'db>, &'a CallableBinding<'db>)>, + db: &'db dyn Db, + overloads_with_binding: &mut OverloadsWithBinding<'a, 'db>, binding: &'a CallableBinding<'db>, + constraints: &ConstraintSetBuilder<'db>, + call_expression_tcx: TypeContext<'db>, ) { - match binding.matching_overload_index() { - MatchingOverloadIndex::Single(_) | MatchingOverloadIndex::Multiple(_) => { - overloads_with_binding.extend( - binding - .matching_overloads() - .map(|(_, overload)| (overload, binding)), + let mut matching_overloads = binding.matching_overloads().peekable(); + if matching_overloads.peek().is_some() { + overloads_with_binding.extend(matching_overloads.map(|(_, overload)| { + let specialization = overload.argument_type_context_specialization( + db, + constraints, + call_expression_tcx, ); - } + + (overload, binding, specialization) + })); + } else if let Some(overload) = binding.best_failing_overload() { + let specialization = overload.argument_type_context_specialization( + db, + constraints, + call_expression_tcx, + ); // If there is a single overload that does not match, we still infer the argument // types for better diagnostics. - MatchingOverloadIndex::None => { - if let [overload] = binding.overloads() { - overloads_with_binding.push((overload, binding)); - } - } + overloads_with_binding.push((overload, binding, specialization)); } } let db = self.db(); - let constraints = ConstraintSetBuilder::new(); - - let mut overloads_with_binding: Vec<(&Binding<'db>, &CallableBinding<'db>)> = Vec::new(); - bindings.visit_type_context_callables(&mut |binding| { - add_overloads_from_binding(&mut overloads_with_binding, binding); - }); + // Collect the set of candidate overloads and bindings. + let mut overloads_with_binding: OverloadsWithBinding = Vec::new(); + if let Some(candidates) = candidates { + bindings.visit_overload_set(candidates, &mut |overload, binding| { + let specialization = overload.argument_type_context_specialization( + db, + constraints, + call_expression_tcx, + ); - // A keyword argument matched to `**P.kwargs` can appear before the keyword argument that - // binds `P`, e.g. `wrapper(TagSet=[...], func=put_object)`. Seed those binder argument - // types first so the normal ParamSpec context path below is not source-order dependent. - for (argument_index, ast_argument) in ast_arguments.clone().enumerate() { - if ast_argument.is_variadic() { - continue; - } - let ast_argument = ast_argument.value(); + overloads_with_binding.push((overload, binding, specialization)); + }); + } else { + bindings.visit_type_context_callables(&mut |binding| { + add_overloads_from_binding( + db, + &mut overloads_with_binding, + binding, + constraints, + call_expression_tcx, + ); + }); + } - let mut inferred_declared_types = FxHashSet::default(); - for declared_type in overloads_with_binding - .iter() - .filter_map(|(overload, binding)| { - overload.paramspec_binder_parameter_type(db, binding, argument_index) - }) - { - if !inferred_declared_types.insert(declared_type) { - continue; + // Collect the type context of each argument from each matching overload. + (0..argument_types.len()) + .map(|argument_index| { + if argument_types.is_variadic(argument_index) { + return None; } - let mut speculative_builder = self.speculate_without_diagnostics(); - let inferred_ty = infer_argument_ty( - &mut speculative_builder, - ( + let parameter_tcx = + |overload: &Binding<'db>, binding: &CallableBinding<'db>, specialization| { + overload.argument_type_context( + db, + constraints, + binding, + argument_types, + argument_index, + call_expression_tcx, + specialization, + ) + }; + + let parameter_contexts = if let Ok((overload, binding, specialization)) = + overloads_with_binding.iter().exactly_one() + { + MatchingArgumentTypeContext::Unique(parameter_tcx( + overload, + binding, + *specialization, + )) + } else { + MatchingArgumentTypeContext::Many( + overloads_with_binding + .iter() + .map(|(overload, binding, specialization)| { + parameter_tcx(overload, binding, *specialization) + }) + .collect(), + ) + }; + + Some(parameter_contexts) + }) + .collect() + } + + /// Infers every call argument using the provided set of type context. + fn infer_all_argument_types( + &mut self, + ast_arguments: ArgumentsIter<'_>, + argument_types: &mut CallArguments<'_, 'db>, + arguments_tcx: &[Option>], + infer_argument_ty: &mut dyn FnMut(&mut Self, ArgExpr<'db, '_>) -> Type<'db>, + mode: CallArgumentInferenceMode, + ) { + let insert_argument_ty = + |argument_index, + inferred_ty, + argument_tcx: &Option>, + argument_types: &mut CallArguments<'_, 'db>| { + if let Some(argument_tcx) = argument_tcx { + argument_tcx.insert_inferred_type_into( + argument_types, argument_index, - ast_argument, - TypeContext::new(Some(declared_type)), - ), - ); - arguments_types.insert_type(argument_index, declared_type, inferred_ty); - } - } + inferred_ty, + ); + } else { + argument_types.insert_type(argument_index, TypeContext::default(), inferred_ty); + } + }; for (argument_index, ast_argument) in ast_arguments.enumerate() { // Splatted arguments are inferred before parameter matching to @@ -5191,109 +5607,89 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let ast_argument = ast_argument.value(); - let parameter_tcx = |overload: &'bindings Binding<'db>, - binding: &CallableBinding<'db>| { - overload.argument_type_context( - db, - &constraints, - binding, - arguments_types, - argument_index, - call_expression_tcx, - ) + let Some(argument_tcx) = &arguments_tcx[argument_index] else { + continue; }; - // If there is only a single binding and overload, we can infer the argument directly with - // the unique parameter type annotation. - if let Ok((overload, binding)) = overloads_with_binding.iter().exactly_one() { - let parameter_context = parameter_tcx(overload, binding); + match argument_tcx { + MatchingArgumentTypeContext::Unique(argument_tcx) => { + let tcx = argument_tcx + .map(ArgumentTypeContext::type_context) + .unwrap_or_default(); + let inferred_ty = infer_argument_ty(self, (argument_index, ast_argument, tcx)); + insert_argument_ty(argument_index, inferred_ty, argument_tcx, argument_types); + } + + MatchingArgumentTypeContext::Many(argument_tcx) => { + let mut inferred_by_cache_key = FxHashMap::default(); + + // If there are multiple applicable type contexts and we are not in + // speculative mode, infer the argument without type context as the + // default inference. + if mode.requires_default_inference() { + let inferred_ty = infer_argument_ty( + self, + (argument_index, ast_argument, TypeContext::default()), + ); - if let Some(parameter_context) = parameter_context { - let inferred_ty = infer_argument_ty( - self, - ( + argument_types.insert_type( argument_index, - ast_argument, - parameter_context.type_context(), - ), - ); - parameter_context.insert_inferred_type( - arguments_types, - argument_index, - inferred_ty, - ); - } else { - let inferred_ty = infer_argument_ty( - self, - (argument_index, ast_argument, TypeContext::default()), - ); - arguments_types.insert_type( - argument_index, - TypeContext::default(), - inferred_ty, - ); - } - } else { - // Infer the type of each argument once with each distinct parameter type as type context. - let parameter_contexts: Vec<_> = overloads_with_binding - .iter() - .filter_map(|(overload, binding)| parameter_tcx(overload, binding)) - .collect(); + TypeContext::default(), + inferred_ty, + ); + + inferred_by_cache_key.insert(None, inferred_ty); + } + + // Cache expressions inferred across speculative inference attempts. + // + // This is important to avoid exponential blowup for deeply nested generic calls, + // as inner expressions are repeatedly inferred with the same type context. + let teardown_expression_cache = self.setup_expression_cache(); + + for argument_tcx in argument_tcx { + let inference_cache_key = + argument_tcx.map(ArgumentTypeContext::inference_cache_key); + if let Some(inferred_ty) = + inferred_by_cache_key.get(&inference_cache_key).copied() + { + // Even when the inference cache key is identical, this overload may later + // look up the inferred type through a different original `ParamSpec` + // annotation, so insert through its own context. + insert_argument_ty( + argument_index, + inferred_ty, + argument_tcx, + argument_types, + ); - // We perform inference once without any type context, emitting any diagnostics that are unrelated - // to bidirectional type inference. - let default_ty = - infer_argument_ty(self, (argument_index, ast_argument, TypeContext::default())); - arguments_types.insert_type(argument_index, TypeContext::default(), default_ty); + continue; + } - let mut inferred_by_cache_key = FxHashMap::default(); + let tcx = argument_tcx + .map(ArgumentTypeContext::type_context) + .unwrap_or_default(); - // Cache expressions inferred across speculative inference attempts. - // - // This is important to avoid exponential blowup for deeply nested generic calls, - // as inner expressions are repeatedly inferred with the same type context. - let teardown = self.setup_expression_cache(); - - for parameter_context in parameter_contexts { - let inference_cache_key = parameter_context.inference_cache_key(); - if let Some(inferred_ty) = - inferred_by_cache_key.get(&inference_cache_key).copied() - { - // Even when the inference cache key is identical, this overload may later - // look up the inferred type through a different original `ParamSpec` - // annotation, so insert through its own context. - parameter_context.insert_inferred_type( - arguments_types, - argument_index, - inferred_ty, + let mut speculative_builder = self.speculate(); + let inferred_ty = infer_argument_ty( + &mut speculative_builder, + (argument_index, ast_argument, tcx), ); - continue; - } - // We use a speculative builder to silence any diagnostics emitted during multi-inference, as the - // type context is only used as a hint to infer a more assignable argument type, and should not lead - // to diagnostics for non-matching overloads. - let mut speculative_builder = self.speculate_without_diagnostics(); - let inferred_ty = infer_argument_ty( - &mut speculative_builder, - ( + insert_argument_ty( argument_index, - ast_argument, - parameter_context.type_context(), - ), - ); + inferred_ty, + argument_tcx, + argument_types, + ); - inferred_by_cache_key.insert(inference_cache_key, inferred_ty); - self.union_expected_types(&speculative_builder.expected_types); - parameter_context.insert_inferred_type( - arguments_types, - argument_index, - inferred_ty, - ); - } + inferred_by_cache_key.insert(inference_cache_key, inferred_ty); + self.union_expected_types(&speculative_builder.expected_types); + } - if teardown { - self.teardown_expression_cache(); + if teardown_expression_cache { + self.teardown_expression_cache(); + } } } } @@ -5383,7 +5779,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { peer_ty: Option>, mut infer_expression: impl FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, ) -> Type<'db> { - let peer_tcx = if allows_collection_literal_peer_context(tcx) + let peer_tcx = if is_empty_collection_type_context(tcx) && is_collection_literal(expression) && let Some(peer_ty) = peer_ty { @@ -5421,31 +5817,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { standalone_expression: Expression<'db>, tcx: TypeContext<'db>, ) -> Type<'db> { - // `infer_expression_types` cache inference results for a given expression and type - // context. For expressions that are not directly affected by type context, we defer - // applying the type context until after the Salsa query runs, allowing us to reuse - // the memoized query result during multi-inference. - let inference_tcx = if can_defer_type_context(expression) { - TypeContext::default() - } else { - tcx - }; - - let types = infer_expression_types(self.db(), standalone_expression, inference_tcx); + let types = infer_expression_types(self.db(), standalone_expression, tcx); self.extend_expression(types); // Instead of calling `self.expression_type(expr)` after extending here, we get // the result from `types` directly because we might be in cycle recovery where // `types.cycle_fallback_type` is `Some(fallback_ty)`, which we can retrieve by // using `expression_type` on `types`: - let ty = types.expression_type(expression); - if inference_tcx == tcx { - ty - } else { - let ty = self.apply_type_context(expression, ty, tcx); - self.expressions.insert(expression.into(), ty); - ty - } + types.expression_type(expression) } /// Infer the type of an expression. @@ -5454,27 +5833,60 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { expression: &ast::Expr, tcx: TypeContext<'db>, ) -> Type<'db> { - if let Some(ty) = self.expression_cache.as_ref().and_then(|expression_cache| { - expression_cache - .borrow() - .get(&(expression.into(), tcx)) - .copied() - }) { - self.store_expression_type(expression, ty); - return ty; + let Some(expression_cache) = &self.expression_cache else { + return self.infer_expression_uncached(expression, tcx); + }; + + // See if we already have a cached entry for this expression. + let expression_key = expression.into(); + let cache_entry = expression_cache.borrow().get(expression_key, tcx).cloned(); + + match cache_entry { + Some(ExpressionCacheEntry::Small(ty)) => { + self.store_expression_type(expression, ty); + ty + } + + Some(ExpressionCacheEntry::Full(inference)) => { + let ty = inference.expression_type(expression_key); + self.extend_expression_cache_entry(&inference); + ty + } + + _ => { + // The expression is uncached, infer it independently and cache the inference results. + let mut speculative_builder = self.speculate(); + let ty = speculative_builder.infer_expression_uncached(expression, tcx); + let inference = speculative_builder.into_expression_cache_entry(); + + let cached = if inference.is_single_expression(expression_key, ty) { + self.store_expression_type(expression, ty); + ExpressionCacheEntry::Small(ty) + } else { + self.extend_expression_cache_entry(&inference); + ExpressionCacheEntry::Full(Rc::new(inference)) + }; + + if let Some(expression_cache) = &self.expression_cache { + expression_cache + .borrow_mut() + .insert(expression_key, tcx, cached); + } + + ty + } } + } + fn infer_expression_uncached( + &mut self, + expression: &ast::Expr, + tcx: TypeContext<'db>, + ) -> Type<'db> { if let Some(target) = tcx.annotation && let Some(ty) = self.infer_type_form_contextual_expression(expression, target) { self.store_expression_type(expression, ty); - - if let Some(expression_cache) = &self.expression_cache { - expression_cache - .borrow_mut() - .insert((expression.into(), tcx), ty); - } - return ty; } @@ -5490,7 +5902,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { expression: &ast::Expr, tcx: TypeContext<'db>, ) -> Type<'db> { - let mut ty = match expression { + let ty = match expression { ast::Expr::NoneLiteral(ast::ExprNoneLiteral { range: _, node_index: _, @@ -5516,8 +5928,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_dict_comprehension_expression(dictcomp, tcx) } ast::Expr::SetComp(setcomp) => self.infer_set_comprehension_expression(setcomp, tcx), - ast::Expr::Name(name) => self.infer_name_expression(name), - ast::Expr::Attribute(attribute) => self.infer_attribute_expression(attribute), + ast::Expr::Name(name) => { + let ty = self.infer_name_expression(name); + tcx.annotation.map_or(ty, |target| { + self.specialize_generic_class_from_context(ty, target) + }) + } + ast::Expr::Attribute(attribute) => { + let ty = self.infer_attribute_expression(attribute); + tcx.annotation.map_or(ty, |target| { + self.specialize_generic_class_from_context(ty, target) + }) + } ast::Expr::UnaryOp(unary_op) => self.infer_unary_expression(unary_op), ast::Expr::BinOp(binary) => self.infer_binary_expression(binary, tcx), ast::Expr::BoolOp(bool_op) => self.infer_boolean_expression(bool_op, tcx), @@ -5541,15 +5963,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }; - ty = self.apply_type_context(expression, ty, tcx); + let ty = self.apply_type_context(expression, ty, tcx); self.store_expression_type(expression, ty); - - if let Some(expression_cache) = &self.expression_cache { - expression_cache - .borrow_mut() - .insert((expression.into(), tcx), ty); - } - ty } @@ -5560,12 +5975,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { mut ty: Type<'db>, tcx: TypeContext<'db>, ) -> Type<'db> { - if matches!(expression, ast::Expr::Name(_) | ast::Expr::Attribute(_)) - && let Some(target) = tcx.annotation - { - ty = self.specialize_generic_class_from_context(ty, target); - } - // Avoid promoting explicitly annotated literal values. if let Type::LiteralValue(literal) = ty && let Some(tcx) = tcx.annotation @@ -6238,7 +6647,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut narrowed_tys = Vec::new(); let mut item_types = FxHashMap::default(); // Reuse nested expressions that receive the same field context across candidates. - let teardown = self.setup_expression_cache(); + let teardown_expression_cache = self.setup_expression_cache(); for typed_dict in typed_dicts { // Suppress diagnostics for discarded candidates. A mixed union like // `TypedDict | dict[str, Any]` should remain quiet when the dict arm accepts @@ -6252,7 +6661,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { item_types.clear(); } - if teardown { + if teardown_expression_cache { self.teardown_expression_cache(); } @@ -6795,10 +7204,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { builder.build_with(generic_context, |current_typevar, bounds| { let lower = bounds?.lower?; - let lower = if tcx.annotation.is_none() { - // Constraints learned from later collection uses should follow the same - // promotion policy as literal elements: promote element literal types in - // invariant position unless an explicit annotation made them unpromotable. + let lower = if is_empty_collection_type_context(tcx) { + // Constraints learned from later collection uses follow the same promotion + // policy as literal elements: promote element literal types in invariant + // position unless an explicit annotation made them unpromotable. lower.promote(self.db()) } else { lower @@ -6812,7 +7221,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { lower }; - let lower = if elt_tcx_constraints.is_empty() { + let lower = if is_empty_collection_type_context(tcx) { lower // Promote singleton types to `T | Unknown` in inferred type parameters, // so that e.g. `[None]` is inferred as `list[None | Unknown]`. @@ -7346,7 +7755,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let test_ty = self.infer_maybe_standalone_expression(test, TypeContext::default()); let (body_ty, orelse_ty) = - if allows_collection_literal_peer_context(tcx) && is_collection_literal(body) { + if is_empty_collection_type_context(tcx) && is_collection_literal(body) { // Infer the peer branch first so the body can use its type as context. let orelse_ty = self.infer_expression(orelse, tcx); let body_ty = self.infer_expression_with_collection_literal_peer_context( @@ -9958,7 +10367,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = bool_op; // The first operand has no peers. If no later operand is a collection literal, // accumulating prior types cannot affect inference. - let track_peer_types = allows_collection_literal_peer_context(tcx) + let track_peer_types = is_empty_collection_type_context(tcx) && values.iter().skip(1).any(is_collection_literal); self.infer_chained_boolean_types( *op, @@ -10148,13 +10557,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { pub(super) fn finish_expression(mut self) -> ExpressionInference<'db> { self.infer_region(); + self.into_expression_inference() + } + + /// Consume the results already collected by this builder without inferring its region. + fn into_expression_inference(self) -> ExpressionInference<'db> { + let region = self.region; + self.into_expression_cache_entry() + .into_expression_inference(region) + } + /// Consume the results already collected by this builder without compacting them. + fn into_expression_cache_entry(self) -> FullExpressionCacheEntry<'db> { let Self { context, expressions, qualifiers: _, type_expression_flags, - mut collection_use_constraints, + collection_use_constraints, string_annotations, expected_types, scope, @@ -10173,13 +10593,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { reachability_cache: _, typevar_binding_context: _, deferred_state: _, - called_functions: _, + called_functions, index: _, region: _, return_types_and_ranges: _, } = self; - let diagnostics = context.finish(); + let diagnostics = context.finish_uncompacted(); let _ = scope; assert!( @@ -10191,37 +10611,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "Expression region can't have deferred definitions" ); - let extra = - (!string_annotations.is_empty() - || !type_expression_flags.is_empty() - || !collection_use_constraints.is_empty() - || !expected_types.is_empty() - || cycle_recovery.is_some() - || !bindings.is_empty() - || !diagnostics.is_empty()).then(|| { - if bindings.len() > 20 { - tracing::debug!( - "Inferred expression region `{:?}` contains {} bindings. Lookups by linear scan might be slow.", - self.region, - bindings.len() - ); - } - - collection_use_constraints.shrink_to_fit(); - Box::new(ExpressionInferenceExtra { - string_annotations: FrozenSet::from(string_annotations), - expected_types: FrozenMap::from(expected_types), - type_expression_flags: FrozenMap::from(type_expression_flags), - bindings: bindings.into_boxed_slice(), - diagnostics, - cycle_recovery, - collection_use_constraints - }) - }); - - ExpressionInference { - expressions: FrozenMap::from(expressions), - extra, + FullExpressionCacheEntry { + expressions, + type_expression_flags, + collection_use_constraints, + string_annotations, + expected_types, + bindings, + diagnostics, + called_functions, + cycle_recovery, #[cfg(debug_assertions)] scope, } @@ -10644,6 +11043,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } /// Returns a speculative builder that does not construct diagnostics. + /// + /// Note that this method may lead to lost diagnostics if the expression cache + /// is enabled, as future multi-inference attempts may reuse inference results + /// in which diagnostics were suppressed. fn speculate_without_diagnostics(&self) -> Self { let mut builder = self.speculate(); builder.context.suppress_diagnostics(); @@ -10722,6 +11125,169 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } +/// An expression cache shared across builders during multi-inference. +/// +/// This provides a cheap way of reusing inference results without the overhead +/// of Salsa standalone expressions. +#[derive(Default)] +struct ExpressionCache<'db> { + entries: FxHashMap>, +} + +impl<'db> ExpressionCache<'db> { + fn get( + &self, + expression: ExpressionNodeKey, + tcx: TypeContext<'db>, + ) -> Option<&ExpressionCacheEntry<'db>> { + self.entries.get(&expression)?.get(tcx) + } + + fn insert( + &mut self, + expression: ExpressionNodeKey, + tcx: TypeContext<'db>, + value: ExpressionCacheEntry<'db>, + ) { + match self.entries.entry(expression) { + hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().insert(tcx, value); + } + hash_map::Entry::Vacant(entry) => { + entry.insert(ExpressionCacheEntries::Single(tcx, value)); + } + } + } +} + +/// The inferred types of a given expression, keyed by type context. +enum ExpressionCacheEntries<'db> { + Single(TypeContext<'db>, ExpressionCacheEntry<'db>), + Many(FxHashMap, ExpressionCacheEntry<'db>>), +} + +impl<'db> ExpressionCacheEntries<'db> { + fn get(&self, tcx: TypeContext<'db>) -> Option<&ExpressionCacheEntry<'db>> { + match self { + Self::Single(cached_tcx, value) if *cached_tcx == tcx => Some(value), + Self::Single(_, _) => None, + Self::Many(values) => values.get(&tcx), + } + } + + fn insert(&mut self, tcx: TypeContext<'db>, value: ExpressionCacheEntry<'db>) { + if let Self::Single(cached_tcx, cached_value) = self + && *cached_tcx == tcx + { + *cached_value = value; + return; + } + + let previous = std::mem::replace(self, Self::Many(FxHashMap::default())); + *self = match previous { + Self::Single(cached_tcx, cached_value) => Self::Many(FxHashMap::from_iter([ + (cached_tcx, cached_value), + (tcx, value), + ])), + Self::Many(mut values) => { + values.insert(tcx, value); + Self::Many(values) + } + }; + } +} + +/// The inferred types for an expression region under a given type context. +#[derive(Clone)] +enum ExpressionCacheEntry<'db> { + Small(Type<'db>), + Full(Rc>), +} + +/// The full inference results for an expression region. +/// +/// Unlike [`ExpressionInference`], this type is short-lived, and avoids the cost of compaction +/// that is otherwise performed for Salsa results. +struct FullExpressionCacheEntry<'db> { + expressions: FxHashMap>, + type_expression_flags: FxHashMap, + collection_use_constraints: CollectionUseConstraints<'db>, + string_annotations: FxHashSet, + expected_types: FxHashMap>, + bindings: VecMap, Type<'db>>, + diagnostics: TypeCheckDiagnostics, + called_functions: FxIndexSet>, + cycle_recovery: Option>, + #[cfg(debug_assertions)] + scope: ScopeId<'db>, +} + +impl<'db> FullExpressionCacheEntry<'db> { + fn expression_type(&self, expression: ExpressionNodeKey) -> Type<'db> { + self.expressions + .get(&expression) + .copied() + .or(self.cycle_recovery) + .unwrap_or_else(Type::unknown) + } + + fn is_single_expression(&self, expression: ExpressionNodeKey, ty: Type<'db>) -> bool { + self.expressions.len() == 1 + && self.expressions.get(&expression) == Some(&ty) + && self.type_expression_flags.is_empty() + && self.collection_use_constraints.is_empty() + && self.string_annotations.is_empty() + && self.expected_types.is_empty() + && self.bindings.is_empty() + && self.diagnostics.is_empty() + && self.called_functions.is_empty() + && self.cycle_recovery.is_none() + } + + fn into_expression_inference( + mut self, + region: InferenceRegion<'db>, + ) -> ExpressionInference<'db> { + let extra = (!self.string_annotations.is_empty() + || !self.type_expression_flags.is_empty() + || !self.collection_use_constraints.is_empty() + || !self.expected_types.is_empty() + || self.cycle_recovery.is_some() + || !self.bindings.is_empty() + || !self.called_functions.is_empty() + || !self.diagnostics.is_empty()) + .then(|| { + if self.bindings.len() > 20 { + tracing::debug!( + "Inferred expression region `{:?}` contains {} bindings. Lookups by linear scan might be slow.", + region, + self.bindings.len() + ); + } + + self.collection_use_constraints.shrink_to_fit(); + self.diagnostics.shrink_to_fit(); + Box::new(ExpressionInferenceExtra { + string_annotations: FrozenSet::from(self.string_annotations), + expected_types: FrozenMap::from(self.expected_types), + type_expression_flags: FrozenMap::from(self.type_expression_flags), + bindings: self.bindings.into_boxed_slice(), + diagnostics: self.diagnostics, + called_functions: self.called_functions.into_iter().collect(), + cycle_recovery: self.cycle_recovery, + collection_use_constraints: self.collection_use_constraints, + }) + }); + + ExpressionInference { + expressions: FrozenMap::from(self.expressions), + extra, + #[cfg(debug_assertions)] + scope: self.scope, + } + } +} + /// Manages the inference of a given expression. struct MultiInferenceGuard<'db, 'ast, 'infer> { infer_expr: @@ -10792,6 +11358,29 @@ impl Drop for MultiInferenceGuard<'_, '_, '_> { /// context. type ArgExpr<'db, 'ast> = (usize, &'ast ast::Expr, TypeContext<'db>); +#[derive(Clone, Copy)] +enum CallArgumentInferenceMode { + /// Infer against every candidate type context entirely speculatively. + Speculate, + + /// Commit a default inference without type context, if there are multiple + /// applicable type contexts. + Commit, +} + +impl CallArgumentInferenceMode { + fn requires_default_inference(self) -> bool { + matches!(self, Self::Commit) + } +} + +/// The set of type contexts to use when inferring a call-site argument, across all matching overloads. +#[derive(Debug, PartialEq, Eq)] +enum MatchingArgumentTypeContext<'db> { + Unique(Option>), + Many(Vec>>), +} + fn is_collection_literal(expression: &ast::Expr) -> bool { matches!( expression, @@ -10799,50 +11388,6 @@ fn is_collection_literal(expression: &ast::Expr) -> bool { ) } -/// Returns `true` if applying type context to the given expression may be deferred after inference. -/// -/// For example, list literals must be inferred with type context directly, as the type context may -/// influence the type assigned to an invariant generic type parameter. However, bare name expressions -/// may be inferred without type context, and later specialized after inference. -fn can_defer_type_context(expression: &ast::Expr) -> bool { - match expression { - ast::Expr::StringLiteral(_) - | ast::Expr::Tuple(_) - | ast::Expr::List(_) - | ast::Expr::Set(_) - | ast::Expr::Dict(_) - | ast::Expr::Generator(_) - | ast::Expr::ListComp(_) - | ast::Expr::DictComp(_) - | ast::Expr::SetComp(_) - | ast::Expr::BinOp(_) - | ast::Expr::BoolOp(_) - | ast::Expr::If(_) - | ast::Expr::Lambda(_) - | ast::Expr::Call(_) - | ast::Expr::Starred(_) - | ast::Expr::Await(_) => false, - - ast::Expr::NoneLiteral(_) - | ast::Expr::NumberLiteral(_) - | ast::Expr::BooleanLiteral(_) - | ast::Expr::BytesLiteral(_) - | ast::Expr::FString(_) - | ast::Expr::TString(_) - | ast::Expr::EllipsisLiteral(_) - | ast::Expr::Name(_) - | ast::Expr::Attribute(_) - | ast::Expr::UnaryOp(_) - | ast::Expr::Compare(_) - | ast::Expr::Subscript(_) - | ast::Expr::Slice(_) - | ast::Expr::Yield(_) - | ast::Expr::YieldFrom(_) - | ast::Expr::Named(_) - | ast::Expr::IpyEscapeCommand(_) => true, - } -} - /// Returns `true` if `tcx` cannot provide useful type context for a collection literal. /// /// During generic call argument inference, type variables that cannot yet be specialized are @@ -10857,7 +11402,7 @@ fn can_defer_type_context(expression: &ast::Expr) -> bool { /// This deliberately matches only the bare marker: a partially specialized context such as /// `list[UnspecializedTypeVar | int]` still carries useful collection structure and concrete type /// information. -fn allows_collection_literal_peer_context(tcx: TypeContext<'_>) -> bool { +fn is_empty_collection_type_context(tcx: TypeContext<'_>) -> bool { tcx.annotation .is_none_or(|annotation| annotation == Type::Dynamic(DynamicType::UnspecializedTypeVar)) } From 9a5220ecdbafa12e2f17a4dd061ea381ad549e9b Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Jul 2026 02:09:07 +0200 Subject: [PATCH 104/222] Reuse parser scratch buffers (#26798) ## Summary Use scratch buffers instead of allocating new `Vec`s and shrinking them when constructing the AST node. While this doesn't remove the need for copying the elements, it reduces the allocations within our parser (`shrink_to_fit` often needs to allocate a new buffer and move all elements). One shared scratch buffer per node type is sufficient, because our parser is recursive (more nested parse rule always push to the end, and parse function drain from the end as they complete). ## Performance Very consistent perf improvement ## Codspeed memory regression This can cause the parser's peak memory to increase, because we retain the scratch buffer allocation after parsing a clause completed. However, that memory is only temporary and released as soon as the parser completes. Most project-level memory reports show a memory reduction. It's only specific parser benchmarks that show a memory increase --- Cargo.lock | 1 + crates/ruff_python_parser/Cargo.toml | 1 + .../src/parser/expression.rs | 96 ++++++------- crates/ruff_python_parser/src/parser/mod.rs | 30 +++- .../src/parser/scratch_buffer.rs | 136 ++++++++++++++++++ .../src/parser/statement.rs | 114 ++++++++------- 6 files changed, 269 insertions(+), 109 deletions(-) create mode 100644 crates/ruff_python_parser/src/parser/scratch_buffer.rs diff --git a/Cargo.lock b/Cargo.lock index cb32832690..52e8a3ac14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3594,6 +3594,7 @@ dependencies = [ "bstr", "compact_str", "datatest-stable", + "drop_bomb", "get-size2", "insta", "itertools 0.15.0", diff --git a/crates/ruff_python_parser/Cargo.toml b/crates/ruff_python_parser/Cargo.toml index 480589a5a6..c88c116faf 100644 --- a/crates/ruff_python_parser/Cargo.toml +++ b/crates/ruff_python_parser/Cargo.toml @@ -20,6 +20,7 @@ ruff_text_size = { workspace = true, features = ["get-size"] } bitflags = { workspace = true } bstr = { workspace = true } compact_str = { workspace = true } +drop_bomb = { workspace = true } get-size2 = { workspace = true } memchr = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index e38529c1b7..118a372a1c 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -815,8 +815,8 @@ impl<'src> Parser<'src> { }; } - let mut args = vec![]; - let mut keywords = vec![]; + let args_snapshot = self.expr_scratch.snapshot(); + let keywords_snapshot = self.keyword_scratch.snapshot(); let mut seen_keyword_argument = false; // foo = 1 let mut seen_keyword_unpacking = false; // **foo @@ -826,7 +826,7 @@ impl<'src> Parser<'src> { if parser.eat(TokenKind::DoubleStar) { let value = parser.parse_conditional_expression_or_higher(); - keywords.push(ast::Keyword { + parser.keyword_scratch.push(ast::Keyword { arg: None, value: value.expr, range: parser.node_range(argument_start), @@ -916,7 +916,7 @@ impl<'src> Parser<'src> { let value = parser.parse_conditional_expression_or_higher(); - keywords.push(ast::Keyword { + parser.keyword_scratch.push(ast::Keyword { arg: Some(arg), value: value.expr, range: parser.node_range(argument_start), @@ -936,23 +936,19 @@ impl<'src> Parser<'src> { ); } } - // Reserve exactly one slot for the first positional argument, while - // avoiding any allocation for keyword-only calls. - if args.is_empty() { - args.reserve_exact(1); - } - args.push(parsed_expr.expr); + parser.expr_scratch.push(parsed_expr.expr); } } }); self.expect(TokenKind::Rpar); + let keywords = self.keyword_scratch.take_thin_vec(keywords_snapshot); let arguments = ast::Arguments { range: self.node_range(start), node_index: AtomicNodeIndex::NONE, - args: args.into_boxed_slice(), - keywords: keywords.into(), + args: self.expr_scratch.take(args_snapshot), + keywords, }; self.validate_arguments(&arguments, has_trailing_comma, context); @@ -1005,16 +1001,16 @@ impl<'src> Parser<'src> { // If there are more than one element in the slice, we need to create a tuple // expression to represent it. if self.eat(TokenKind::Comma) { - let mut slices = vec![slice]; + let slices_snapshot = self.expr_scratch.snapshot(); + self.expr_scratch.push(slice); self.parse_comma_separated_list(RecoveryContextKind::Slices, |parser| { - slices.push(parser.parse_slice()); + let slice = parser.parse_slice(); + parser.expr_scratch.push(slice); }); - slices.shrink_to_fit(); - slice = Expr::Tuple(ast::ExprTuple { - elts: slices, + elts: self.expr_scratch.take(slices_snapshot), ctx: ExprContext::Load, range: self.node_range(slice_start), parenthesized: false, @@ -1252,8 +1248,8 @@ impl<'src> Parser<'src> { ) -> ast::ExprBoolOp { self.bump(TokenKind::from(op)); - let mut values = Vec::with_capacity(2); - values.push(lhs); + let values_snapshot = self.expr_scratch.snapshot(); + self.expr_scratch.push(lhs); let mut progress = ParserProgress::default(); // Keep adding the expression to `values` until we see a different @@ -1263,17 +1259,15 @@ impl<'src> Parser<'src> { let parsed_expr = self.parse_binary_expression_or_higher(OperatorPrecedence::from(op), context); - values.push(parsed_expr.expr); + self.expr_scratch.push(parsed_expr.expr); if !self.eat(TokenKind::from(op)) { break; } } - values.shrink_to_fit(); - ast::ExprBoolOp { - values, + values: self.expr_scratch.take(values_snapshot), op, range: self.node_range(start), node_index: AtomicNodeIndex::NONE, @@ -1322,7 +1316,7 @@ impl<'src> Parser<'src> { ) -> ast::ExprCompare { self.bump_cmp_op(op); - let mut comparators = vec![]; + let comparators_snapshot = self.expr_scratch.snapshot(); let mut operators = vec![op]; let mut progress = ParserProgress::default(); @@ -1330,13 +1324,13 @@ impl<'src> Parser<'src> { loop { progress.assert_progressing(self); - comparators.push( - self.parse_binary_expression_or_higher( + let comparator = self + .parse_binary_expression_or_higher( OperatorPrecedence::ComparisonsMembershipIdentity, context, ) - .expr, - ); + .expr; + self.expr_scratch.push(comparator); let next_token = self.current_token_kind(); if matches!(next_token, TokenKind::In) && context.is_in_excluded() { @@ -1356,7 +1350,7 @@ impl<'src> Parser<'src> { ast::ExprCompare { left: Box::new(lhs), ops: operators.into_boxed_slice(), - comparators: comparators.into_boxed_slice(), + comparators: self.expr_scratch.take(comparators_snapshot), range: self.node_range(start), node_index: AtomicNodeIndex::NONE, } @@ -2455,20 +2449,20 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Comma); } - let mut elts = vec![first_element]; + let elts_snapshot = self.expr_scratch.snapshot(); + self.expr_scratch.push(first_element); self.parse_comma_separated_list(RecoveryContextKind::TupleElements(parenthesized), |p| { - elts.push(parse_func(p).expr); + let element = parse_func(p).expr; + p.expr_scratch.push(element); }); if parenthesized.is_yes() { self.expect(TokenKind::Rpar); } - elts.shrink_to_fit(); - ast::ExprTuple { - elts, + elts: self.expr_scratch.take(elts_snapshot), ctx: ExprContext::Load, range: self.node_range(start), node_index: AtomicNodeIndex::NONE, @@ -2484,22 +2478,20 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Comma); } - let mut elts = vec![first_element]; + let elts_snapshot = self.expr_scratch.snapshot(); + self.expr_scratch.push(first_element); self.parse_comma_separated_list(RecoveryContextKind::ListElements, |parser| { - elts.push( - parser - .parse_named_expression_or_higher(ExpressionContext::starred_bitwise_or()) - .expr, - ); + let element = parser + .parse_named_expression_or_higher(ExpressionContext::starred_bitwise_or()) + .expr; + parser.expr_scratch.push(element); }); self.expect(TokenKind::Rsqb); - elts.shrink_to_fit(); - ast::ExprList { - elts, + elts: self.expr_scratch.take(elts_snapshot), ctx: ExprContext::Load, range: self.node_range(start), node_index: AtomicNodeIndex::NONE, @@ -2529,7 +2521,8 @@ impl<'src> Parser<'src> { ); } - let mut elts = vec![first_element.expr]; + let elts_snapshot = self.expr_scratch.snapshot(); + self.expr_scratch.push(first_element.expr); self.parse_comma_separated_list(RecoveryContextKind::SetElements, |parser| { let parsed_expr = @@ -2544,7 +2537,7 @@ impl<'src> Parser<'src> { ); } - elts.push(parsed_expr.expr); + parser.expr_scratch.push(parsed_expr.expr); }); self.expect(TokenKind::Rbrace); @@ -2552,7 +2545,7 @@ impl<'src> Parser<'src> { ast::ExprSet { range: self.node_range(start), node_index: AtomicNodeIndex::NONE, - elts, + elts: self.expr_scratch.take(elts_snapshot), } } @@ -2653,7 +2646,7 @@ impl<'src> Parser<'src> { self.expect(TokenKind::In); let iter = self.parse_simple_expression(ExpressionContext::default()); - let mut ifs = Vec::new(); + let ifs_snapshot = self.expr_scratch.snapshot(); let mut progress = ParserProgress::default(); while self.eat(TokenKind::If) { @@ -2661,20 +2654,15 @@ impl<'src> Parser<'src> { let parsed_expr = self.parse_simple_expression(ExpressionContext::default()); - if ifs.is_empty() { - ifs.reserve_exact(1); - } - ifs.push(parsed_expr.expr); + self.expr_scratch.push(parsed_expr.expr); } - ifs.shrink_to_fit(); - ast::Comprehension { range: self.node_range(start), node_index: AtomicNodeIndex::NONE, target: target.expr, iter: iter.expr, - ifs, + ifs: self.expr_scratch.take(ifs_snapshot), is_async, } } diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index f1a44c9241..0c7093793a 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -6,7 +6,8 @@ use bitflags::bitflags; use ruff_python_ast::name::Name; use ruff_python_ast::token::TokenKind; use ruff_python_ast::{ - AtomicNodeIndex, Int, IpyEscapeKind, Mod, ModExpression, ModModule, StringFlags, + Alias, AtomicNodeIndex, ElifElseClause, Expr, Int, IpyEscapeKind, Keyword, Mod, ModExpression, + ModModule, ParameterWithDefault, Stmt, StringFlags, }; use ruff_python_trivia::is_python_whitespace; use ruff_text_size::{Ranged, TextRange, TextSize}; @@ -16,6 +17,7 @@ use unicode_normalization::UnicodeNormalization; use crate::error::UnsupportedSyntaxError; use crate::parser::expression::ExpressionContext; use crate::parser::progress::{ParserProgress, TokenId}; +use crate::parser::scratch_buffer::ScratchBuffer; use crate::string::InterpolatedStringKind; use crate::token_set::TokenSet; use crate::token_source::{TokenSource, TokenSourceCheckpoint}; @@ -30,6 +32,7 @@ mod options; mod pattern; mod progress; mod recovery; +mod scratch_buffer; mod statement; #[cfg(test)] mod tests; @@ -68,6 +71,24 @@ pub(crate) struct Parser<'src> { /// Maximum lexer nesting depth before postfix calls and subscripts should stop recursing. max_nesting_depth: u32, + + /// Reusable, nesting-safe scratch storage for expression lists. + expr_scratch: ScratchBuffer, + + /// Reusable, nesting-safe scratch storage for call keywords. + keyword_scratch: ScratchBuffer, + + /// Reusable, nesting-safe scratch storage for function and lambda parameters. + parameter_scratch: ScratchBuffer, + + /// Reusable, nesting-safe scratch storage for statement lists. + stmt_scratch: ScratchBuffer, + + /// Reusable scratch storage for import aliases. + alias_scratch: ScratchBuffer, + + /// Reusable, nesting-safe scratch storage for `elif` and `else` clauses. + elif_else_scratch: ScratchBuffer, } impl<'src> Parser<'src> { @@ -98,6 +119,12 @@ impl<'src> Parser<'src> { current_token_id: TokenId::default(), depth_remaining, max_nesting_depth, + expr_scratch: ScratchBuffer::with_capacity(16), + keyword_scratch: ScratchBuffer::new(), + parameter_scratch: ScratchBuffer::new(), + stmt_scratch: ScratchBuffer::with_capacity(32), + alias_scratch: ScratchBuffer::new(), + elif_else_scratch: ScratchBuffer::new(), } } @@ -207,7 +234,6 @@ impl<'src> Parser<'src> { TokenKind::EndOfFile, "Parser should be at the end of the file." ); - // TODO consider re-integrating lexical error handling into the parser? let parse_errors = self.errors; let (tokens, lex_errors) = self.tokens.finish(); diff --git a/crates/ruff_python_parser/src/parser/scratch_buffer.rs b/crates/ruff_python_parser/src/parser/scratch_buffer.rs new file mode 100644 index 0000000000..9f3acb015e --- /dev/null +++ b/crates/ruff_python_parser/src/parser/scratch_buffer.rs @@ -0,0 +1,136 @@ +use std::vec::Drain; + +use drop_bomb::DebugDropBomb; +use thin_vec::ThinVec; + +/// Reusable scratch storage that preserves entries belonging to outer parser frames. +#[derive(Debug)] +pub(super) struct ScratchBuffer { + buffer: Vec, +} + +impl ScratchBuffer { + pub(super) fn new() -> Self { + Self { buffer: Vec::new() } + } + + pub(super) fn with_capacity(capacity: usize) -> Self { + Self { + buffer: Vec::with_capacity(capacity), + } + } + + #[inline] + pub(super) fn push(&mut self, value: T) { + self.buffer.push(value); + } + + #[inline] + pub(super) fn is_empty(&self, snapshot: &ScratchSnapshot) -> bool { + debug_assert!( + self.buffer.len() >= snapshot.len, + "Scratch buffer snapshots must be restored in reverse order of creation." + ); + self.buffer.len() == snapshot.len + } + + #[inline] + pub(super) fn snapshot(&self) -> ScratchSnapshot { + ScratchSnapshot { + len: self.buffer.len(), + bomb: DebugDropBomb::new("Scratch buffer snapshots must be restored."), + } + } + + #[inline] + pub(super) fn take>(&mut self, snapshot: ScratchSnapshot) -> C { + self.drain_snapshot(snapshot).collect() + } + + #[inline] + pub(super) fn take_thin_vec(&mut self, mut snapshot: ScratchSnapshot) -> ThinVec { + if self.is_empty(&snapshot) { + snapshot.bomb.defuse(); + return ThinVec::new(); + } + + let drain = self.drain_snapshot(snapshot); + let mut result = ThinVec::with_capacity(drain.len()); + result.extend(drain); + result + } + + #[inline] + fn drain_snapshot(&mut self, mut snapshot: ScratchSnapshot) -> Drain<'_, T> { + debug_assert!( + self.buffer.len() >= snapshot.len, + "Scratch buffer snapshots must be restored in reverse order of creation." + ); + snapshot.bomb.defuse(); + self.buffer.drain(snapshot.len..) + } +} + +impl Drop for ScratchBuffer { + fn drop(&mut self) { + debug_assert!( + self.buffer.is_empty() || std::thread::panicking(), + "Scratch buffers must be empty when dropped." + ); + } +} + +pub(super) struct ScratchSnapshot { + len: usize, + bomb: DebugDropBomb, +} + +#[cfg(test)] +mod tests { + use super::ScratchBuffer; + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "Scratch buffers must be empty when dropped.")] + fn buffer_must_be_empty_when_dropped() { + let mut buffer = ScratchBuffer::new(); + buffer.push(1); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "Scratch buffer snapshots must be restored.")] + fn snapshot_must_be_restored() { + let buffer = ScratchBuffer::::new(); + let _snapshot = buffer.snapshot(); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic( + expected = "Scratch buffer snapshots must be restored in reverse order of creation." + )] + fn snapshot_must_be_restored_in_reverse_order() { + let mut buffer = ScratchBuffer::new(); + buffer.push(1); + let snapshot = buffer.snapshot(); + buffer.buffer.clear(); + + let _: Vec<_> = buffer.take(snapshot); + } + + #[test] + fn snapshot_is_empty_relative_to_its_buffer() { + let mut buffer = ScratchBuffer::new(); + let outer_snapshot = buffer.snapshot(); + buffer.push(1); + let snapshot = buffer.snapshot(); + assert!(buffer.is_empty(&snapshot)); + + buffer.push(2); + assert!(!buffer.is_empty(&snapshot)); + + let _: Vec<_> = buffer.take(snapshot); + let _: Vec<_> = buffer.take(outer_snapshot); + } +} diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 5f406e304b..6785c8dc2f 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -195,13 +195,14 @@ impl<'src> Parser<'src> { /// /// [Python grammar]: https://docs.python.org/3/reference/grammar.html fn parse_simple_statements(&mut self) -> Suite { - let mut stmts = Suite::with_capacity(1); + let stmts_snapshot = self.stmt_scratch.snapshot(); let mut progress = ParserProgress::default(); loop { progress.assert_progressing(self); - stmts.push(self.parse_simple_statement()); + let stmt = self.parse_simple_statement(); + self.stmt_scratch.push(stmt); if !self.eat(TokenKind::Semi) { if self.at_simple_stmt() { @@ -258,8 +259,7 @@ impl<'src> Parser<'src> { // test_ok simple_stmts_with_semicolons // return; import a; from x import y; z; type T = int - stmts.shrink_to_fit(); - stmts + self.stmt_scratch.take_thin_vec(stmts_snapshot) } /// Parses a simple statement. @@ -620,11 +620,12 @@ impl<'src> Parser<'src> { // import , // import x, y, - let mut names = self.parse_comma_separated_list_into_vec_with_capacity( - RecoveryContextKind::ImportNames, - |p| p.parse_alias(ImportStyle::Import), - 1, - ); + let names_snapshot = self.alias_scratch.snapshot(); + self.parse_comma_separated_list(RecoveryContextKind::ImportNames, |parser| { + let alias = parser.parse_alias(ImportStyle::Import); + parser.alias_scratch.push(alias); + }); + let names: Vec<_> = self.alias_scratch.take(names_snapshot); if names.is_empty() { // test_err import_stmt_empty @@ -632,8 +633,6 @@ impl<'src> Parser<'src> { self.add_error(ParseErrorType::EmptyImportNames, self.current_token_range()); } - names.shrink_to_fit(); - ast::StmtImport { names, is_lazy, @@ -698,7 +697,7 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Import); let names_start = self.node_start(); - let mut names = Vec::new(); + let names_snapshot = self.alias_scratch.snapshot(); let mut seen_star_import = false; let parenthesized = Parenthesized::from(self.eat(TokenKind::Lpar)); @@ -716,9 +715,10 @@ impl<'src> Parser<'src> { // from x import a, b.c, d, e.f, g let alias = parser.parse_alias(ImportStyle::ImportFrom); seen_star_import |= alias.name.id == "*"; - names.push(alias); + parser.alias_scratch.push(alias); }, ); + let names: Vec<_> = self.alias_scratch.take(names_snapshot); if names.is_empty() { // test_err from_import_empty_names @@ -758,8 +758,6 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Rpar); } - names.shrink_to_fit(); - ast::StmtImportFrom { module, names, @@ -1452,23 +1450,21 @@ impl<'src> Parser<'src> { // pass // else: // pass - let mut elif_else_clauses = self.parse_clauses(Clause::ElIf, |p| { - p.parse_elif_or_else_clause(ElifOrElse::Elif) + let elif_else_snapshot = self.elif_else_scratch.snapshot(); + self.parse_clauses(Clause::ElIf, |parser| { + let clause = parser.parse_elif_or_else_clause(ElifOrElse::Elif); + parser.elif_else_scratch.push(clause); }); if self.at(TokenKind::Else) { - if elif_else_clauses.is_empty() { - elif_else_clauses.reserve_exact(1); - } - elif_else_clauses.push(self.parse_elif_or_else_clause(ElifOrElse::Else)); + let clause = self.parse_elif_or_else_clause(ElifOrElse::Else); + self.elif_else_scratch.push(clause); } - elif_else_clauses.shrink_to_fit(); - ast::StmtIf { test: Box::new(test.expr), body, - elif_else_clauses, + elif_else_clauses: self.elif_else_scratch.take(elif_else_snapshot), range: self.node_range(start), node_index: AtomicNodeIndex::NONE, } @@ -1560,7 +1556,8 @@ impl<'src> Parser<'src> { // except* ExceptionGroup: // pass let mut mixed_except_ranges = Vec::new(); - let mut handlers = self.parse_clauses(Clause::Except, |p| { + let mut handlers = Vec::new(); + self.parse_clauses(Clause::Except, |p| { let (handler, kind) = p.parse_except_clause(); if let ExceptClauseKind::Star(range) = kind { p.add_unsupported_syntax_error(UnsupportedSyntaxErrorKind::ExceptStar, range); @@ -1570,7 +1567,10 @@ impl<'src> Parser<'src> { } else if is_star != Some(kind.is_star()) { mixed_except_ranges.push(handler.range()); } - handler + if handlers.is_empty() { + handlers.reserve_exact(1); + } + handlers.push(handler); }); handlers.shrink_to_fit(); @@ -3125,10 +3125,13 @@ impl<'src> Parser<'src> { self.bump(TokenKind::Indent); let statements = if let Some(statements) = self.with_recursion(|parser| { - parser.parse_list_into_thin_vec( - RecoveryContextKind::BlockStatements, - Parser::parse_statement, - ) + let snapshot = parser.stmt_scratch.snapshot(); + parser.parse_list(RecoveryContextKind::BlockStatements, |parser| { + let statement = parser.parse_statement(); + parser.stmt_scratch.push(statement); + }); + + parser.stmt_scratch.take_thin_vec(snapshot) }) { statements } else { @@ -3308,6 +3311,9 @@ impl<'src> Parser<'src> { // uses `Parameter` (not `ParameterWithDefault`) which means that the parser cannot // recover well from `*args=(1, 2)`. let mut parameters = ast::Parameters::default(); + let parameters_snapshot = self.parameter_scratch.snapshot(); + let mut args_snapshot = None; + let mut kwonlyargs_snapshot = None; let mut seen_default_param = false; // `a=10` let mut seen_positional_only_separator = false; // `/` @@ -3334,6 +3340,9 @@ impl<'src> Parser<'src> { let star_range = parser.current_token_range(); parser.bump(TokenKind::Star); + kwonlyargs_snapshot + .get_or_insert_with(|| parser.parameter_scratch.snapshot()); + if parser.at_name_or_soft_keyword() { let param = parser.parse_parameter(param_start, function_kind, AllowStarAnnotation::Yes); let param_star_range = parser.node_range(star_range.start()); @@ -3445,7 +3454,10 @@ impl<'src> Parser<'src> { let slash_range = parser.current_token_range(); parser.bump(TokenKind::Slash); - if parameters.is_empty() { + if parser.parameter_scratch.is_empty(¶meters_snapshot) + && parameters.vararg.is_none() + && parameters.kwarg.is_none() + { // test_err params_no_arg_before_slash // def foo(/): ... // def foo(/, a): ... @@ -3485,9 +3497,11 @@ impl<'src> Parser<'src> { } if !seen_positional_only_separator { - // We should only swap if we're seeing the separator for the + // We should only split if we're seeing the separator for the // first time, otherwise it's a user error. - std::mem::swap(&mut parameters.args, &mut parameters.posonlyargs); + if kwonlyargs_snapshot.is_none() { + args_snapshot = Some(parser.parameter_scratch.snapshot()); + } seen_positional_only_separator = true; // test_ok pos_only_py38 @@ -3534,11 +3548,7 @@ impl<'src> Parser<'src> { seen_keyword_only_param_after_separator = true; } - if seen_keyword_only_separator || parameters.vararg.is_some() { - parameters.kwonlyargs.push(param); - } else { - parameters.args.push(param); - } + parser.parameter_scratch.push(param); last_keyword_only_separator_range = None; } _ => { @@ -3562,9 +3572,17 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Rpar); } - parameters.args.shrink_to_fit(); - parameters.kwonlyargs.shrink_to_fit(); - parameters.posonlyargs.shrink_to_fit(); + if let Some(kwonlyargs_snapshot) = kwonlyargs_snapshot { + parameters.kwonlyargs = self.parameter_scratch.take_thin_vec(kwonlyargs_snapshot); + } + if let Some(args_snapshot) = args_snapshot { + parameters.args = self.parameter_scratch.take_thin_vec(args_snapshot); + parameters.posonlyargs = self.parameter_scratch.take_thin_vec(parameters_snapshot); + } else if seen_positional_only_separator { + parameters.posonlyargs = self.parameter_scratch.take_thin_vec(parameters_snapshot); + } else { + parameters.args = self.parameter_scratch.take_thin_vec(parameters_snapshot); + } parameters.range = self.node_range(start); @@ -4000,12 +4018,7 @@ impl<'src> Parser<'src> { /// For now, don't recover when parsing clause headers, but add the terminator tokens (e.g. /// `Else`) to the recovery context so that expression recovery stops when it encounters an /// `else` token. - fn parse_clauses( - &mut self, - clause: Clause, - mut parse_clause: impl FnMut(&mut Parser<'src>) -> T, - ) -> Vec { - let mut clauses = Vec::new(); + fn parse_clauses(&mut self, clause: Clause, mut parse_clause: impl FnMut(&mut Parser<'src>)) { let mut progress = ParserProgress::default(); let recovery_kind = match clause { @@ -4022,15 +4035,10 @@ impl<'src> Parser<'src> { while recovery_kind.is_list_element(self) { progress.assert_progressing(self); - if clauses.is_empty() { - clauses.reserve_exact(1); - } - clauses.push(parse_clause(self)); + parse_clause(self); } self.recovery_context = saved_context; - - clauses } } From 4bee07979b3df7ca602bbb658033c93a10c49391 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 17:59:56 -0700 Subject: [PATCH 105/222] [ty] Retain sequence pattern narrowing in successful `match` cases (#26807) ## Summary Prior to #25940, a successful sequence pattern retained the synthesized `__len__` and literal-index `__getitem__` narrowing implied by the pattern. We stopped retaining that information for subjects without an exact tuple shape because mutation or stateful access could make them stale. This PR tries a narrower heuristic: retain the observed length and indexed-element types on the original match subject only while type-checking the successful case branch. Case branches are generally narrowly scoped, which limits how long the mutation-sensitive facts survive: ```python def f(value: object) -> None: match value: case [int()]: reveal_type(len(value)) # Literal[1] reveal_type(value[0]) # int ``` --- .../resources/mdtest/narrow/match.md | 76 ++++++++++++++----- crates/ty_python_semantic/src/types/narrow.rs | 16 ++-- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 1c75821f21..22d38f76ef 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -224,49 +224,79 @@ def test_match_exact_sequence_excludes_bytearray(x: bytearray | tuple[int, int]) def test_match_exact_object_sequence(value: object) -> None: match value: case int(), str(): - # revealed: Sequence[object] & ~str & ~bytes & ~bytearray + # revealed: Sequence[object] & & ~str & ~bytes & ~bytearray reveal_type(value) - reveal_type(len(value)) # revealed: int - reveal_type(value[0]) # revealed: object - reveal_type(value[1]) # revealed: object + reveal_type(len(value)) # revealed: Literal[2] + reveal_type(value[0]) # revealed: int + reveal_type(value[1]) # revealed: str def test_match_empty_object_sequence(value: object) -> None: match value: case []: - # revealed: Sequence[object] & ~str & ~bytes & ~bytearray + # revealed: Sequence[object] & & ~str & ~bytes & ~bytearray reveal_type(value) - reveal_type(len(value)) # revealed: int + reveal_type(len(value)) # revealed: Literal[0] def test_match_singleton_object_sequence(value: object) -> None: match value: case [int()]: - # revealed: Sequence[object] & ~str & ~bytes & ~bytearray + # revealed: Sequence[object] & & ~bytearray & ~bytes reveal_type(value) - reveal_type(len(value)) # revealed: int - reveal_type(value[0]) # revealed: object + reveal_type(len(value)) # revealed: Literal[1] + reveal_type(value[0]) # revealed: int + +def test_match_singleton_object_sequence_capture(value: object) -> None: + match value: + case [int() as item]: + reveal_type(value[0]) # revealed: int + reveal_type(item) # revealed: int + +def test_sequence_subject_facts_are_scoped_to_successful_case( + value: list[int | str], +) -> None: + match value: + case [int(), str()]: + reveal_type(len(value)) # revealed: Literal[2] + reveal_type(value[0]) # revealed: int + case _: + pass + reveal_type(len(value)) # revealed: int + reveal_type(value[0]) # revealed: int | str + +# This deliberately documents the remaining unsoundness. Mutating the subject inside the case can +# invalidate the observed element types, but we retain them for the rest of the branch. +def test_sequence_subject_facts_can_be_stale_after_mutation( + value: list[int | str], +) -> None: + match value: + case [int(), str()]: + value.reverse() + reveal_type(value[0]) # revealed: int def test_match_prefix_star_object_sequence(value: object) -> None: match value: case [int(), *rest]: - # revealed: Sequence[object] & ~str & ~bytes & ~bytearray + # revealed: Sequence[object] & & ~str & ~bytes & ~bytearray reveal_type(value) reveal_type(len(value)) # revealed: int - reveal_type(value[0]) # revealed: object + reveal_type(value[0]) # revealed: int reveal_type(value[1]) # revealed: object def test_match_prefix_and_suffix_star_object_sequence(value: object) -> None: match value: case [int(), *rest, str()]: - # revealed: Sequence[object] & ~str & ~bytes & ~bytearray + # revealed: Sequence[object] & & ~str & ~bytes & ~bytearray reveal_type(value) - reveal_type(value[0]) # revealed: object - reveal_type(value[-1]) # revealed: object + reveal_type(value[0]) # revealed: int + # This is deliberately unsound: custom sequences can handle negative indices differently + # from their corresponding positive indices. + reveal_type(value[-1]) # revealed: str reveal_type(value[1]) # revealed: object def test_match_prefix_star_known_sequence(value: Sequence[int | str]) -> None: match value: case [int(), *rest]: - reveal_type(value[0]) # revealed: int | str + reveal_type(value[0]) # revealed: int reveal_type(value[1]) # revealed: int | str reveal_type(rest) # revealed: list[int | str] ``` @@ -538,7 +568,8 @@ def failed_sequence_alternative_does_not_narrow_later_capture( item.append(1) match item: case [only]: - reveal_type(item) # revealed: list[int] + # revealed: list[int] & + reveal_type(item) ``` ## Declared pattern captures @@ -659,6 +690,8 @@ def test_mutable_sequence_alias_does_not_keep_index_types( ) -> None: match value: case [int(), str()] as whole: + reveal_type(len(value)) # revealed: Literal[2] + reveal_type(value[0]) # revealed: int reveal_type(len(whole)) # revealed: int whole.reverse() reveal_type(whole[0]) # revealed: int | str @@ -678,7 +711,7 @@ def mutable_sequence_alias_does_not_keep_previous_shape_constraints( whole.clear() match whole: case []: - reveal_type(whole) # revealed: list[int] + reveal_type(whole) # revealed: list[int] & def failed_sequence_pattern_does_not_narrow_mutable_subject( value: list[int], @@ -692,7 +725,7 @@ def failed_sequence_pattern_does_not_narrow_mutable_subject( value.clear() match value: case []: - reveal_type(value) # revealed: list[int] + reveal_type(value) # revealed: list[int] & ``` ## Indirect class patterns @@ -1660,14 +1693,15 @@ def builtin_class_pattern_narrows_subject(value: bool) -> None: case bool(True): reveal_type(value) # revealed: Literal[True] -def list_class_pattern_does_not_keep_index_types_after_mutation( +def list_class_pattern_can_keep_stale_index_types_after_mutation( value: list[int | str], ) -> None: match value: case list([int(), str()]): - # Reversing the list invalidates the indexed-element facts established by the pattern. + # This is deliberately unsound: reversing the list invalidates the indexed-element + # facts, but we retain them for the rest of the successful case branch. value.reverse() - reveal_type(value[0]) # revealed: int | str + reveal_type(value[0]) # revealed: int def nested_list_pattern_does_not_keep_index_types_after_mutation( value: tuple[list[int | str]], diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 7a7b767dd4..a4aca8077a 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -2343,8 +2343,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { /// Return the type established while a sequence pattern is being evaluated. /// /// Exact tuples can refine their tuple element types with the facts established by successful - /// child patterns. Other sequences retain the structural constraints already present in - /// `narrowed_subject_ty`. + /// child patterns. For other sequences, retain the observed length and indexed-element facts + /// while type-checking the successful case branch. fn successful_sequence_subject_type( &self, kind: &SequencePatternPredicateKind<'db>, @@ -2359,14 +2359,10 @@ impl<'db> PatternSuccessAnalyzer<'db> { return refined; } - if subject_ty.exact_tuple_instance_spec(self.db).is_some() { - self.intersect_types( - narrowed_subject_ty, - self.successful_sequence_pattern_type(kind, matched_element_types), - ) - } else { - narrowed_subject_ty - } + self.intersect_types( + narrowed_subject_ty, + self.successful_sequence_pattern_type(kind, matched_element_types), + ) } /// Return the sequence type safe to assign to a binding created by the pattern. From e96819b4333ba0d7bef3947f0f0898908b3c66d0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 15 Jul 2026 18:51:45 -0700 Subject: [PATCH 106/222] [ty] Derive descriptor setter domains for protocols (#26683) ## Summary This is a follow-up to #26681. That PR conservatively treats a descriptor-decorated protocol member as read-only unless its `__set__` method has one simple signature. This PR models descriptor write capability separately from the write domain we can represent as a `Type`, so writable protocols remain writable without inventing an unsound approximation. For concrete setter signatures, we derive the accepted value domain after normal descriptor binding. Applicable overloads within one callable expand the domain, while callable alternatives and union-valued descriptors intersect their domains because every possible runtime setter must accept the assignment: ```python def either_descriptor(getter: object) -> Descriptor[int | str] | Descriptor[str | bytes]: ... class HasValue(Protocol): @either_descriptor def value(self) -> object: ... def update(value: HasValue) -> None: value.value = "valid" value.value = 1 # error ``` Some valid setter contracts cannot be flattened into one exact type without losing information, including constrained or aliased method type variables, gradual and variadic signatures, and intersections that exceed the set-theoretic expansion budget. These cases now retain the descriptor call contract with an unknown domain instead of collapsing to `Never` or becoming read-only. Exact `Never` domains remain writable, and optional trailing parameters remain supported. Real assignments invoke each possible descriptor's bound `__set__` method with the active receiver arm. This preserves receiver-sensitive overloads and the binding behavior of ordinary methods, static methods, class methods, and callable objects, while structural protocol compatibility continues to reject read-only or too-narrow implementations. --- .../resources/mdtest/protocols.md | 599 +++++++++++++++++- .../src/types/attribute_write.rs | 32 +- .../infer/builder/attribute_assignment.rs | 155 ++++- .../src/types/protocol_class.rs | 472 +++++++++++--- 4 files changed, 1122 insertions(+), 136 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index d47e23a4b4..c8415b748d 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2658,8 +2658,8 @@ reveal_protocol_interface(StoresDescriptor) An overloaded `__set__` method can accept different values for different receiver types. For `HasValue`, the overloads with an `object` receiver accept `int` and `bytes`; the overload for -`Other` does not apply. Until these overloads can be analyzed, `Unknown` is used as the write type. -This preserves the writable requirement without rejecting assignments. +`Other` does not apply. Therefore, assignments of `int` and `bytes` are valid, but assignments of +`str` are not. ```py from typing import Protocol, final, overload @@ -2694,14 +2694,151 @@ read_only: HasValue = ReadOnlyValue() # error: [invalid-assignment] def update_value(value: HasValue) -> None: value.value = 1 value.value = b"valid" - # TODO: This assignment should be rejected. - value.value = "bad" + value.value = "bad" # error: [invalid-assignment] +``` + +### Descriptor setters on union protocol receivers + +When assigning through a union of protocols, each descriptor setter is called with its matching +union element as the receiver. The other elements of the union do not participate in that call. The +`a_only` and `b_only` methods keep the two protocol types distinct. + +```py +from __future__ import annotations + +from typing import Protocol + +class ADescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + def __set__(self, instance: A, value: int) -> None: ... + +class BDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + def __set__(self, instance: B, value: int) -> None: ... + +class A(Protocol): + @ADescriptor + def value(self) -> int: ... + def a_only(self) -> None: ... + +class B(Protocol): + @BDescriptor + def value(self) -> int: ... + def b_only(self) -> None: ... + +def update_union_value(value: A | B) -> None: + value.value = 1 +``` + +### Static, class, and callable setters + +The examples below use the same property implementations to check both assignment and protocol +compatibility: + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class IntPropertySetter: + @property + def value(self) -> int: + return 1 + + @value.setter + def value(self, new_value: int) -> None: ... + +class StrPropertySetter: + @property + def value(self) -> int: + return 1 + + @value.setter + def value(self, new_value: str) -> None: ... +``` + +A static `__set__` method receives the instance and assigned value directly: + +```py +class StaticSetterDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + @staticmethod + def __set__(instance: object, value: int) -> None: ... + +class HasStaticSetter(Protocol): + @StaticSetterDescriptor + def value(self) -> int: ... + +static_assert(is_subtype_of(IntPropertySetter, HasStaticSetter)) +static_assert(not is_subtype_of(StrPropertySetter, HasStaticSetter)) + +def update_static_setter(value: HasStaticSetter) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +A class `__set__` method also receives the descriptor class implicitly: + +```py +class ClassSetterDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + @classmethod + def __set__(cls, instance: object, value: int) -> None: ... + +class HasClassSetter(Protocol): + @ClassSetterDescriptor + def value(self) -> int: ... + +static_assert(is_subtype_of(IntPropertySetter, HasClassSetter)) +static_assert(not is_subtype_of(StrPropertySetter, HasClassSetter)) + +def update_class_setter(value: HasClassSetter) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +An object stored in `__set__` is called with the instance and assigned value: + +```py +class IntSetterCallable: + def __call__(self, instance: object, value: int) -> None: ... + +class CallableSetterDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + __set__ = IntSetterCallable() + +class HasCallableSetter(Protocol): + @CallableSetterDescriptor + def value(self) -> int: ... + +static_assert(is_subtype_of(IntPropertySetter, HasCallableSetter)) +static_assert(not is_subtype_of(StrPropertySetter, HasCallableSetter)) + +def update_callable_setter(value: HasCallableSetter) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] ``` ### Union descriptor types If a decorator can return either of two descriptors, an assignment must be accepted by both possible -descriptors. Here, only `str` is accepted by both. +descriptors. Here, only `str` is accepted by both, so an `int` assignment is invalid even though one +of the descriptors accepts it. ```py from typing import Generic, Protocol, TypeVar @@ -2723,8 +2860,105 @@ class HasEitherValue(Protocol): def update_either_value(value: HasEitherValue) -> None: value.either_value = "valid" - # TODO: This assignment should be rejected. - value.either_value = 1 + value.either_value = 1 # error: [invalid-assignment] +``` + +### Aliased union descriptor types + +Top-level PEP 695 aliases do not change which assignments a descriptor union accepts. As with the +unaliased form above, only `str` is accepted by both possible descriptors. The alias also does not +make the protocol member read-only. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Generic, Protocol, TypeVar + +T = TypeVar("T") + +class Descriptor(Generic[T]): + def __get__(self, instance: object, owner: type | None = None) -> T: + raise NotImplementedError + + def __set__(self, instance: object, value: T) -> None: ... + +type DescriptorAlias = Descriptor[int | str] | Descriptor[str | bytes] + +def aliased_descriptor(getter: object) -> DescriptorAlias: + raise NotImplementedError + +class HasAliasedValue(Protocol): + @aliased_descriptor + def value(self) -> object: ... + +class ReadOnlyAliasedValue: + @property + def value(self) -> object: + return "value" + +read_only: HasAliasedValue = ReadOnlyAliasedValue() # error: [invalid-assignment] + +def update_aliased_value(value: HasAliasedValue) -> None: + value.value = "valid" + value.value = 1 # error: [invalid-assignment] +``` + +### Large unions of descriptor types + +A value assigned through the protocol must be accepted by every possible descriptor. Here, `AX` is +accepted by both descriptors, while `A` is accepted only by the first. Because the protocol member +is writable, a read-only property cannot implement it. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Generic, Protocol, TypeVar + +T = TypeVar("T") + +class A: ... +class B: ... +class C: ... +class X: ... +class Y: ... +class Z: ... +class AX(A, X): ... + +class Descriptor(Generic[T]): + def __get__(self, instance: object, owner: type | None = None) -> object: + raise NotImplementedError + + def __set__(self, instance: object, value: T) -> None: ... + +def large_union_descriptor( + getter: object, +) -> Descriptor[A | B | C] | Descriptor[X | Y | Z]: + raise NotImplementedError + +class HasLargeUnionValue(Protocol): + @large_union_descriptor + def value(self) -> object: ... + +class ReadOnlyLargeUnionValue: + @property + def value(self) -> object: + return "value" + +read_only: HasLargeUnionValue = ReadOnlyLargeUnionValue() # error: [invalid-assignment] + +def update_large_union_value( + value: HasLargeUnionValue, + valid: AX, + invalid: A, +) -> None: + value.value = valid + value.value = invalid # error: [invalid-assignment] ``` ### Overloaded setters selected by descriptor type @@ -2758,14 +2992,13 @@ class HasIntValue(Protocol): def update_int_value(value: HasIntValue) -> None: value.int_value = 1 - # TODO: This assignment should be rejected. - value.int_value = "bad" + value.int_value = "bad" # error: [invalid-assignment] ``` ### Generic setter value types A setter that uses a method type variable directly as its value parameter accepts every value -allowed by that type variable's upper bound. +allowed by that type variable's upper bound. The setter below therefore accepts `int` values. ```py from typing import Protocol, TypeVar @@ -2787,8 +3020,350 @@ class HasBoundedValue(Protocol): def update_bounded_value(value: HasBoundedValue) -> None: value.bounded_value = 1 - # TODO: This assignment should be rejected. - value.bounded_value = "bad" + value.bounded_value = "bad" # error: [invalid-assignment] +``` + +### Type variables from the surrounding function + +A type variable supplied by the surrounding function is still the descriptor's value type. Assigning +a value of that type is valid. + +```py +from typing import Generic, Protocol, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + +class Descriptor(Generic[T]): + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> T: + raise NotImplementedError + + def __set__(self, instance: object, value: T) -> None: ... + +class HasGenericValue(Protocol[T]): + @Descriptor[T] + def value(self) -> T: ... + +def update_generic_value(value: HasGenericValue[U], new_value: U) -> None: + value.value = new_value +``` + +### Setter type variables inside aliases + +A type alias does not hide that `T` belongs to `__set__`. The descriptor below still accepts `int`, +so it remains writable with `int` but not with `str`. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Never, Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +type Alias[T] = T + +class AliasedReceiverDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + def __set__[T](self, instance: Alias[T], value: int) -> None: ... + +class HasAliasedReceiver(Protocol): + @AliasedReceiverDescriptor + def value(self) -> int: ... +``` + +A property setter that accepts only `Never` cannot implement this protocol member: + +```py +class NeverPropertySetter: + @property + def value(self) -> int: + return 1 + + @value.setter + def value(self, new_value: Never) -> None: ... + +static_assert(not is_subtype_of(NeverPropertySetter, HasAliasedReceiver)) +``` + +Assignments through the protocol accept `int` but reject `str`: + +```py +def update_aliased_receiver(value: HasAliasedReceiver) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +### Constrained generic setters + +A setter with a constrained type variable must choose one constraint for each call. It accepts `int` +and `str` separately, but not a value whose type is `int | str`. + +```py +from typing import Protocol, TypeVar + +T = TypeVar("T", int, str) + +class ConstrainedDescriptor: + def __get__(self, instance: object, owner: type | None = None) -> int | str: + raise NotImplementedError + + def __set__(self, instance: object, value: T) -> None: ... + +def constrained_descriptor(getter: object) -> ConstrainedDescriptor: + raise NotImplementedError + +class HasConstrainedValue(Protocol): + @constrained_descriptor + def constrained_value(self) -> int | str: ... +``` + +The setter is still present, so a read-only property cannot implement the protocol: + +```py +class ReadOnlyConstrainedValue: + @property + def constrained_value(self) -> int | str: + return "value" + +read_only: HasConstrainedValue = ReadOnlyConstrainedValue() # error: [invalid-assignment] +``` + +Literal values select one constraint, while a union of the constraints does not: + +```py +def update_constrained_value(value: HasConstrainedValue, new_value: int | str) -> None: + value.constrained_value = 1 + value.constrained_value = "valid" + value.constrained_value = new_value # error: [invalid-assignment] +``` + +### Setters that accept `Never` + +A `Never` value cannot normally exist, but a `__set__` parameter of type `Never` still makes the +protocol member writable. A read-only property therefore cannot implement it. + +```py +from typing import Protocol +from typing_extensions import Never + +class NeverDescriptor: + def __get__(self, instance: object, owner: type | None = None) -> object: + raise NotImplementedError + + def __set__(self, instance: object, value: Never) -> None: ... + +def never_descriptor(getter: object) -> NeverDescriptor: + raise NotImplementedError + +class HasNeverValue(Protocol): + @never_descriptor + def never_value(self) -> object: ... + +class ReadOnlyNeverValue: + @property + def never_value(self) -> object: + return "value" + +read_only: HasNeverValue = ReadOnlyNeverValue() # error: [invalid-assignment] +``` + +If the assigned expression itself has type `Never`, the assignment is valid: + +```py +def update_never_value(value: HasNeverValue, new_value: Never) -> None: + value.never_value = new_value +``` + +### Optional parameters after the setter value + +Attribute assignment calls `__set__` with the instance and assigned value. Any later parameters can +be present if they can all be omitted. + +```py +from typing import Protocol + +class OptionalTrailingDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + raise NotImplementedError + + def __set__( + self, + instance: object, + value: int, + notify: bool = False, + *metadata: str, + log: bool = False, + **named_metadata: str, + ) -> None: ... + +class HasOptionalTrailingValue(Protocol): + @OptionalTrailingDescriptor + def value(self) -> int: ... + +def update_optional_trailing_value(value: HasOptionalTrailingValue) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +### Gradual variadic tails after the setter value + +A `*args: Any, **kwargs: Any` tail can also be omitted. It does not make the protocol member +read-only or change the `int` value accepted by the setter. + +```py +from typing import Any, Protocol + +class GradualTrailingDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + raise NotImplementedError + + def __set__(self, instance: object, value: int, *args: Any, **kwargs: Any) -> None: ... + +class HasGradualTrailingValue(Protocol): + @GradualTrailingDescriptor + def value(self) -> int: ... + +class ReadOnlyGradualTrailingValue: + @property + def value(self) -> int: + return 1 + +read_only: HasGradualTrailingValue = ReadOnlyGradualTrailingValue() # error: [invalid-assignment] + +def update_gradual_trailing_value(value: HasGradualTrailingValue) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +### Required parameters after the setter value + +If `__set__` requires another parameter after the assigned value, attribute assignment cannot call +it because that argument is missing. + +```py +from typing import Protocol + +class RequiredTrailingDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + raise NotImplementedError + + def __set__(self, instance: object, value: int, required: bool) -> None: ... + +class HasRequiredTrailingValue(Protocol): + @RequiredTrailingDescriptor + def value(self) -> int: ... + +def update_required_trailing_value(value: HasRequiredTrailingValue) -> None: + value.value = 1 # error: [invalid-assignment] +``` + +### Setter values captured by `*args` + +When `__set__` declares `(instance, *values: int)`, attribute assignment supplies the value as the +first element of `values`. The descriptor therefore accepts `int` but not `str`. + +```py +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class VariadicValueDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + def __set__(self, instance: object, *values: int) -> None: ... + +class HasVariadicValue(Protocol): + @VariadicValueDescriptor + def value(self) -> int: ... +``` + +A property setter restricted to `str` cannot implement this protocol member: + +```py +class StrPropertySetter: + @property + def value(self) -> int: + return 1 + + @value.setter + def value(self, new_value: str) -> None: ... + +static_assert(not is_subtype_of(StrPropertySetter, HasVariadicValue)) +``` + +Assignments through the protocol follow the `int` annotation on `*values`: + +```py +def update_variadic_value(value: HasVariadicValue) -> None: + value.value = 1 + value.value = "bad" # error: [invalid-assignment] +``` + +### Gradually typed setter signatures + +When `__set__` is `Callable[..., None]`, ty cannot determine which values it accepts. Assignment is +allowed, but a property setter limited to `int` is not guaranteed to implement the same member. + +```py +from typing import Any, Callable, Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +class IntPropertySetter: + @property + def value(self) -> int: + return 1 + + @value.setter + def value(self, new_value: int) -> None: ... + +class CallableSetterDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + __set__: Callable[..., None] + +class HasCallableSetter(Protocol): + @CallableSetterDescriptor + def value(self) -> int: ... + +static_assert(not is_subtype_of(IntPropertySetter, HasCallableSetter)) + +def update_callable_setter(value: HasCallableSetter) -> None: + value.value = object() +``` + +The same applies when the type of `__set__` is `Any`: + +```py +class AnySetterDescriptor: + def __init__(self, getter: object) -> None: ... + def __get__(self, instance: object, owner: type | None = None) -> int: + return 1 + + __set__: Any + +class HasAnySetter(Protocol): + @AnySetterDescriptor + def value(self) -> int: ... + +static_assert(not is_subtype_of(IntPropertySetter, HasAnySetter)) + +def update_any_setter(value: HasAnySetter) -> None: + value.value = object() ``` ## Variance of generic protocols with `Final` members diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index bbbc8d6894..f34f6ae8a9 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -42,13 +42,12 @@ pub(super) enum AttributeWriteRequirement<'db> { /// /// `None` represents an unresolved module attribute rather than an unconstrained write. Module(Option>), - /// The effective instance-write type of a declared protocol member. + /// The effective instance-write requirement of a declared protocol member. /// - /// `write_ty` is `None` for a read-only member. Qualifiers are retained so assignment - /// inference can distinguish `Final` and `ClassVar` diagnostics from other non-writable - /// members. + /// `write` is `None` for a read-only member. Qualifiers are retained so assignment inference + /// can distinguish `Final` and `ClassVar` diagnostics from other non-writable members. ProtocolMember { - write_ty: Option>, + write: Option>, qualifiers: TypeQualifiers, }, /// A write through an instance, resolved against its class and instance attributes. @@ -63,6 +62,23 @@ pub(super) enum AttributeWriteRequirement<'db> { }, } +/// How a writable protocol member validates an assigned value. +pub(super) enum ProtocolMemberWriteRequirement<'db> { + /// Check the assigned value against a directly representable write type. + AssignableTo(Type<'db>), + /// Invoke every possible descriptor setter with the assigned value. + /// + /// `domain` is the precisely derived write type when that domain fits in [`Type`]. It is used + /// for contextual inference and protocol compatibility, while descriptor calls remain the + /// authority for real assignments. `None` preserves a known write capability whose generic or + /// set-theoretic domain cannot be represented precisely. + Descriptor { + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, + domain: Option>, + }, +} + /// The member that governs a write through an instance. /// /// A declared class member takes precedence over an instance fallback. A custom `__setattr__` is @@ -243,8 +259,8 @@ pub(super) fn attribute_write_requirement<'db>( .instance_write_requirement(db, object_ty, attribute) .map_or_else( || instance_attribute_write_requirement(db, object_ty, attribute), - |(write_ty, qualifiers)| AttributeWriteRequirement::ProtocolMember { - write_ty, + |(write, qualifiers)| AttributeWriteRequirement::ProtocolMember { + write, qualifiers, }, ), @@ -277,7 +293,7 @@ pub(super) fn attribute_write_requirement<'db>( .map_or_else( || class_attribute_write_requirement(db, object_ty, attribute), |(write_ty, qualifiers)| AttributeWriteRequirement::ProtocolMember { - write_ty, + write: write_ty.map(ProtocolMemberWriteRequirement::AssignableTo), qualifiers, }, ), diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs index 27cd7a0db1..2f47b9a5d5 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -5,8 +5,8 @@ use super::{MultiInferenceGuard, TypeInferenceBuilder}; use crate::place::{DefinedPlace, Place, PlaceAndQualifiers}; use crate::types::attribute_write::{ AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, - FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, attribute_write_requirement, - property_setter_returns_never, + FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, + ProtocolMemberWriteRequirement, attribute_write_requirement, property_setter_returns_never, }; use crate::types::call::{CallArguments, CallError}; use crate::types::diagnostic::{ @@ -185,15 +185,29 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { false } } - AttributeWriteRequirement::ProtocolMember { - write_ty, - qualifiers, - } => { - if let Some(write_ty) = write_ty { + AttributeWriteRequirement::ProtocolMember { write, qualifiers } => match write { + Some(ProtocolMemberWriteRequirement::AssignableTo(write_ty)) => { let value_ty = self.infer_value(TypeContext::new(Some(*write_ty)), emit_diagnostics); self.check_type_pair(value_ty, *write_ty, emit_diagnostics) - } else { + } + Some(ProtocolMemberWriteRequirement::Descriptor { + descriptor_ty, + receiver_ty, + domain, + }) => { + let value_ty = self.infer_value( + TypeContext::new(Some(domain.unwrap_or_else(Type::unknown))), + emit_diagnostics, + ); + self.evaluate_protocol_descriptor_write( + *descriptor_ty, + *receiver_ty, + value_ty, + emit_diagnostics, + ) + } + None => { self.infer_value(TypeContext::default(), emit_diagnostics); let reported_final = !qualifiers.contains(TypeQualifiers::CLASS_VAR) && qualifiers.contains(TypeQualifiers::FINAL) @@ -211,7 +225,7 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { } false } - } + }, AttributeWriteRequirement::Instance { object_ty, member } => { self.evaluate_instance(*object_ty, member, emit_diagnostics) } @@ -420,34 +434,109 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { descriptor_ty, setter_ty, .. - } => { - let db = self.builder.db(); - let result = setter_ty.try_call( - db, - &CallArguments::positional([*descriptor_ty, object_ty, value_ty]), - ); - if property_setter_returns_never(db, *descriptor_ty, object_ty, value_ty) { + } => self.evaluate_descriptor_write( + *descriptor_ty, + *setter_ty, + object_ty, + value_ty, + emit_diagnostics, + ), + ExplicitAttributeWriteRequirement::AssignableTo { ty, .. } => { + let value_ty = self.infer_value(TypeContext::new(Some(*ty)), false); + self.check_type_pair(value_ty, *ty, emit_diagnostics) + } + } + } + + fn evaluate_protocol_descriptor_write( + &mut self, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, + value_ty: Type<'db>, + emit_diagnostics: bool, + ) -> bool { + let db = self.builder.db(); + let descriptor_ty = descriptor_ty.resolve_type_alias(db); + if let Type::Union(union) = descriptor_ty { + for descriptor_ty in union.elements(db) { + if !self.evaluate_protocol_descriptor_write( + *descriptor_ty, + receiver_ty, + value_ty, + false, + ) { if emit_diagnostics { - self.report(AssignmentAttributeWriteDiagnostic::TerminalDescriptor); - } - false - } else { - match result { - Ok(_) => true, - Err(error) => { - if emit_diagnostics { - self.report(AssignmentAttributeWriteDiagnostic::BadDunderSet( - error, - )); - } - false - } + self.evaluate_protocol_descriptor_write( + *descriptor_ty, + receiver_ty, + value_ty, + true, + ); } + return false; } } - ExplicitAttributeWriteRequirement::AssignableTo { ty, .. } => { - let value_ty = self.infer_value(TypeContext::new(Some(*ty)), false); - self.check_type_pair(value_ty, *ty, emit_diagnostics) + return true; + } + + if property_setter_returns_never(db, descriptor_ty, receiver_ty, value_ty) { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::TerminalDescriptor); + } + return false; + } + + match descriptor_ty.try_call_dunder_with_policy( + db, + "__set__", + &mut CallArguments::positional([receiver_ty, value_ty]), + TypeContext::default(), + MemberLookupPolicy::REQUIRE_CONCRETE, + ) { + Ok(_) => true, + Err(CallDunderError::CallError(kind, bindings, _)) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::BadDunderSet(CallError( + kind, bindings, + ))); + } + false + } + Err(CallDunderError::MethodNotAvailable | CallDunderError::PossiblyUnbound { .. }) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::CannotAssign); + } + false + } + } + } + + fn evaluate_descriptor_write( + &mut self, + descriptor_ty: Type<'db>, + setter_ty: Type<'db>, + object_ty: Type<'db>, + value_ty: Type<'db>, + emit_diagnostics: bool, + ) -> bool { + let db = self.builder.db(); + if property_setter_returns_never(db, descriptor_ty, object_ty, value_ty) { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::TerminalDescriptor); + } + return false; + } + + match setter_ty.try_call( + db, + &CallArguments::positional([descriptor_ty, object_ty, value_ty]), + ) { + Ok(_) => true, + Err(error) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::BadDunderSet(error)); + } + false } } } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index be49d0bde3..71797e3d6f 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -8,7 +8,8 @@ use rustc_hash::FxHashMap; use crate::types::attribute_write::{ AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, - FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, attribute_write_requirement, + FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, + ProtocolMemberWriteRequirement, attribute_write_requirement, }; use crate::types::call::{CallArguments, CallDunderError}; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; @@ -22,9 +23,10 @@ use crate::{ types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, CallableType, ClassBase, ClassType, ErrorContext, FindLegacyTypeVarsVisitor, - InstanceFallbackShadowsNonDataDescriptor, KnownFunction, MemberLookupPolicy, - PropertyInstanceType, ProtocolInstanceType, SelfBinding, StaticClassLiteral, Type, - TypeMapping, TypeQualifiers, TypeVarVariance, UnionType, VarianceInferable, + InstanceFallbackShadowsNonDataDescriptor, IntersectionType, KnownFunction, + MemberLookupPolicy, Parameter, PropertyInstanceType, ProtocolInstanceType, SelfBinding, + Signature, StaticClassLiteral, Type, TypeMapping, TypeQualifiers, + TypeVarBoundOrConstraints, TypeVarVariance, UnionType, VarianceInferable, constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, diagnostic::report_undeclared_protocol_member, @@ -242,7 +244,14 @@ pub(super) fn walk_protocol_instance_interface< } } ProtocolMemberKind::Property { read, write } => { - for member_type in [read, write].into_iter().flatten() { + for member_type in [ + read, + write.and_then(ProtocolMemberWrite::domain), + write.and_then(ProtocolMemberWrite::descriptor_type), + ] + .into_iter() + .flatten() + { if let Some(ty) = member_type.bind_self(db, receiver_ty) { visitor.visit_type(db, ty); } @@ -352,21 +361,21 @@ impl<'db> ProtocolInterface<'db> { /// Returns the declared instance-write requirement for a protocol member. /// /// `None` means that the protocol does not declare `name`; `Some((None, _))` means that the - /// member exists but is read-only. A writable member's type is bound to `receiver_ty` before - /// it is returned. + /// member exists but is read-only. A writable member's requirement is bound to `receiver_ty` + /// before it is returned. pub(super) fn instance_write_requirement( self, db: &'db dyn Db, receiver_ty: Type<'db>, name: &str, - ) -> Option<(Option>, TypeQualifiers)> { + ) -> Option<(Option>, TypeQualifiers)> { self.member_by_name(db, name).map(|member| { let capabilities = member.capabilities(db); ( capabilities .instance .write - .and_then(|write| write.bind_self(db, receiver_ty)), + .and_then(|write| write.bind_requirement(db, receiver_ty)), member.qualifiers(), ) }) @@ -387,7 +396,7 @@ impl<'db> ProtocolInterface<'db> { member .meta_access(db)? .write - .and_then(|write| write.bind_self(db, receiver_ty)), + .and_then(|write| write.bind_compatibility_type(db, receiver_ty)), member.qualifiers(), )) }) @@ -535,6 +544,166 @@ impl<'db> ProtocolInterface<'db> { } } +/// A protocol member's write capability. +/// +/// Descriptor setters retain their call contract even when their accepted values cannot be +/// represented by a single [`Type`]. This keeps an unrepresentable domain distinct from an absent +/// setter and lets real assignments use normal call binding. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +enum ProtocolMemberWrite<'db> { + Type(ProtocolMemberType<'db>), + Descriptor { + descriptor: ProtocolMemberType<'db>, + domain: Option>, + }, +} + +impl<'db> ProtocolMemberWrite<'db> { + const fn from_type(member: ProtocolMemberType<'db>) -> Self { + Self::Type(member) + } + + fn descriptor( + descriptor_ty: Type<'db>, + domain: Option>, + definition: Option>, + ) -> Self { + Self::Descriptor { + descriptor: ProtocolMemberType::with_definition(descriptor_ty, definition), + domain: domain.map(|ty| ProtocolMemberType::with_definition(ty, definition)), + } + } + + const fn domain(self) -> Option> { + match self { + Self::Type(member) => Some(member), + Self::Descriptor { domain, .. } => domain, + } + } + + const fn descriptor_type(self) -> Option> { + match self { + Self::Type(_) => None, + Self::Descriptor { descriptor, .. } => Some(descriptor), + } + } + + fn display_type(self, db: &'db dyn Db) -> Option> { + match self { + Self::Type(member) => member.resolve(db), + Self::Descriptor { + domain: Some(domain), + .. + } => domain.resolve(db), + Self::Descriptor { domain: None, .. } => Some(ProtocolMemberType::new(Type::unknown())), + } + } + + fn bind_requirement( + self, + db: &'db dyn Db, + self_type: Type<'db>, + ) -> Option> { + match self { + Self::Type(member) => Some(ProtocolMemberWriteRequirement::AssignableTo( + member.bind_self(db, self_type)?, + )), + Self::Descriptor { descriptor, domain } => { + Some(ProtocolMemberWriteRequirement::Descriptor { + descriptor_ty: descriptor.bind_self(db, self_type)?, + receiver_ty: self_type, + domain: domain.and_then(|domain| domain.bind_self(db, self_type)), + }) + } + } + } + + fn bind_compatibility_type(self, db: &'db dyn Db, self_type: Type<'db>) -> Option> { + match self { + Self::Type(member) => member.bind_self(db, self_type), + Self::Descriptor { domain, .. } => Some( + domain + .and_then(|domain| domain.bind_self(db, self_type)) + .unwrap_or_else(Type::unknown), + ), + } + } + + fn cycle_normalized(self, db: &'db dyn Db, previous: Self, cycle: &salsa::Cycle) -> Self { + match (self, previous) { + (Self::Type(current), Self::Type(previous)) => { + Self::Type(current.cycle_normalized(db, previous, cycle)) + } + ( + Self::Descriptor { + descriptor: current_descriptor, + domain: current_domain, + }, + Self::Descriptor { + descriptor: previous_descriptor, + domain: previous_domain, + }, + ) => Self::Descriptor { + descriptor: current_descriptor.cycle_normalized(db, previous_descriptor, cycle), + domain: cycle_normalized_optional_type(db, current_domain, previous_domain, cycle), + }, + (current, _) => current, + } + } + + fn cycle_normalized_without_previous(self, db: &'db dyn Db, cycle: &salsa::Cycle) -> Self { + let normalize = |member: ProtocolMemberType<'db>| { + member.with_ty(member.ty().recursive_type_normalized(db, cycle)) + }; + match self { + Self::Type(member) => Self::Type(normalize(member)), + Self::Descriptor { descriptor, domain } => Self::Descriptor { + descriptor: normalize(descriptor), + domain: domain.map(normalize), + }, + } + } + + fn recursive_type_normalized_impl( + self, + db: &'db dyn Db, + div: Type<'db>, + nested: bool, + ) -> Option { + Some(match self { + Self::Type(member) => { + Self::Type(member.recursive_type_normalized_impl(db, div, nested)?) + } + Self::Descriptor { descriptor, domain } => Self::Descriptor { + descriptor: descriptor.recursive_type_normalized_impl(db, div, nested)?, + domain: match domain { + Some(domain) => Some(domain.recursive_type_normalized_impl(db, div, nested)?), + None => None, + }, + }, + }) + } + + fn apply_type_mapping_impl<'a>( + self, + db: &'db dyn Db, + type_mapping: &TypeMapping<'a, 'db>, + tcx: TypeContext<'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> Self { + match self { + Self::Type(member) => { + Self::Type(member.apply_type_mapping_impl(db, type_mapping, tcx, visitor)) + } + Self::Descriptor { descriptor, domain } => Self::Descriptor { + descriptor: descriptor.apply_type_mapping_impl(db, type_mapping, tcx, visitor), + domain: domain + .map(|domain| domain.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), + }, + } + } +} + impl<'db> VarianceInferable<'db> for ProtocolInterface<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>) -> TypeVarVariance { self.members(db) @@ -684,7 +853,7 @@ impl<'db> ProtocolMemberType<'db> { /// an instance cannot write a `ClassVar`, while a normal instance attribute has no class access. struct ProtocolMemberAccess<'db> { read: Option>, - write: Option>, + write: Option>, } impl<'db> ProtocolMemberAccess<'db> { @@ -695,7 +864,7 @@ impl<'db> ProtocolMemberAccess<'db> { const fn new( read: Option>, - write: Option>, + write: Option>, ) -> Self { Self { read, write } } @@ -707,6 +876,7 @@ impl<'db> ProtocolMemberAccess<'db> { .into_iter() .chain( self.write + .and_then(ProtocolMemberWrite::domain) .and_then(|member| member.resolve(db)) .map(|member| (member.ty(), TypeVarVariance::Contravariant)), ) @@ -781,7 +951,7 @@ impl<'db> ProtocolMemberData<'db> { fn property( read: Option>, - write: Option>, + write: Option>, definition: Option>, ) -> Self { Self { @@ -836,12 +1006,14 @@ impl<'db> ProtocolMemberData<'db> { ProtocolMemberCapabilities { instance: ProtocolMemberAccess::new( Some(member_ty), - (!is_class_var && !is_final && !is_todo).then_some(member_ty), + (!is_class_var && !is_final && !is_todo) + .then_some(ProtocolMemberWrite::from_type(member_ty)), ), class: if is_class_var { ProtocolMemberAccess::new( Some(member_ty), - (!is_final && !is_todo).then_some(member_ty), + (!is_final && !is_todo) + .then_some(ProtocolMemberWrite::from_type(member_ty)), ) } else { ProtocolMemberAccess::NONE @@ -920,7 +1092,7 @@ impl<'db> ProtocolMemberData<'db> { if let Some(read) = read.and_then(|read| read.resolve(self.db)) { d.field("read", &format_args!("`{}`", read.ty().display(self.db))); } - if let Some(write) = write.and_then(|write| write.resolve(self.db)) { + if let Some(write) = write.and_then(|write| write.display_type(self.db)) { d.field("write", &format_args!("`{}`", write.ty().display(self.db))); } d.finish() @@ -950,7 +1122,7 @@ enum ProtocolMemberKind<'db> { Method(ProtocolMemberType<'db>, ProtocolMethodKind), Property { read: Option>, - write: Option>, + write: Option>, }, Attribute(ProtocolMemberType<'db>), } @@ -965,9 +1137,13 @@ enum ProtocolMethodKind { impl<'db> ProtocolMemberKind<'db> { fn member_types(self) -> impl Iterator> { match self { - Self::Method(member, _) => [Some(member), None], - Self::Property { read, write } => [read, write], - Self::Attribute(attribute) => [Some(attribute), None], + Self::Method(method, _) => [Some(method), None, None], + Self::Property { read, write } => [ + read, + write.and_then(ProtocolMemberWrite::domain), + write.and_then(ProtocolMemberWrite::descriptor_type), + ], + Self::Attribute(attribute) => [Some(attribute), None, None], } .into_iter() .flatten() @@ -1008,7 +1184,15 @@ impl<'db> ProtocolMemberKind<'db> { }, ) => Self::Property { read: cycle_normalized_optional_type(db, current_read, previous_read, cycle), - write: cycle_normalized_optional_type(db, current_write, previous_write, cycle), + write: match (current_write, previous_write) { + (Some(current), Some(previous)) => { + Some(current.cycle_normalized(db, previous, cycle)) + } + (Some(current), None) => { + Some(current.cycle_normalized_without_previous(db, cycle)) + } + (None, _) => None, + }, }, (Self::Attribute(current), Self::Attribute(previous)) => { Self::Attribute(current.cycle_normalized(db, previous, cycle)) @@ -1329,6 +1513,8 @@ fn descriptor_decorated_protocol_member<'db>( protocol: ClassType<'db>, definition: Option>, ) -> Option> { + let descriptor_ty = descriptor_ty.resolve_type_alias(db); + // Applying a generic descriptor decorator to a method that refers to an enclosing type // variable can currently materialize that variable as `Unknown`. Reducing the descriptor to // its `__get__` result would then erase the remaining descriptor structure and weaken the @@ -1352,77 +1538,190 @@ fn descriptor_decorated_protocol_member<'db>( descriptor_ty.try_call_dunder_get(db, Some(receiver_ty), receiver_ty.to_meta_type(db))?; let read = Some(ProtocolMemberType::with_definition(read_ty, definition)); - let write = if let Place::Defined(DefinedPlace { + let write = match descriptor_setter_domain(db, descriptor_ty, receiver_ty) { + DescriptorSetterDomain::Missing => None, + DescriptorSetterDomain::Known(domain) => Some(ProtocolMemberWrite::descriptor( + descriptor_ty, + Some(domain), + definition, + )), + DescriptorSetterDomain::Deferred => Some(ProtocolMemberWrite::descriptor( + descriptor_ty, + None, + definition, + )), + }; + + Some(ProtocolMemberData::property(read, write, definition)) +} + +#[derive(Copy, Clone)] +enum DescriptorSetterDomain<'db> { + Missing, + Known(Type<'db>), + Deferred, +} + +/// Derive the values accepted by every possible descriptor setter when they fit in [`Type`]. +fn descriptor_setter_domain<'db>( + db: &'db dyn Db, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> DescriptorSetterDomain<'db> { + match descriptor_ty { + Type::Union(union) => { + let mut write_types = Vec::with_capacity(union.elements(db).len()); + for descriptor_ty in union.elements(db) { + match single_descriptor_setter_domain(db, *descriptor_ty, receiver_ty) { + DescriptorSetterDomain::Missing => return DescriptorSetterDomain::Missing, + DescriptorSetterDomain::Known(write_ty) => write_types.push(write_ty), + DescriptorSetterDomain::Deferred => return DescriptorSetterDomain::Deferred, + } + } + IntersectionType::bounded_from_elements(db, write_types).map_or( + DescriptorSetterDomain::Deferred, + DescriptorSetterDomain::Known, + ) + } + _ => single_descriptor_setter_domain(db, descriptor_ty, receiver_ty), + } +} + +/// Derive the values accepted by one possible runtime descriptor. +fn single_descriptor_setter_domain<'db>( + db: &'db dyn Db, + descriptor_ty: Type<'db>, + receiver_ty: Type<'db>, +) -> DescriptorSetterDomain<'db> { + let Place::Defined(DefinedPlace { ty: setter_ty, definedness: Definedness::AlwaysDefined, .. }) = descriptor_ty - .class_member_with_policy(db, "__set__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .member_lookup_with_policy( + db, + "__set__".into(), + MemberLookupPolicy::REQUIRE_CONCRETE | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) .place - { - Some(ProtocolMemberType::with_definition( - descriptor_setter_write_type(db, setter_ty, descriptor_ty, receiver_ty) - .unwrap_or_else(Type::unknown), - definition, - )) - } else { - None + else { + return DescriptorSetterDomain::Missing; }; - Some(ProtocolMemberData::property(read, write, definition)) + let Some(callables) = setter_ty.try_upcast_to_callable(db) else { + return DescriptorSetterDomain::Deferred; + }; + let mut callable_domains = Vec::with_capacity(callables.iter().len()); + for callable in &callables { + let mut write_types = Vec::new(); + for signature in callable.signatures(db) { + match descriptor_setter_signature_domain(db, signature, descriptor_ty, receiver_ty) { + DescriptorSetterSignatureDomain::Inapplicable => {} + DescriptorSetterSignatureDomain::Known(write_ty) => write_types.push(write_ty), + DescriptorSetterSignatureDomain::Deferred => { + return DescriptorSetterDomain::Deferred; + } + } + } + callable_domains.push(UnionType::from_elements(db, write_types)); + } + IntersectionType::bounded_from_elements(db, callable_domains).map_or( + DescriptorSetterDomain::Deferred, + DescriptorSetterDomain::Known, + ) +} + +enum DescriptorSetterSignatureDomain<'db> { + Inapplicable, + Known(Type<'db>), + Deferred, } -/// Derive a write type from a single ordinary setter signature. -fn descriptor_setter_write_type<'db>( +/// Derive the values accepted by one `__set__` overload when they fit in [`Type`]. +fn descriptor_setter_signature_domain<'db>( db: &'db dyn Db, - setter_ty: Type<'db>, + signature: &Signature<'db>, descriptor_ty: Type<'db>, receiver_ty: Type<'db>, -) -> Option> { - let callable = setter_ty.try_upcast_to_callable(db)?.exactly_one()?; - let signatures = callable.signatures(db); - let [signature] = signatures.overloads.as_ref() else { - return None; +) -> DescriptorSetterSignatureDomain<'db> { + let parameters = signature.parameters(); + let missing_required_parameter = || { + if parameters.is_gradual() || parameters.as_slice().iter().any(Parameter::is_variadic) { + DescriptorSetterSignatureDomain::Deferred + } else { + DescriptorSetterSignatureDomain::Inapplicable + } }; - - // A method type variable cannot be used as the write type directly: it is inferred separately - // for each call. Deriving its accepted values requires generic call analysis. - if signature.generic_context.is_some_and(|generic_context| { - generic_context.variables(db).any(|typevar| { - !typevar.typevar(db).is_self(db) - && typevar - .binding_context(db) - .definition() - .is_some_and(|definition| definition.kind(db).is_function_def()) - }) + let Some(trailing_parameters) = parameters.as_slice().get(2..) else { + return missing_required_parameter(); + }; + if !trailing_parameters.iter().all(|parameter| { + parameter.default_type().is_some() + || ((parameters.is_standard() || parameters.is_gradual()) + && (parameter.is_variadic() || parameter.is_keyword_variadic())) }) { - return None; + return DescriptorSetterSignatureDomain::Inapplicable; } - let parameters = signature.parameters(); - if parameters.len() != 3 { - return None; - } - let descriptor_parameter = parameters - .get_positional(0)? + let Some(receiver_parameter) = parameters.get_positional(0) else { + return missing_required_parameter(); + }; + let receiver_parameter = receiver_parameter .annotated_type() .bind_self_typevars(db, descriptor_ty); - let receiver_parameter = parameters - .get_positional(1)? + if contains_signature_typevar(db, signature, receiver_parameter) { + return DescriptorSetterSignatureDomain::Deferred; + } + if !receiver_ty.is_assignable_to(db, receiver_parameter) { + return DescriptorSetterSignatureDomain::Inapplicable; + } + + let Some(write_parameter) = parameters.get_positional(1) else { + return missing_required_parameter(); + }; + let write_ty = write_parameter .annotated_type() .bind_self_typevars(db, descriptor_ty); - if !descriptor_ty.is_assignable_to(db, descriptor_parameter) - || !receiver_ty.is_assignable_to(db, receiver_parameter) + if !contains_signature_typevar(db, signature, write_ty) { + return DescriptorSetterSignatureDomain::Known(write_ty); + } + + let Type::TypeVar(typevar) = write_ty else { + return DescriptorSetterSignatureDomain::Deferred; + }; + let Some(generic_context) = signature.generic_context else { + return DescriptorSetterSignatureDomain::Deferred; + }; + if !generic_context.contains(db, typevar.identity(db)) + || !typevar + .binding_context(db) + .definition() + .is_some_and(|definition| definition.kind(db).is_function_def()) { - return None; + return DescriptorSetterSignatureDomain::Deferred; } - Some( - parameters - .get_positional(2)? - .annotated_type() - .bind_self_typevars(db, descriptor_ty), - ) + match typevar.typevar(db).bound_or_constraints(db) { + None => DescriptorSetterSignatureDomain::Known(Type::object()), + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + DescriptorSetterSignatureDomain::Known(bound.bind_self_typevars(db, descriptor_ty)) + } + Some(TypeVarBoundOrConstraints::Constraints(_)) => { + DescriptorSetterSignatureDomain::Deferred + } + } +} + +fn contains_signature_typevar<'db>( + db: &'db dyn Db, + signature: &Signature<'db>, + ty: Type<'db>, +) -> bool { + signature.generic_context.is_some_and(|generic_context| { + super::visitor::any_over_type(db, ty, true, |ty| { + matches!(ty, Type::TypeVar(typevar) if generic_context.contains(db, typevar.identity(db))) + }) + }) } fn property_set_type<'db>( @@ -1549,13 +1848,19 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } AttributeWriteRequirement::Unconstrained => self.always(), AttributeWriteRequirement::CannotAssign => self.never(), - AttributeWriteRequirement::Module(Some(write_ty)) - | AttributeWriteRequirement::ProtocolMember { - write_ty: Some(write_ty), + AttributeWriteRequirement::Module(Some(write_ty)) => { + self.check_type_pair(db, value_ty, *write_ty) + } + AttributeWriteRequirement::ProtocolMember { + write: Some(ProtocolMemberWriteRequirement::AssignableTo(write_ty)), .. } => self.check_type_pair(db, value_ty, *write_ty), + AttributeWriteRequirement::ProtocolMember { + write: Some(ProtocolMemberWriteRequirement::Descriptor { domain, .. }), + .. + } => self.check_type_pair(db, value_ty, domain.unwrap_or_else(Type::unknown)), AttributeWriteRequirement::Module(None) - | AttributeWriteRequirement::ProtocolMember { write_ty: None, .. } => self.never(), + | AttributeWriteRequirement::ProtocolMember { write: None, .. } => self.never(), AttributeWriteRequirement::Instance { object_ty, member } => { self.check_instance_property_write(db, *object_ty, member, member_name, value_ty) } @@ -1964,7 +2269,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { read_result.and(db, self.constraints, || { required.write.map_or_else( || self.always(), - |write_ty| { + |write| { let fallback_ty = ty.literal_fallback_instance(db).unwrap_or(ty); let receiver_ty = if access == ProtocolMemberAccessMode::Instance && matches!(ty, Type::LiteralValue(_)) @@ -1973,10 +2278,9 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } else { receiver_ty }; - write_ty.bind_self(db, fallback_ty).when_some_and( - db, - self.constraints, - |write_ty| { + write + .bind_compatibility_type(db, fallback_ty) + .when_some_and(db, self.constraints, |write_ty| { let result = self.check_property_write(db, receiver_ty, member.name, write_ty); if let Some(context) = self.report_context() @@ -1987,8 +2291,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }); } result - }, - ) + }) }, ) }) @@ -2205,8 +2508,8 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } (Some(source), Some(target)) => { let (Some(target), Some(source)) = ( - target.bind_self(db, source_type), - source.bind_self(db, source_type), + target.bind_compatibility_type(db, source_type), + source.bind_compatibility_type(db, source_type), ) else { return self.never(); }; @@ -2521,7 +2824,10 @@ fn cached_protocol_interface<'db>( let member = match ty { Type::PropertyInstance(property) => ProtocolMemberData::property( property.getter(db).map(ProtocolMemberType::property_getter), - property.setter(db).map(ProtocolMemberType::property_setter), + property + .setter(db) + .map(ProtocolMemberType::property_setter) + .map(ProtocolMemberWrite::from_type), definition, ), Type::Callable(callable) From db195f3a494c55feeef225daa5de0b803a5253ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Thu, 16 Jul 2026 00:29:54 -0700 Subject: [PATCH 107/222] [ty] Render Google docstrings as structured Markdown (#26599) ## Summary This introduces Markdown rendering for Google-style sections in docstrings. Supported sections are rendered with canonical Markdown headings, bold item names, inline code spans for types and raised exceptions, and descriptions passed through the existing general Markdown renderer. Here's what it looks like in VSCode: | Before | After | | :--- | :--- | | before-1 before-2 before-3 | after-1 after-2 after-3 | The implementation includes the following judgement calls that I think are acceptable, but that we may want to revisit based on user feedback: - We only render Markdown headings for a fixed subset of Google sections that map cleanly to a shared document model: parameters, attributes, returns, yields, and raises. That list can be expanded in the future. - Return and yield bodies are rendered as prose rather than split into type and description. Google-style docstrings do not require a single unambiguous shape for these sections, so preserving their content avoids guessing. - We carry forward a few of the rendering policies that were [established for the reStructuredText format](): - Malformed, unsupported, or ambiguous content still causes us to leave an entire section raw (i.e. source content, not structured Markdown). Other well-formed sections in the same docstring are rendered as usual. - Types are rendered with inline code spans, and so do not receive syntax highlighting. - Physical line breaks in descriptions are preserved as Markdown hard breaks rather than being reflowed. I recommend reviewing this diff commit-by-commit. /xref https://github.com/astral-sh/ty/issues/1667 ## Test Plan See included tests. --- crates/ty_ide/src/docstring.rs | 100 ++--- crates/ty_ide/src/docstring/document.rs | 4 +- .../ty_ide/src/docstring/document/google.rs | 341 +++++++++++++---- .../src/docstring/markdown/structured.rs | 55 ++- .../docstring/markdown/structured/google.rs | 344 ++++++++++++++++++ crates/ty_ide/src/hover.rs | 39 +- 6 files changed, 759 insertions(+), 124 deletions(-) create mode 100644 crates/ty_ide/src/docstring/markdown/structured/google.rs diff --git a/crates/ty_ide/src/docstring.rs b/crates/ty_ide/src/docstring.rs index 697f03ae02..40a8a91da1 100644 --- a/crates/ty_ide/src/docstring.rs +++ b/crates/ty_ide/src/docstring.rs @@ -841,16 +841,17 @@ Summary. let docstring = Docstring::new(docstring.to_owned()); assert_snapshot!(docstring.render_markdown(), @" - My cool func... - - Returns: -     Some details + My cool func... + + ## Returns + Some details + `````python - x_y = thing_do(); - ``` # this should't close the fence! - a_b = other_thing(); + x_y = thing_do(); + ``` # this should't close the fence! + a_b = other_thing(); ````` -     And so on. + And so on. "); } @@ -876,16 +877,17 @@ Summary. let docstring = Docstring::new(docstring.to_owned()); assert_snapshot!(docstring.render_markdown(), @" - My cool func... - - Returns: -     Some details + My cool func... + + ## Returns + Some details + ~~~~~~python - x_y = thing_do(); - ~~~ # this should't close the fence! - a_b = other_thing(); + x_y = thing_do(); + ~~~ # this should't close the fence! + a_b = other_thing(); ~~~~~~ -     And so on. + And so on. "); } @@ -1284,16 +1286,21 @@ Summary. "); assert_snapshot!(docstring.render_markdown(), @" - This is a function description. - - Args: -     param1 (str): The first parameter description -     param2 (int): The second parameter description -         This is a continuation of param2 description. -     param3: A parameter without type annotation - - Returns: -     str: The return value description + This is a function description. + + ## Arguments + **param1**: `str` + The first parameter description + + **param2**: `int` + The second parameter description + This is a continuation of param2 description. + + **param3** + A parameter without type annotation + + ## Returns + str: The return value description "); } @@ -1495,12 +1502,15 @@ Summary. "); assert_snapshot!(docstring.render_markdown(), @" - This is a function description. - - Args: -     param1 (str): Google-style parameter -     param2 (int): Another Google-style parameter - + This is a function description. + + ## Arguments + **param1**: `str` + Google-style parameter + + **param2**: `int` + Another Google-style parameter + Parameters ---------- param3 : bool @@ -1649,11 +1659,14 @@ Summary. "); assert_snapshot!(docstring.render_markdown(), @" - This is a function description. - - Args: -     param1 (str): Google-style parameter -     param2 (int): Google-style duplicate parameter + This is a function description. + + ## Arguments + **param1**: `str` + Google-style parameter + + **param2**: `int` + Google-style duplicate parameter ## Parameters **param2**: `int` @@ -1854,11 +1867,14 @@ Summary. let markdown = docstring_windows.render_markdown(); assert_snapshot!(markdown.as_str(), @" - This is a function description. - - Args: -     param1 (str): The first parameter -     param2 (int): The second parameter + This is a function description. + + ## Arguments + **param1**: `str` + The first parameter + + **param2**: `int` + The second parameter "); assert_eq!(docstring_mac.render_markdown(), markdown); assert_eq!(docstring_unix.render_markdown(), markdown); diff --git a/crates/ty_ide/src/docstring/document.rs b/crates/ty_ide/src/docstring/document.rs index fdae0721e0..8b630a7201 100644 --- a/crates/ty_ide/src/docstring/document.rs +++ b/crates/ty_ide/src/docstring/document.rs @@ -1,11 +1,9 @@ use indexmap::IndexMap; use strum_macros::EnumIter; -/// Google-style docstring parsing. -mod google; +pub(super) mod google; pub(super) mod preformatted; pub(super) mod rst; -/// Syntax utilities shared by docstring format parsers and renderers. pub(in crate::docstring) mod syntax; /// Returns docs for all parameters recognized in the given docstring. diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 8802ebf0db..09e22ba36d 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -8,8 +8,9 @@ //! - `Keyword Args` and `Keyword Arguments` //! - `Other Args`, `Other Arguments`, and `Other Parameters` //! -//! It accepts comma-separated Python names with optional parenthesized types, preserves -//! continuation text, and skips section-like text inside preformatted or container blocks. +//! It accepts comma-separated Python names with optional parenthesized types, recovers common +//! conjunction-separated names, preserves continuation text, and skips section-like text inside +//! preformatted or container blocks. //! Supported section bodies are parsed into prose and named-item fragments; other known headings //! only delimit sections. //! @@ -38,7 +39,7 @@ use super::SectionKind; use super::preformatted::PreformattedBlockScanner; use super::syntax::{ ParsedLine, consume_quoted_string, container_block_end, indentation, parsed_lines, - split_once_at_top_level_colon, split_trailing_parenthetical, + split_once_at_top_level_colon, split_trailing_parenthetical, starts_with_markdown_list_item, }; /// Returns parameter documentation from recognized Google-style parameter sections. @@ -50,14 +51,14 @@ pub(super) fn parameter_documentation(normalized_source: &str) -> IndexMap impl Iterator, + body: SectionBody, +} + +impl Section { + /// Returns the section kind. + pub(in crate::docstring) const fn kind(&self) -> SectionKind { + self.kind + } + + /// Returns the section's source range. + pub(in crate::docstring) const fn range(&self) -> TextRange { + self.range + } + + /// Consumes this section and returns its fragments when it can be rendered structurally. + pub(in crate::docstring) fn into_renderable_fragments(self) -> Option> { + let SectionBody::Parsed { + fragments, + has_structural_ambiguity, + } = self.body + else { + return None; + }; + (!has_structural_ambiguity).then_some(fragments) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SectionBody { + /// A body parsed into semantic fragments. + Parsed { + fragments: Vec, + /// Whether the body's structure is ambiguous. + has_structural_ambiguity: bool, + }, + /// A body whose contents were not parsed. + Opaque, +} + +impl SectionBody { + /// Creates an unambiguous body containing the description as a single prose fragment. + fn from_prose(description: String) -> Self { + let fragments = (!description.is_empty()) + .then_some(BodyFragment::Prose(description)) + .into_iter() + .collect(); + Self::Parsed { + fragments, + has_structural_ambiguity: false, + } + } + + fn into_fragments(self) -> Vec { + match self { + Self::Parsed { fragments, .. } => fragments, + Self::Opaque => Vec::new(), + } + } } /// One parsed fragment in a Google section body. #[derive(Debug, Clone, PartialEq, Eq)] -enum BodyFragment { +pub(in crate::docstring) enum BodyFragment { /// Section-level prose that is not attached to a named item. Prose(String), /// A named item and its description. @@ -90,12 +148,21 @@ enum BodyFragment { /// A named item in a Google section. #[derive(Debug, Clone, PartialEq, Eq)] -struct Item { +pub(in crate::docstring) struct Item { display_name: String, ty: Option, description: String, } +impl Item { + /// Consumes this item and returns its display parts. + pub(in crate::docstring) fn into_display_name_type_and_description( + self, + ) -> (String, Option, String) { + (self.display_name, self.ty, self.description) + } +} + /// Splits a display name from a trailing parenthesized type. /// /// For example, `"value (str)"` yields `("value", Some("str"))`. @@ -147,6 +214,55 @@ fn is_parameter_name(name: &str) -> bool { is_identifier(identifier) } +/// A parameter name that has been parsed into comma- and conjunction- separated parts. +#[derive(Clone, Copy)] +struct ParameterDisplayName<'a> { + comma_separated_names: &'a str, + final_name: Option<&'a str>, +} + +impl<'a> ParameterDisplayName<'a> { + /// Parses comma-separated parameter names, optionally joined by a final conjunction. + /// + /// For example, `"stdin, stdout and stderr"` yields the three individual names. + fn parse(display_name: &'a str) -> Option { + let comma_separated = Self { + comma_separated_names: display_name, + final_name: None, + }; + if comma_separated.names().all(is_parameter_name) { + return Some(comma_separated); + } + + for conjunction in [" and ", " or "] { + let Some((comma_separated_names, final_name)) = display_name.rsplit_once(conjunction) + else { + continue; + }; + let conjunction_separated = Self { + comma_separated_names, + final_name: Some(final_name), + }; + if conjunction_separated.names().all(is_parameter_name) { + return Some(conjunction_separated); + } + } + + None + } + + fn names(self) -> impl Iterator { + self.comma_separated_names + .split(',') + .chain(self.final_name) + .map(str::trim) + } + + const fn is_conjunction_separated(self) -> bool { + self.final_name.is_some() + } +} + #[derive(Default)] struct Parameters(IndexMap); @@ -164,7 +280,10 @@ impl Parameters { if description.is_empty() { continue; } - for name in display_name.split(',').map(str::trim) { + let Some(display_name) = ParameterDisplayName::parse(&display_name) else { + continue; + }; + for name in display_name.names() { self.0.insert(name.to_string(), description.clone()); } } @@ -327,7 +446,13 @@ impl<'a> SectionBuilder<'a> { // Third, classify a nonblank line and stop if it begins content outside // this section. - let item_line = ItemLine::classify(self.section_header.kind, line); + let also_parses_as_section_header = + line_header.is_some() && line.text.trim().starts_with(char::is_uppercase); + let item_line = ItemLine::classify( + self.section_header.kind, + line, + also_parses_as_section_header, + ); let has_leading_blank_lines = !self.pending_blank_lines.is_empty(); if self.should_end_before( line, @@ -411,18 +536,18 @@ impl<'a> SectionBuilder<'a> { fn push_content_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { self.range = self.range.cover(line.range); - self.body.push_line(line, item_line); + self.body.push_line(self.section_header, line, item_line); } fn finish(self) -> Option
{ let HeaderKind::Structured(kind) = self.section_header.kind else { return None; }; - let fragments = self.body.finish(); + Some(Section { kind, range: self.range, - fragments, + body: self.body.finish(), }) } } @@ -517,25 +642,19 @@ impl<'a> BodyBuilder<'a> { } } - fn push_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { + fn push_line(&mut self, section_header: Header, line: ParsedLine<'a>, item_line: ItemLine<'a>) { match self { - Self::ItemList(body) => body.push_line(line, item_line), + Self::ItemList(builder) => builder.push_line(section_header, line, item_line), Self::Prose(builder) => builder.push_line(line.text), Self::Opaque => {} } } - fn finish(self) -> Vec { + fn finish(self) -> SectionBody { match self { - Self::ItemList(body) => body.finish(), - Self::Prose(description) => { - let prose = description.finish(); - (!prose.is_empty()) - .then_some(BodyFragment::Prose(prose)) - .into_iter() - .collect() - } - Self::Opaque => Vec::new(), + Self::ItemList(builder) => builder.finish(), + Self::Prose(builder) => SectionBody::from_prose(builder.finish()), + Self::Opaque => SectionBody::Opaque, } } } @@ -548,6 +667,8 @@ struct ItemListBuilder<'a> { leading_prose: DescriptionBuilder<'a>, /// Indentation established by the first renderable item. item_indent: Option, + /// Whether the body's structure is ambiguous. + has_structural_ambiguity: bool, } impl<'a> ItemListBuilder<'a> { @@ -559,7 +680,7 @@ impl<'a> ItemListBuilder<'a> { } } - fn push_line(&mut self, line: ParsedLine<'a>, item_line: ItemLine<'a>) { + fn push_line(&mut self, section_header: Header, line: ParsedLine<'a>, item_line: ItemLine<'a>) { let line_indent = indentation(line.text); if self .item_indent @@ -570,12 +691,21 @@ impl<'a> ItemListBuilder<'a> { self.finish_current_item(); self.current_item = Some(ItemBuilder::new(&item_header)); self.item_indent.get_or_insert(line_indent); + self.has_structural_ambiguity |= + item_line.also_parses_as_section_header || item_header.has_structural_ambiguity; return; } + if let Some(item_indent) = self.item_indent + && !item_line.can_render_as_continuation(section_header.kind, line_indent, item_indent) + { + self.has_structural_ambiguity = true; + } + if let Some(item) = &mut self.current_item { item.description.push_continuation(line.text); } else { + self.has_structural_ambiguity = true; self.leading_prose.push_line(line.text); } } @@ -593,10 +723,13 @@ impl<'a> ItemListBuilder<'a> { } } - fn finish(mut self) -> Vec { + fn finish(mut self) -> SectionBody { self.finish_leading_prose(); self.finish_current_item(); - self.fragments + SectionBody::Parsed { + fragments: self.fragments, + has_structural_ambiguity: self.has_structural_ambiguity, + } } } @@ -640,7 +773,12 @@ impl<'a> DescriptionBuilder<'a> { } fn push_line(&mut self, line: &'a str) { - if self.inline.is_none() && self.continuation_lines.is_empty() { + // Keep a leading list item with the block so that its indentation establishes the baseline + // for nested items. Ordinary first lines use the allocation-free inline representation. + if self.inline.is_none() + && self.continuation_lines.is_empty() + && !starts_with_markdown_list_item(line.trim_start()) + { self.inline = Some(line.trim()); } else { self.push_continuation(line); @@ -692,10 +830,33 @@ struct ItemLine<'a> { /// Whether this line establishes item indentation for section-boundary detection. boundary_item: bool, item_header: Option>, + /// Whether this line resembles an item but is actually a URL or path continuation. + is_item_like_continuation: bool, + /// Whether this item could instead introduce a new section. + also_parses_as_section_header: bool, } impl<'a> ItemLine<'a> { - fn classify(section_kind: HeaderKind, line: ParsedLine<'a>) -> Self { + fn can_render_as_continuation( + &self, + section_kind: HeaderKind, + line_indent: TextSize, + item_indent: TextSize, + ) -> bool { + // More deeply indented lines are unambiguously part of the current item. + line_indent > item_indent + // Although the style guide suggests indenting continuation lines, + // aligned parameter prose is common in practice. + || (line_indent == item_indent && section_kind.is_parameter_section()) + // Aligned URLs and paths are continuations despite resembling item headers. + || (line_indent == item_indent && self.is_item_like_continuation) + } + + fn classify( + section_kind: HeaderKind, + line: ParsedLine<'a>, + also_parses_as_section_header: bool, + ) -> Self { let HeaderKind::Structured(kind) = section_kind else { return Self::default(); }; @@ -707,40 +868,56 @@ impl<'a> ItemLine<'a> { } let line_text = line.text.trim(); - let split = split_once_at_field_delimiter(line_text).or_else(|| { - if matches!( - kind, - SectionKind::Parameters - | SectionKind::KeywordArguments - | SectionKind::OtherParameters - ) { - // If malformed type syntax hides the delimiter, recover the conventional field - // shape and discard the type. - recover_parameter_without_type(line_text) - } else { - None - } - }); - split - .map(|split| Self::from_split(kind, split)) - .unwrap_or_default() + let (split, type_was_discarded) = if let Some(split) = + split_once_at_field_delimiter(line_text) + { + (split, false) + } else if matches!( + kind, + SectionKind::Parameters | SectionKind::KeywordArguments | SectionKind::OtherParameters + ) { + // If malformed type syntax hides the delimiter, recover the conventional field shape + // and discard the type. The section remains opaque to the structured renderer. + let Some(split) = recover_parameter_without_type(line_text) else { + return Self::default(); + }; + (split, true) + } else { + return Self::default(); + }; + + Self::from_split( + kind, + split, + also_parses_as_section_header, + type_was_discarded, + ) } - fn from_split(kind: SectionKind, (raw_name, inline_description): (&'a str, &'a str)) -> Self { + fn from_split( + kind: SectionKind, + (raw_name, inline_description): (&'a str, &'a str), + also_parses_as_section_header: bool, + type_was_discarded: bool, + ) -> Self { let name = raw_name.trim(); if name.is_empty() { return Self::default(); } - let (display_name, ty) = match kind { + let (display_name, ty, display_name_has_structural_ambiguity) = match kind { SectionKind::Parameters | SectionKind::KeywordArguments | SectionKind::OtherParameters => { let (display_name, ty) = split_name_and_type(name); - if !is_parameter_display_name(display_name) { + let Some(parsed_display_name) = ParameterDisplayName::parse(display_name) else { return Self::default(); - } - (display_name, ty) + }; + ( + display_name, + ty, + parsed_display_name.is_conjunction_separated(), + ) } SectionKind::Attributes => { let (display_name, ty) = split_name_and_type(name); @@ -750,7 +927,7 @@ impl<'a> ItemLine<'a> { ..Self::default() }; } - (display_name, ty) + (display_name, ty, false) } SectionKind::Raises => { if !is_dotted_identifier(name) { @@ -759,18 +936,37 @@ impl<'a> ItemLine<'a> { ..Self::default() }; } - (name, None) + (name, None, false) } SectionKind::Returns | SectionKind::Yields => return Self::default(), }; + // URLs (`https://...`), Windows paths (`C:\\...`), and reST literal-block introductions + // (`Example::`) are description continuations rather than item headers. + // + // This was configured from a survey of such continuations in popular public projects that + // use Google-style docstrings; it may need to be reconfigured in the future. + if matches!( + inline_description.as_bytes().first(), + Some(b'/' | b'\\' | b':') + ) { + return Self { + is_item_like_continuation: true, + ..Self::default() + }; + } + Self { boundary_item: true, item_header: Some(ItemHeader { display_name, ty, inline_description, + has_structural_ambiguity: type_was_discarded + || display_name_has_structural_ambiguity, }), + is_item_like_continuation: false, + also_parses_as_section_header, } } } @@ -779,6 +975,8 @@ struct ItemHeader<'a> { display_name: &'a str, ty: Option<&'a str>, inline_description: &'a str, + /// Whether this header's noncanonical syntax makes its structure ambiguous. + has_structural_ambiguity: bool, } /// Splits at the field delimiter, skipping top-level colons in reST roles. @@ -847,12 +1045,6 @@ fn consume_rest_prefix_role(cursor: &mut Cursor<'_>) -> bool { true } -fn is_parameter_display_name(display_name: &str) -> bool { - display_name - .split(',') - .all(|name| is_parameter_name(name.trim())) -} - fn is_attribute_display_name(display_name: &str) -> bool { display_name .split(',') @@ -952,6 +1144,27 @@ Args: "); } + #[test] + fn extracts_conjunction_separated_parameter_names() { + let raw = "\ +Args: + stdin, stdout and stderr: Standard streams. + encoding or errors: Text settings."; + + assert_snapshot!(display_parameters(raw), @" + stdin: + │ Standard streams. + stdout: + │ Standard streams. + stderr: + │ Standard streams. + encoding: + │ Text settings. + errors: + │ Text settings. + "); + } + #[test] fn ignores_prose_before_first_parameter() { let raw = "\ @@ -1530,7 +1743,13 @@ Returns: Methods: helper: Method documentation."; let sections = sections(raw) - .map(|section| (section.kind, section.fragments, &raw[section.range])) + .map(|section| { + ( + section.kind, + section.body.into_fragments(), + &raw[section.range], + ) + }) .collect::>(); assert_eq!( diff --git a/crates/ty_ide/src/docstring/markdown/structured.rs b/crates/ty_ide/src/docstring/markdown/structured.rs index f7519c85a8..ac693750b8 100644 --- a/crates/ty_ide/src/docstring/markdown/structured.rs +++ b/crates/ty_ide/src/docstring/markdown/structured.rs @@ -8,6 +8,7 @@ use crate::docstring::document::SectionKind; use crate::docstring::document::preformatted::MarkdownFence; use crate::docstring::document::syntax::{is_markdown_code_span, starts_with_markdown_list_item}; +mod google; mod rst; /// Renders a docstring as Markdown. @@ -15,7 +16,9 @@ mod rst; /// `source` must have already undergone PEP-257 trimming and universal newline /// normalization (typically via `docstring::documentation_trim`). pub(super) fn render_into(output: &mut String, source: &str) { - render_sections_into(output, source, rst::structured_sections(source)); + let mut sections = rst::structured_sections(source); + sections.extend(google::structured_sections(source)); + render_sections_into(output, source, sections); } /// Renders a docstring from non-overlapping structured sections and general source fragments. @@ -116,6 +119,21 @@ fn ensure_blank_line(output: &mut String) { output.push('\n'); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParameterHeading { + Parameters, + Arguments, +} + +impl ParameterHeading { + const fn as_str(self) -> &'static str { + match self { + Self::Parameters => "Parameters", + Self::Arguments => "Arguments", + } + } +} + /// A parsed section ready for Markdown rendering. /// /// Parser modules create one of these for each supported source section or @@ -127,16 +145,29 @@ struct Section { range: TextRange, /// The list of semantically-meaningful items to render to Markdown. items: Vec, + parameter_heading: ParameterHeading, } impl Section { /// Creates a structured replacement from the items parsed out of one source section. fn new(range: TextRange, items: Vec) -> Option { + Self::new_with_parameter_heading(range, items, ParameterHeading::Parameters) + } + + fn new_with_parameter_heading( + range: TextRange, + items: Vec, + parameter_heading: ParameterHeading, + ) -> Option { if range.is_empty() || items.iter().any(SectionItem::is_empty) { return None; } - Some(Self { range, items }) + Some(Self { + range, + items, + parameter_heading, + }) } fn is_empty(&self) -> bool { @@ -147,9 +178,13 @@ impl Section { fn render_markdown(&self, output: &mut String) { let mut rendered_section = false; for kind in SectionKind::iter() { + let heading = match kind { + SectionKind::Parameters => self.parameter_heading.as_str(), + _ => kind.heading(), + }; if render_markdown_section( output, - kind.heading(), + heading, self.items.iter().filter(move |item| item.kind == kind), rendered_section, ) { @@ -190,6 +225,20 @@ impl SectionItem { } } + fn from_owned_parts( + kind: SectionKind, + display_name: Option, + ty: Option, + description_source: String, + ) -> Self { + Self { + kind, + display_name, + ty: ty.filter(|ty| !ty.is_empty()), + description_source, + } + } + /// Returns whether the item would render no user-visible Markdown. fn is_empty(&self) -> bool { self.display_name.is_none() && self.ty.is_none() && self.description_source.is_empty() diff --git a/crates/ty_ide/src/docstring/markdown/structured/google.rs b/crates/ty_ide/src/docstring/markdown/structured/google.rs new file mode 100644 index 0000000000..a8801e5daf --- /dev/null +++ b/crates/ty_ide/src/docstring/markdown/structured/google.rs @@ -0,0 +1,344 @@ +use crate::docstring::document::google; + +use super::{ParameterHeading, Section, SectionItem, SectionKind}; + +/// Returns Google-style sections that can be rendered structurally. +pub(super) fn structured_sections(normalized_source: &str) -> Vec
{ + google::sections(normalized_source) + .filter_map(section) + .collect() +} + +fn section(parsed: google::Section) -> Option
{ + let kind = parsed.kind(); + let range = parsed.range(); + let fragments = parsed.into_renderable_fragments()?; + if fragments.is_empty() { + return None; + } + let items = fragments + .into_iter() + .map(|fragment| section_item(kind, fragment)) + .collect(); + Section::new_with_parameter_heading(range, items, ParameterHeading::Arguments) +} + +fn section_item(kind: SectionKind, fragment: google::BodyFragment) -> SectionItem { + match fragment { + google::BodyFragment::Prose(description) => { + SectionItem::from_owned_parts(kind, None, None, description) + } + google::BodyFragment::Item(item) => { + let (display_name, ty, description) = item.into_display_name_type_and_description(); + SectionItem::from_owned_parts(kind, Some(display_name), ty, description) + } + } +} + +#[cfg(test)] +mod tests { + use insta::{Settings, assert_snapshot}; + + use super::super::render_sections_into; + use super::structured_sections; + + #[test] + fn renders_supported_sections() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Summary. + +Keyword Args: + optional (`int`): Optional value. + +Other Parameters: + timeout (float): Maximum wait. + +Attributes: + name (str): Display name. + +Yields: + The next `int` value. + +Raises: + ValueError: If invalid. +"; + + assert_snapshot!(render_google(docstring), @" + Summary. + + ## Keyword Arguments + **optional**: `int` + Optional value. + + ## Other Parameters + **timeout**: `float` + Maximum wait. + + ## Attributes + **name**: `str` + Display name. + + ## Yields + The next `int` value. + + ## Raises + `ValueError` + If invalid. + "); + } + + #[test] + fn renders_aligned_parameter_continuation() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Args: + value: The value. + For example: pass an absolute path. +"; + + assert_snapshot!(render_google(docstring), @" + ## Arguments + **value** + The value. + For example: pass an absolute path. + "); + } + + #[test] + fn renders_parameter_url_continuation() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Args: + endpoint: Service endpoint, for example: + https://example.com/api +"; + + assert_snapshot!(render_google(docstring), @" + ## Arguments + **endpoint** + Service endpoint, for example: + https://example.com/api + "); + } + + #[test] + fn renders_parameter_windows_path_continuation() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Args: + output: Output path, for example: + C:\\temp\\result.txt +"; + + assert_snapshot!(render_google(docstring), @r" + ## Arguments + **output** + Output path, for example: + C:\temp\result.txt + "); + } + + #[test] + fn renders_preformatted_parameter_descriptions() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Args: + value: Description. + ```python + Args: + nested: Still code. + Returns: + Still code. + ``` + url (Literal[\"http://\"]): URL. +"; + + assert_snapshot!(render_google(docstring), @r#" + ## Arguments + **value** + Description. + + ```python + Args: + nested: Still code. + Returns: + Still code. + ``` + + **url**: `Literal["http://"]` + URL. + "#); + } + + #[test] + fn renders_return_body_as_prose() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Returns: + int: The **count**. + str: The `name`. +"; + + assert_snapshot!(render_google(docstring), @" + ## Returns + int: The **count**. + str: The `name`. + "); + } + + #[test] + fn renders_nested_list_in_return_prose() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Returns: + - first + - nested +"; + + assert_snapshot!(render_google(docstring), @" + ## Returns + - first + - nested + "); + } + + #[test] + fn renders_fenced_block_in_return_prose() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Returns: + str: Example output. + ```python + Args: + still code. + ``` +"; + + assert_snapshot!(render_google(docstring), @" + ## Returns + str: Example output. + + ```python + Args: + still code. + ``` + "); + } + + #[test] + fn renders_nested_heading_in_return_prose() { + let _snap = bind_markdown_snapshot_filters(); + let docstring = "\ +Returns: + The result. + + Examples: + This heading is part of the description. +"; + + assert_snapshot!(render_google(docstring), @" + ## Returns + The result. + + Examples: + This heading is part of the description. + "); + } + + #[test] + fn declines_to_render_inline_section_heading_in_parameter_body() { + let docstring = "\ +Summary. + +Args: + value: The value. + Examples: Try it. +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + #[test] + fn declines_to_render_unclosed_parameter_fence() { + let docstring = "\ +Summary. + +Args: + value: Example. + ```python + +Args: + nested = 1 + ``` +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + #[test] + fn declines_to_render_parameter_section_with_unrecognized_leading_content() { + let docstring = "\ +Args: + (data, indices) : data and indices in batched COO format. + shape : shape of sparse array. +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + #[test] + fn declines_to_render_conjunction_separated_parameter_names() { + let docstring = "\ +Arguments: + args: Program arguments. + + stdin, stdout and stderr: Standard stream handles. +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + #[test] + fn declines_to_render_parameter_section_with_unbalanced_type_brackets() { + let docstring = "\ +Args: + query_embeddings (`Union[torch.Tensor, list[torch.Tensor]`): Query embeddings. +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + #[test] + fn declines_to_render_empty_return_section() { + let docstring = "\ +Summary. + +Returns: + +After. +"; + + assert!(parsed_sections(docstring).is_empty()); + } + + fn render_google(normalized_source: &str) -> String { + let mut output = String::new(); + render_sections_into( + &mut output, + normalized_source, + parsed_sections(normalized_source), + ); + output + } + + fn parsed_sections(normalized_source: &str) -> Vec { + structured_sections(normalized_source) + } + + fn bind_markdown_snapshot_filters() -> impl Drop { + let mut settings = Settings::clone_current(); + settings.add_filter(" \n", "\n"); + settings.bind_to_scope() + } +} diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index ffb103180d..90ab1fa408 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -494,11 +494,14 @@ mod tests { ) -> Unknown ``` --- - This is such a great func!! - - Args: -     a: first for a reason -     b: coming for `a`'s title + This is such a great func!! + + ## Arguments + **a** + first for a reason + + **b** + coming for `a`'s title --------------------------------------------- info[hover]: Hovered content is --> main.py:11:1 @@ -547,11 +550,14 @@ mod tests { ) -> Unknown ``` --- - This is such a great func!! - - Args: -     a: first for a reason -     b: coming for `a`'s title + This is such a great func!! + + ## Arguments + **a** + first for a reason + + **b** + coming for `a`'s title --------------------------------------------- info[hover]: Hovered content is --> main.py:2:5 @@ -1577,11 +1583,14 @@ mod tests { ) -> Unknown ``` --- - This is such a great func!! - - Args: -     a: first for a reason -     b: coming for `a`'s title + This is such a great func!! + + ## Arguments + **a** + first for a reason + + **b** + coming for `a`'s title --------------------------------------------- info[hover]: Hovered content is --> main.py:25:3 From 6d6b2b37686da8b1ee56e54fd99831f1f5fa0381 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Jul 2026 10:18:48 +0200 Subject: [PATCH 108/222] [ty] Avoid allocations during Salsa interned lookups (#26877) --- crates/ruff_python_ast/src/name.rs | 21 +++++++++++++++++++ crates/ty_module_resolver/src/list.rs | 7 ++++--- crates/ty_module_resolver/src/module.rs | 9 ++++---- crates/ty_module_resolver/src/resolve.rs | 14 ++++++------- crates/ty_python_semantic/src/types.rs | 2 +- .../src/types/class/enum_literal.rs | 2 +- .../ty_python_semantic/src/types/equality.rs | 4 +--- .../src/types/equality/enums.rs | 10 +++------ .../ty_python_semantic/src/types/generics.rs | 2 +- .../src/types/infer/builder.rs | 11 ++-------- .../src/types/infer/builder/enum_call.rs | 10 ++++----- .../src/types/infer/builder/named_tuple.rs | 6 +++--- .../src/types/infer/builder/new_class.rs | 8 +++---- .../src/types/infer/builder/type_call.rs | 9 ++++---- .../src/types/infer/builder/typed_dict.rs | 8 +++---- .../ty_python_semantic/src/types/typevar.rs | 9 ++++---- 16 files changed, 70 insertions(+), 62 deletions(-) diff --git a/crates/ruff_python_ast/src/name.rs b/crates/ruff_python_ast/src/name.rs index 8c045ddcf0..545e1f81f9 100644 --- a/crates/ruff_python_ast/src/name.rs +++ b/crates/ruff_python_ast/src/name.rs @@ -150,6 +150,27 @@ impl salsa::Lookup for &Name { } } +#[cfg(feature = "salsa")] +impl salsa::Lookup for &str { + #[inline] + fn into_owned(self) -> Name { + Name::new(self) + } +} + +#[cfg(feature = "salsa")] +impl salsa::HashEqLike<&str> for Name { + #[inline] + fn hash(&self, state: &mut H) { + std::hash::Hash::hash(self.as_str(), state); + } + + #[inline] + fn eq(&self, data: &&str) -> bool { + self.as_str() == *data + } +} + #[cfg(feature = "salsa")] impl salsa::HashEqLike for compact_str::CompactString { #[inline] diff --git a/crates/ty_module_resolver/src/list.rs b/crates/ty_module_resolver/src/list.rs index ebb7727e3a..9b56c84cb7 100644 --- a/crates/ty_module_resolver/src/list.rs +++ b/crates/ty_module_resolver/src/list.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::btree_map::{BTreeMap, Entry}; use ruff_db::files::directory_listing; @@ -178,7 +179,7 @@ impl<'db> Lister<'db> { &module_path, Module::file_module( self.db, - module_name, + Cow::Owned(module_name), ModuleKind::Package, self.search_path.clone(), file, @@ -222,7 +223,7 @@ impl<'db> Lister<'db> { if !self.search_path.is_standard_library() { self.add_module( &module_path, - Module::namespace_package(self.db, module_name), + Module::namespace_package(self.db, Cow::Owned(module_name)), ); } return; @@ -250,7 +251,7 @@ impl<'db> Lister<'db> { &module_path, Module::file_module( self.db, - module_name, + Cow::Owned(module_name), ModuleKind::Module, self.search_path.clone(), file, diff --git a/crates/ty_module_resolver/src/module.rs b/crates/ty_module_resolver/src/module.rs index be5d8bd80d..b9391339f0 100644 --- a/crates/ty_module_resolver/src/module.rs +++ b/crates/ty_module_resolver/src/module.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Formatter; use std::str::FromStr; @@ -25,7 +26,7 @@ impl get_size2::GetSize for Module<'_> {} impl<'db> Module<'db> { pub(crate) fn file_module( db: &'db dyn Db, - name: ModuleName, + name: Cow<'_, ModuleName>, kind: ModuleKind, search_path: SearchPath, file: File, @@ -35,7 +36,7 @@ impl<'db> Module<'db> { Self::File(FileModule::new(db, name, kind, search_path, file, known)) } - pub(crate) fn namespace_package(db: &'db dyn Db, name: ModuleName) -> Self { + pub(crate) fn namespace_package(db: &'db dyn Db, name: Cow<'_, ModuleName>) -> Self { Self::Namespace(NamespacePackage::new(db, name)) } @@ -204,7 +205,7 @@ fn all_submodule_names_for_package<'db>( }; Some(Module::file_module( db, - name, + Cow::Owned(name), kind, module.search_path(db).clone(), file, @@ -241,7 +242,7 @@ fn all_submodule_names_for_package<'db>( }; Some(Module::file_module( db, - name, + Cow::Owned(name), kind, module.search_path(db).clone(), file, diff --git a/crates/ty_module_resolver/src/resolve.rs b/crates/ty_module_resolver/src/resolve.rs index 91d8138e0c..a4fcd37fdc 100644 --- a/crates/ty_module_resolver/src/resolve.rs +++ b/crates/ty_module_resolver/src/resolve.rs @@ -222,7 +222,7 @@ fn resolve_module_query<'db>( resolved .into_iter() .next() - .map(|candidate| candidate.into_module(db, name.clone())) + .map(|candidate| candidate.into_module(db, name)) } /// Like `resolve_module_query` but for cases where it failed to resolve the module @@ -262,7 +262,7 @@ fn desperately_resolve_module<'db>( resolved .into_iter() .next() - .map(|candidate| candidate.into_module(db, name.clone())) + .map(|candidate| candidate.into_module(db, name)) } /// Resolves the module for the given path. @@ -1167,11 +1167,11 @@ impl ModuleResolutionCandidate { } // This is the module we were actually interested in resolving, complete the resolution - fn into_module(self, db: &'_ dyn Db, name: ModuleName) -> Module<'_> { + fn into_module<'db>(self, db: &'db dyn Db, name: &ModuleName) -> Module<'db> { match self.module { ResolvedModule::NamespacePackage => { tracing::trace!("Resolve namespace package `{name}`"); - Module::namespace_package(db, name) + Module::namespace_package(db, Cow::Borrowed(name)) } ResolvedModule::LegacyNamespacePackage(file) => { // legacy namespace packages behave like regular packages @@ -1182,7 +1182,7 @@ impl ModuleResolutionCandidate { ); Module::file_module( db, - name, + Cow::Borrowed(name), ModuleKind::Package, self.path.into_search_path(), file, @@ -1195,7 +1195,7 @@ impl ModuleResolutionCandidate { ); Module::file_module( db, - name, + Cow::Borrowed(name), ModuleKind::Package, self.path.into_search_path(), file, @@ -1205,7 +1205,7 @@ impl ModuleResolutionCandidate { tracing::trace!("Resolved module `{name}` to `{path}`", path = file.path(db)); Module::file_module( db, - name, + Cow::Borrowed(name), ModuleKind::Module, self.path.into_search_path(), file, diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c814ecd283..323074f253 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -867,7 +867,7 @@ impl<'db> DataclassParams<'db> { .ignore_possibly_undefined() .unwrap_or_else(Type::unknown); - Self::new(db, flags, vec![dataclasses_field].into_boxed_slice()) + Self::new(db, flags, [dataclasses_field].as_slice()) } fn from_transformer_params(db: &'db dyn Db, params: DataclassTransformerParams<'db>) -> Self { diff --git a/crates/ty_python_semantic/src/types/class/enum_literal.rs b/crates/ty_python_semantic/src/types/class/enum_literal.rs index 663dd7ef62..69b01345ea 100644 --- a/crates/ty_python_semantic/src/types/class/enum_literal.rs +++ b/crates/ty_python_semantic/src/types/class/enum_literal.rs @@ -248,7 +248,7 @@ impl<'db> DynamicEnumLiteral<'db> { && let Some(canonical_name) = enum_class.resolve_member(db, &Name::new(name)) { let enum_lit = - crate::types::literal::EnumLiteralType::new(db, enum_class, canonical_name.clone()); + crate::types::literal::EnumLiteralType::new(db, enum_class, canonical_name); return Member::definitely_declared(Type::enum_literal(enum_lit)); } Member::unbound() diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index d37b4a8b26..b48f7c9a15 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -790,9 +790,7 @@ fn enum_literal_constraint<'db>( } let enum_class_literal = right.enum_class_literal(db); - let name = enum_class_literal - .resolve_member(db, right.name(db))? - .clone(); + let name = enum_class_literal.resolve_member(db, right.name(db))?; let equal_to_right = Type::from(LiteralValueType::new( EnumLiteralType::new(db, enum_class_literal, name), right_literal.is_promotable(), diff --git a/crates/ty_python_semantic/src/types/equality/enums.rs b/crates/ty_python_semantic/src/types/equality/enums.rs index 39aafdb3b9..cae8937677 100644 --- a/crates/ty_python_semantic/src/types/equality/enums.rs +++ b/crates/ty_python_semantic/src/types/equality/enums.rs @@ -411,7 +411,7 @@ impl<'db> EnumValueSet<'db> { Type::EnumComplement(EnumComplementType::new( db, self.enum_class, - complement.excluded_names(db).clone(), + complement.excluded_names(db), FxOrderSet::default(), )) } @@ -445,11 +445,7 @@ impl<'db> EnumValueSet<'db> { } fn member_type(&self, db: &'db dyn Db, name: &Name, promotable: bool) -> Type<'db> { - LiteralValueType::new( - EnumLiteralType::new(db, self.enum_class, name.clone()), - promotable, - ) - .into() + LiteralValueType::new(EnumLiteralType::new(db, self.enum_class, name), promotable).into() } } @@ -809,7 +805,7 @@ fn enum_class_key_profile<'db>( ( name.clone(), semantics.and_then(|semantics| { - enum_literal_value(db, EnumLiteralType::new(db, enum_class, name.clone())) + enum_literal_value(db, EnumLiteralType::new(db, enum_class, name)) .and_then(|value| enum_comparison_key(semantics, value)) }), ) diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 0f009e6e89..ba8915b993 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1004,7 +1004,7 @@ impl<'db> GenericContext<'db> { element_type: Type<'db>, tuple: TupleType<'db>, ) -> Specialization<'db> { - Specialization::new(db, self, Box::from([element_type]), None, Some(tuple)) + Specialization::new(db, self, [element_type].as_slice(), None, Some(tuple)) } fn fill_in_defaults(self, db: &'db dyn Db, types: I) -> Box<[Type<'db>]> diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9c14d27983..56cd62fb68 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3387,10 +3387,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred.insert(definition); Type::KnownInstance(KnownInstanceType::NewType(NewType::new( - db, - ast::name::Name::from(name), - definition, - None, + db, name, definition, None, ))) } @@ -3723,11 +3720,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred.insert(definition); Type::KnownInstance(KnownInstanceType::TypeAliasType( - TypeAliasType::ManualPEP695(ManualPEP695TypeAliasType::new( - db, - ast::name::Name::new(name), - definition, - )), + TypeAliasType::ManualPEP695(ManualPEP695TypeAliasType::new(db, name, definition)), )) } diff --git a/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs b/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs index 8034130dfd..04d8ea70fa 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/enum_call.rs @@ -421,19 +421,19 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let name_ty = self.expression_type(name_arg); let name = name_ty .as_string_literal() - .map(|name_literal| Name::new(name_literal.value(db))); + .map(|name_literal| name_literal.value(db)); if (name.is_some() || name_ty.is_assignable_to(db, KnownClass::Str.to_instance(db))) && let Some(definition) = definition && let Some(assigned_name) = definition.name(db) - && Some(assigned_name.as_str()) != name.as_deref() + && Some(assigned_name.as_str()) != name { report_mismatched_type_name( &self.context, name_arg, base_name, &assigned_name, - name.as_deref(), + name, name_ty, ); } @@ -471,7 +471,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { &mut self, name_arg: &ast::Expr, base_class: KnownClass, - ) -> Option { + ) -> Option<&'db str> { let db = self.db(); let base_name = base_class.name(db); let name_type = self.expression_type(name_arg); @@ -491,7 +491,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return None; }; - Some(Name::new(name_literal.value(db))) + Some(name_literal.value(db)) } fn infer_enum_start_argument(&mut self, value: &ast::Expr) -> EnumStart { diff --git a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs index 10de5544fc..47e3513fb1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs @@ -325,7 +325,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // Extract name. let name = name_type .as_string_literal() - .map(|literal| Name::new(literal.value(db))); + .map(|literal| literal.value(db)); if name.is_none() && !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) @@ -338,7 +338,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { "Expected `str`, found `{}`", name_type.display(db) )); - } else if let Some(actual_name) = name.as_deref() + } else if let Some(actual_name) = name && let Some(definition) = definition && let Some(assigned_name) = definition.name(db) && assigned_name.as_str() != actual_name @@ -353,7 +353,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ); } - let name = name.unwrap_or_else(|| Name::new_static("")); + let name = name.unwrap_or(""); // Handle fields based on which namedtuple variant. let anchor = match definition { diff --git a/crates/ty_python_semantic/src/types/infer/builder/new_class.rs b/crates/ty_python_semantic/src/types/infer/builder/new_class.rs index 10f1a09365..efdfecfe08 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/new_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/new_class.rs @@ -71,7 +71,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .unwrap_or_else(Type::unknown); let name = if let Some(literal) = name_type.as_string_literal() { - ast::name::Name::new(literal.value(db)) + literal.value(db) } else { if let Some(name_node) = name_node && !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) @@ -85,7 +85,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { name_type.display(db) )); } - ast::name::Name::new_static("") + "" }; // For assigned `new_class()` calls, bases inference is deferred to handle forward @@ -147,7 +147,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let has_exec_body = exec_body_arg.is_some_and(|arg| !arg.is_none_literal_expr()); let members: Box<[(ast::name::Name, Type<'db>)]> = Box::new([]); let dynamic_class = - DynamicClassLiteral::new(db, &name, anchor, members, has_exec_body, None); + DynamicClassLiteral::new(db, name, anchor, members, has_exec_body, None); // For dangling calls, validate bases eagerly. For assigned calls, validation is // deferred along with bases inference. @@ -157,7 +157,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut disjoint_bases = self.validate_dynamic_type_bases( bases_arg, explicit_bases, - &name, + dynamic_class.name(db), DynamicClassKind::NewClass, ); diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_call.rs b/crates/ty_python_semantic/src/types/infer/builder/type_call.rs index 106f9247e3..ea4532f886 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_call.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_call.rs @@ -12,7 +12,6 @@ use crate::types::infer::builder::{ }, }; use crate::types::{KnownClass, SubclassOfType, Type, TypeContext, definition_expression_type}; -use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, HasNodeIndex, NodeIndex}; use ty_python_core::definition::Definition; @@ -193,7 +192,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // Extract name and base classes. let name = if let Some(literal) = name_type.as_string_literal() { - Name::new(literal.value(db)) + literal.value(db) } else { if !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) @@ -205,7 +204,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { name_type.display(db) )); } - Name::new_static("") + "" }; let scope = self.scope(); @@ -254,7 +253,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; let dynamic_class = - DynamicClassLiteral::new(db, &name, anchor, members, has_dynamic_namespace, None); + DynamicClassLiteral::new(db, name, anchor, members, has_dynamic_namespace, None); // For dangling calls, validate bases eagerly. For assigned calls, validation is // deferred along with bases inference. @@ -263,7 +262,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut disjoint_bases = self.validate_dynamic_type_bases( bases_arg, explicit_bases, - &name, + dynamic_class.name(db), DynamicClassKind::TypeCall, ); diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index 28aed0cb8a..f182c78e22 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -254,7 +254,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let name = name_type .as_string_literal() - .map(|literal| Name::new(literal.value(db))); + .map(|literal| literal.value(db)); if name.is_none() && !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) @@ -269,19 +269,19 @@ impl<'db> TypeInferenceBuilder<'db, '_> { )); } else if let Some(definition) = definition && let Some(assigned_name) = definition.name(db) - && Some(assigned_name.as_str()) != name.as_deref() + && Some(assigned_name.as_str()) != name { report_mismatched_type_name( &self.context, name_arg, "TypedDict", &assigned_name, - name.as_deref(), + name, name_type, ); } - let name = name.unwrap_or_else(|| Name::new_static("")); + let name = name.unwrap_or(""); self.validate_fields_arg(fields_arg); diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 88fb105a83..f74303932b 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -521,14 +521,13 @@ impl<'db> TypeVarInstance<'db> { let typevar_node = typevar.node(&module); let bound = definition_expression_type(db, definition, typevar_node.bound.as_ref()?); - let constraints = if let Some(tuple) = bound.tuple_instance_spec(db) + if let Some(tuple) = bound.tuple_instance_spec(db) && let Tuple::Fixed(tuple) = tuple.into_owned() { - tuple.owned_elements() + TypeVarConstraints::new(db, tuple.owned_elements()) } else { - vec![Type::unknown()].into_boxed_slice() - }; - TypeVarConstraints::new(db, constraints) + TypeVarConstraints::new(db, [Type::unknown()].as_slice()) + } } // legacy typevar DefinitionKind::Assignment(assignment) => { From 6f361a74a232ad283cc6e5e5d3a62109802aab65 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Thu, 16 Jul 2026 11:24:57 +0200 Subject: [PATCH 109/222] [ty] only collect expected types for files open in the editor (#25546) Closes https://github.com/astral-sh/ty/issues/3376 ## Summary Only collect expected types for files open in the editor ## Test Plan Added test --------- Co-authored-by: Micha Reiser --- crates/ty/src/lib.rs | 3 ++ crates/ty_ide/src/lib.rs | 12 +++++- crates/ty_project/src/db.rs | 21 ++++++++++ crates/ty_project/src/lib.rs | 16 +++++--- crates/ty_python_semantic/src/db.rs | 18 ++++++++ .../src/types/infer/builder.rs | 16 ++++++++ .../src/types/infer/tests.rs | 41 +++++++++++++++++++ crates/ty_python_semantic/tests/corpus.rs | 4 ++ crates/ty_test/src/db.rs | 4 ++ fuzz/fuzz_targets/ty_check_invalid_syntax.rs | 4 ++ 10 files changed, 132 insertions(+), 7 deletions(-) diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index d3da25580b..fe2bb4f507 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -179,6 +179,9 @@ fn run_check(args: CheckCommand) -> anyhow::Result { ruff_db::disable_lru(&mut db); } + // The CLI never opens files, so this is safe even where the freeze below isn't + db.freeze_open_files(); + // A one-shot check never mutates these heavily read inputs, so freezing them avoids recording // unnecessary Salsa dependencies. Watch mode updates inputs incrementally, fix modes apply // source-text overrides, and memory reports measure the database without this optimization, so diff --git a/crates/ty_ide/src/lib.rs b/crates/ty_ide/src/lib.rs index 57457e2cbd..d0b27b0ca2 100644 --- a/crates/ty_ide/src/lib.rs +++ b/crates/ty_ide/src/lib.rs @@ -410,7 +410,7 @@ mod tests { use ruff_python_trivia::textwrap::dedent; use ruff_text_size::TextSize; use ty_module_resolver::SearchPathSettings; - use ty_project::ProjectMetadata; + use ty_project::{Db as _, ProjectMetadata}; use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::PythonVersionWithSource; @@ -552,6 +552,11 @@ mod tests { cursor.is_none(), "found more than one source that contains ``" ); + + // The cursor file is open in the editor; expected types for + // string-literal completions are only collected for open files. + db.project().open_file(&mut db, file); + let source = source_text(&db, file); let parsed = parsed_module(&db, file).load(&db); let stylist = @@ -699,6 +704,11 @@ mod tests { cursor.is_none(), "found more than one source that contains ``" ); + + // The cursor file is open in the editor; expected types for + // string-literal completions are only collected for open files. + db.project().open_file(&mut db, file); + let source = source_text(&db, file); let parsed = parsed_module(&db, file).load(&db); let stylist = diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 43d517f400..a090447a0b 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -30,6 +30,13 @@ pub trait Db: SemanticDb { fn dyn_clone(&self) -> Box; } +/// Tracked so that a change to the open-file set only invalidates queries +/// for files whose open state actually changed. +#[salsa::tracked(heap_size=ruff_memory_usage::heap_size, returns(copy))] +fn is_open_file_impl(db: &dyn Db, file: File) -> bool { + db.project().open_files(db).contains(&file) +} + #[salsa::db] #[derive(Clone)] pub struct ProjectDatabase { @@ -89,6 +96,12 @@ impl ProjectDatabase { self.files.freeze(); } + /// See [`Project::freeze_open_files`]. + pub fn freeze_open_files(&mut self) { + let project = self.project(); + project.freeze_open_files(self); + } + fn new( project_metadata: ProjectMetadata, system: S, @@ -551,6 +564,10 @@ impl SemanticDb for ProjectDatabase { self.project().verbose(self) } + fn is_open_file(&self, file: File) -> bool { + is_open_file_impl(self, file) + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -785,6 +802,10 @@ pub(crate) mod testing { false } + fn is_open_file(&self, file: File) -> bool { + super::is_open_file_impl(self, file) + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index db4d906a0e..50635cad21 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -51,7 +51,7 @@ pub mod watch; #[salsa::input(heap_size=ruff_memory_usage::heap_size)] #[derive(Debug)] pub struct Project { - /// The files that are open in the project, [`None`] if there are no open files. + /// The files that are open in the project. #[returns(ref)] #[default] open_fileset: FxHashSet, @@ -204,7 +204,6 @@ impl Project { let metadata = Box::new(self.metadata(db).clone()); let settings = Box::new(self.settings(db).clone()); let included_paths = self.included_paths_list(db).to_vec(); - let open_files = self.open_fileset(db).clone(); let check_mode = self.check_mode(db); let verbose = self.verbose_flag(db); let force_exclude = self.force_exclude_flag(db); @@ -218,9 +217,6 @@ impl Project { self.set_included_paths_list(db) .with_durability(durability) .to(included_paths); - self.set_open_fileset(db) - .with_durability(durability) - .to(open_files); self.set_check_mode(db) .with_durability(durability) .to(check_mode); @@ -490,7 +486,7 @@ impl Project { } } - /// Returns the open files in the project or `None` if there are no open files. + /// Returns the open files in the project. pub fn open_files(self, db: &dyn Db) -> &FxHashSet { self.open_fileset(db) } @@ -503,6 +499,14 @@ impl Project { self.set_open_fileset(db).to(open_files); } + /// Permanently marks the project as never having open files, so reads of the + /// open-file state record no salsa dependency. Any later write panics. + pub fn freeze_open_files(self, db: &mut dyn Db) { + self.set_open_fileset(db) + .with_durability(Durability::NEVER_CHANGE) + .to(FxHashSet::default()); + } + /// This takes the open files from the project and returns them. fn take_open_files(self, db: &mut dyn Db) -> FxHashSet { tracing::debug!("Take open project files"); diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index e33ff5f2e9..16e02dc948 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -19,6 +19,11 @@ pub trait Db: PythonCoreDb { /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). fn verbose(&self) -> bool; + /// Returns `true` if `file` is open in the editor. + /// + /// Expected types for string-literal completions are only collected for open files. + fn is_open_file(&self, file: File) -> bool; + fn dyn_clone(&self) -> Box; } @@ -55,6 +60,7 @@ pub(crate) mod tests { events: Events, rule_selection: Arc, analysis_settings: Arc, + open_files: rustc_hash::FxHashSet, } impl TestDb { @@ -75,9 +81,17 @@ pub(crate) mod tests { files: Files::default(), rule_selection: Arc::new(RuleSelection::from_registry(default_lint_registry())), analysis_settings: AnalysisSettings::default().into(), + open_files: rustc_hash::FxHashSet::default(), } } + /// Marks `file` as open in the editor. + /// + /// This is untracked state: open a file before running any queries. + pub(crate) fn open_file(&mut self, file: File) { + self.open_files.insert(file); + } + /// Takes the salsa events. pub(crate) fn take_salsa_events(&mut self) -> Vec { let mut events = self.events.lock().unwrap(); @@ -156,6 +170,10 @@ pub(crate) mod tests { false } + fn is_open_file(&self, file: File) -> bool { + self.open_files.contains(&file) + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 56cd62fb68..e91cdfd717 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -6138,11 +6138,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { assert_eq!(previous, None); } + /// Whether this region's inference should record expected types for string-literal + /// completions. They're only ever read for files open in the editor. + fn collects_expected_types(&self) -> bool { + self.db().is_open_file(self.file()) + } + fn store_maybe_expected_type( &mut self, expression: impl Into, ty: Type<'db>, ) { + // Cheaper check first so most queries never depend on the open-file state if !self.has_string_literal_completion_candidates(ty) { return; } @@ -6151,6 +6158,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn store_expected_type(&mut self, expression: impl Into, ty: Type<'db>) { + if !self.collects_expected_types() { + return; + } + self.expected_types.insert(expression.into(), ty); } @@ -6170,6 +6181,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn union_expected_types(&mut self, expected_types: &FxHashMap>) { + // Non-empty only if the producing inference collected, i.e. the file is open + if expected_types.is_empty() { + return; + } + let db = self.db(); #[expect( clippy::iter_over_hash_type, diff --git a/crates/ty_python_semantic/src/types/infer/tests.rs b/crates/ty_python_semantic/src/types/infer/tests.rs index 823573d591..5075a5f283 100644 --- a/crates/ty_python_semantic/src/types/infer/tests.rs +++ b/crates/ty_python_semantic/src/types/infer/tests.rs @@ -72,6 +72,47 @@ fn assert_revealed_type(db: &TestDb, filename: &str, expected: &str) { ); } +#[test] +fn expected_types_are_collected_only_for_open_files() -> anyhow::Result<()> { + let has_expected_type = |open_file: bool| -> anyhow::Result { + let mut db = setup_db(); + db.write_dedented( + "src/a.py", + r#" + from typing_extensions import Literal + + value: Literal["apple", "banana"] = "app" + "#, + )?; + + let file = system_path_to_file(&db, "src/a.py").expect("file to exist"); + if open_file { + db.open_file(file); + } + + let module = parsed_module(&db, file).load(&db); + let assignment = module.syntax().body[1] + .as_ann_assign_stmt() + .expect("annotated assignment"); + let string_expr = assignment + .value + .as_deref() + .expect("annotated assignment to have a value") + .as_string_literal_expr() + .expect("string literal value"); + let scope = global_scope(&db, file); + + Ok(infer_complete_scope_types(&db, scope) + .try_expected_type(ruff_python_ast::ExprRef::from(string_expr)) + .is_some()) + }; + + assert!(!has_expected_type(false)?); + assert!(has_expected_type(true)?); + + Ok(()) +} + #[test] fn compact_definition_types_omit_owner() -> anyhow::Result<()> { assert!( diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index b2bfcff38b..c8aa2108e2 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -281,6 +281,10 @@ impl ty_python_semantic::Db for CorpusDb { false } + fn is_open_file(&self, _file: File) -> bool { + false + } + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { &self.analysis_settings } diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 2606538826..ca833ba1b5 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -199,6 +199,10 @@ impl SemanticDb for Db { self.settings().verbose(self) } + fn is_open_file(&self, _file: File) -> bool { + false + } + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { self.settings().analysis(self) } diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index 274c8851d0..579180f8d1 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -127,6 +127,10 @@ impl SemanticDb for TestDb { false } + fn is_open_file(&self, _file: File) -> bool { + false + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } From e454f91fa63bd745d874ead751ad15d9f521195a Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Jul 2026 12:17:42 +0200 Subject: [PATCH 110/222] [ty] Share interned query keys across Salsa queries (#26794) --- crates/ruff_python_ast/src/name.rs | 41 ++- crates/ty_python_semantic/src/place.rs | 8 +- .../ty_python_semantic/src/semantic_model.rs | 2 +- crates/ty_python_semantic/src/types.rs | 293 ++++++++++-------- .../src/types/attribute_write.rs | 4 +- .../ty_python_semantic/src/types/callable.rs | 3 +- crates/ty_python_semantic/src/types/class.rs | 6 +- .../src/types/class/static_literal.rs | 7 +- .../src/types/constraints.rs | 20 +- crates/ty_python_semantic/src/types/enums.rs | 4 +- .../ty_python_semantic/src/types/equality.rs | 7 +- .../src/types/infer/builder.rs | 6 +- .../infer/builder/attribute_assignment.rs | 2 +- .../src/types/infer/builder/class.rs | 3 +- .../src/types/infer/builder/subscript.rs | 2 +- .../ty_python_semantic/src/types/overrides.rs | 2 +- .../src/types/protocol_class.rs | 24 +- .../ty_python_semantic/src/types/relation.rs | 35 +-- .../src/types/set_theoretic.rs | 53 ++-- 19 files changed, 282 insertions(+), 240 deletions(-) diff --git a/crates/ruff_python_ast/src/name.rs b/crates/ruff_python_ast/src/name.rs index 545e1f81f9..652529b029 100644 --- a/crates/ruff_python_ast/src/name.rs +++ b/crates/ruff_python_ast/src/name.rs @@ -159,41 +159,41 @@ impl salsa::Lookup for &str { } #[cfg(feature = "salsa")] -impl salsa::HashEqLike<&str> for Name { +impl salsa::HashEqLike for compact_str::CompactString { #[inline] fn hash(&self, state: &mut H) { - std::hash::Hash::hash(self.as_str(), state); + std::hash::Hash::hash(self, state); } #[inline] - fn eq(&self, data: &&str) -> bool { - self.as_str() == *data + fn eq(&self, data: &Name) -> bool { + self == data.as_str() } } #[cfg(feature = "salsa")] -impl salsa::HashEqLike for compact_str::CompactString { +impl salsa::HashEqLike<&Name> for compact_str::CompactString { #[inline] fn hash(&self, state: &mut H) { std::hash::Hash::hash(self, state); } #[inline] - fn eq(&self, data: &Name) -> bool { + fn eq(&self, data: &&Name) -> bool { self == data.as_str() } } #[cfg(feature = "salsa")] -impl salsa::HashEqLike<&Name> for compact_str::CompactString { +impl salsa::HashEqLike<&str> for Name { #[inline] fn hash(&self, state: &mut H) { - std::hash::Hash::hash(self, state); + self.as_str().hash(state); } #[inline] - fn eq(&self, data: &&Name) -> bool { - self == data.as_str() + fn eq(&self, data: &&str) -> bool { + self.as_str() == *data } } @@ -822,8 +822,29 @@ type SegmentsStack<'a> = ArrayVec<&'a str, SMALL_LEN>; #[cfg(test)] mod tests { + #[cfg(feature = "salsa")] + use std::hash::{DefaultHasher, Hash, Hasher}; + + #[cfg(feature = "salsa")] + use crate::name::Name; use crate::name::SegmentsVec; + #[cfg(feature = "salsa")] + #[test] + fn salsa_lookup_name_from_str() { + let name = Name::new("member"); + let lookup = "member"; + + let mut name_hasher = DefaultHasher::new(); + salsa::HashEqLike::<&str>::hash(&name, &mut name_hasher); + let mut lookup_hasher = DefaultHasher::new(); + lookup.hash(&mut lookup_hasher); + + assert_eq!(name_hasher.finish(), lookup_hasher.finish()); + assert!(salsa::HashEqLike::<&str>::eq(&name, &lookup)); + assert_eq!(salsa::Lookup::::into_owned(lookup), name); + } + #[test] fn empty_vec() { let empty = SegmentsVec::new(); diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index e447cb82e8..201d34cdb9 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -573,7 +573,7 @@ pub(crate) fn imported_symbol<'db>( "__builtins__" => Place::bound(Type::any()).into(), _ => KnownClass::ModuleType .to_instance(db) - .member_lookup_with_policy(db, name.into(), MemberLookupPolicy::NO_GETATTR_LOOKUP), + .member_lookup_with_policy(db, name, MemberLookupPolicy::NO_GETATTR_LOOKUP), } }) } @@ -2117,11 +2117,7 @@ pub(crate) mod implicit_globals { { KnownClass::ModuleType .to_instance(db) - .member_lookup_with_policy( - db, - name.into(), - MemberLookupPolicy::NO_GETATTR_LOOKUP, - ) + .member_lookup_with_policy(db, name, MemberLookupPolicy::NO_GETATTR_LOOKUP) } _ => Place::Undefined.into(), diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 6106a1223a..7e29a8fccb 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -515,7 +515,7 @@ impl<'db> SemanticModel<'db> { value_ty .member_lookup_with_policy( self.db, - attr.attr.id.clone(), + &attr.attr.id, crate::types::MemberLookupPolicy::default(), ) .qualifiers diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 323074f253..e72f4ada55 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -480,6 +480,8 @@ bitflags! { } } +impl get_size2::GetSize for MemberLookupPolicy {} + impl MemberLookupPolicy { /// Only look up the attribute on the meta-type. /// @@ -522,6 +524,17 @@ impl Default for MemberLookupPolicy { } } +/// The common key for class-member and instance-member lookup. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct MemberLookupKey<'db> { + #[returns(copy)] + ty: Type<'db>, + #[returns(ref)] + name: Name, + #[returns(copy)] + policy: MemberLookupPolicy, +} + /// Meta data for `Type::Todo`, which represents a known limitation in ty. #[cfg(debug_assertions)] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)] @@ -1006,6 +1019,18 @@ pub enum Type<'db> { NewTypeInstance(NewType<'db>), } +/// An ordered pair of types shared by type-relation and set-theoretic queries. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct TypePair<'db> { + #[returns(copy)] + first: Type<'db>, + #[returns(copy)] + second: Type<'db>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for TypePair<'_> {} + /// Helper for `recursive_type_normalized_impl` for `TypeGuardLike` types. fn recursive_type_normalize_type_guard_like<'db, T: TypeGuardLike<'db>>( db: &'db dyn Db, @@ -2722,41 +2747,52 @@ impl<'db> Type<'db> { /// /// Basically corresponds to `self.to_meta_type().find_name_in_mro(name)`, except for the handling /// of union and intersection types. - fn class_member(self, db: &'db dyn Db, name: Name) -> PlaceAndQualifiers<'db> { + fn class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { self.class_member_with_policy(db, name, MemberLookupPolicy::default()) } + fn class_member_with_policy( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + Self::class_member_with_policy_inner(db, MemberLookupKey::new(db, self, name, policy)) + } + #[salsa::tracked( returns(copy), - cycle_initial=|_, id, _, _, _| Place::bound(Type::divergent(id)).into(), - cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _, _, _| { + cycle_initial=|_, id, _| Place::bound(Type::divergent(id)).into(), + cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _| { member.cycle_normalized(db, *previous, cycle) }, heap_size=ruff_memory_usage::heap_size )] - fn class_member_with_policy( - self, + fn class_member_with_policy_inner( db: &'db dyn Db, - name: Name, - policy: MemberLookupPolicy, + key: MemberLookupKey<'db>, ) -> PlaceAndQualifiers<'db> { - tracing::trace!("class_member: {}.{}", self.display(db), name); - if let Some(fallback) = self.materialized_divergent_fallback() { + let ty = key.ty(db); + let name = key.name(db); + let policy = key.policy(db); + + tracing::trace!("class_member: {}.{}", ty.display(db), name); + if let Some(fallback) = ty.materialized_divergent_fallback() { return fallback.class_member_with_policy(db, name, policy); } - match self { + match ty { Type::Union(union) => union.map_with_boundness_and_qualifiers(db, |elem| { - elem.class_member_with_policy(db, name.clone(), policy) + elem.class_member_with_policy(db, name, policy) }), Type::Intersection(inter) => inter.map_with_boundness_and_qualifiers(db, |elem| { - elem.class_member_with_policy(db, name.clone(), policy) + elem.class_member_with_policy(db, name, policy) }), // TODO: Once `to_meta_type` for the synthesized protocol is fully implemented, this handling should be removed. Type::ProtocolInstance(ProtocolInstanceType { inner: Protocol::Synthesized(_), .. - }) => self.instance_member(db, &name), + }) => ty.instance_member(db, name), Type::LiteralValue(literal) if name == "__len__" @@ -2770,7 +2806,7 @@ impl<'db> Type<'db> { let parameters = Parameters::standard([Parameter::positional_only(Some( Name::new_static("self"), )) - .with_annotated_type(self)]); + .with_annotated_type(ty)]); Place::bound(Type::function_like_callable( db, Signature::new(parameters, Type::int_literal(length)), @@ -2785,33 +2821,31 @@ impl<'db> Type<'db> { Type::SubclassOf(subclass_of) if subclass_of.is_dynamic() => { let type_result = KnownClass::Type .to_class_literal(db) - .find_name_in_mro_with_policy(db, name.as_str(), policy) + .find_name_in_mro_with_policy(db, name, policy) .expect("`find_name_in_mro` should return `Some` for a class literal"); if !type_result.place.is_undefined() { type_result } else { - self.to_meta_type(db) - .find_name_in_mro_with_policy(db, name.as_str(), policy) + ty.to_meta_type(db) + .find_name_in_mro_with_policy(db, name, policy) .expect( "`Type::find_name_in_mro()` should return `Some()` when called on a meta-type", ) } } - Type::NominalInstance(instance) => self.to_meta_type(db).class_namespace_member( - db, - instance.class(db), - name.as_str(), - policy, - ), + Type::NominalInstance(instance) => { + ty.to_meta_type(db) + .class_namespace_member(db, instance.class(db), name, policy) + } - Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => self - .to_meta_type(db) - .class_object_member(db, name.as_str(), policy), + Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => { + ty.to_meta_type(db).class_object_member(db, name, policy) + } - _ => self + _ => ty .to_meta_type(db) - .find_name_in_mro_with_policy(db, name.as_str(), policy) + .find_name_in_mro_with_policy(db, name, policy) .expect( "`Type::find_name_in_mro()` should return `Some()` when called on a meta-type", ), @@ -2824,18 +2858,21 @@ impl<'db> Type<'db> { /// not carry attributes stored in a nominal upper-bound class's namespace by its metaclass. /// Add those attributes using the same lookup as a concrete nominal instance. fn instance_lookup_class_member_with_policy( - self, db: &'db dyn Db, - name: Name, - policy: MemberLookupPolicy, + key: MemberLookupKey<'db>, ) -> PlaceAndQualifiers<'db> { - if let Type::TypeVar(_) = self - && let Some(class) = self.nominal_class(db) + let ty = key.ty(db); + + if let Type::TypeVar(_) = ty + && let Some(class) = ty.nominal_class(db) { - self.to_meta_type(db) - .class_namespace_member(db, class, name.as_str(), policy) + let name = key.name(db); + let policy = key.policy(db); + + ty.to_meta_type(db) + .class_namespace_member(db, class, name, policy) } else { - self.class_member_with_policy(db, name, policy) + Self::class_member_with_policy_inner(db, key) } } @@ -3185,7 +3222,7 @@ impl<'db> Type<'db> { fn static_member(&self, db: &'db dyn Db, name: &str) -> Place<'db> { if let Type::ModuleLiteral(module) = self { module.static_member(db, name).place - } else if let place @ Place::Defined(_) = self.class_member(db, name.into()).place { + } else if let place @ Place::Defined(_) = self.class_member(db, name).place { place } else if let Some(place @ Place::Defined(_)) = self.find_name_in_mro(db, name).map(|inner| inner.place) @@ -3275,11 +3312,7 @@ impl<'db> Type<'db> { ty: concrete_descr_get, .. }) = ty - .class_member_with_policy( - db, - "__get__".into(), - MemberLookupPolicy::REQUIRE_CONCRETE, - ) + .class_member_with_policy(db, "__get__", MemberLookupPolicy::REQUIRE_CONCRETE) .place else { return None; @@ -3298,11 +3331,7 @@ impl<'db> Type<'db> { definedness: descr_get_boundness, .. }) = ty - .class_member_with_policy( - db, - "__get__".into(), - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) + .class_member_with_policy(db, "__get__", MemberLookupPolicy::NO_INSTANCE_FALLBACK) .place else { return None; @@ -3585,17 +3614,13 @@ impl<'db> Type<'db> { .is_data_descriptor_impl(db, any_of_union), _ => { !self - .class_member_with_policy( - db, - "__set__".into(), - MemberLookupPolicy::REQUIRE_CONCRETE, - ) + .class_member_with_policy(db, "__set__", MemberLookupPolicy::REQUIRE_CONCRETE) .place .is_undefined() || !self .class_member_with_policy( db, - "__delete__".into(), + "__delete__", MemberLookupPolicy::REQUIRE_CONCRETE, ) .place @@ -3619,14 +3644,13 @@ impl<'db> Type<'db> { /// In addition to that, we also handle various cases of possibly-unbound symbols and fall /// back to lower-precedence stages of the descriptor protocol by building union types. fn invoke_descriptor_protocol( - self, db: &'db dyn Db, + key: MemberLookupKey<'db>, receiver: Type<'db>, - name: &str, fallback: PlaceAndQualifiers<'db>, policy: InstanceFallbackShadowsNonDataDescriptor, - member_policy: MemberLookupPolicy, ) -> PlaceAndQualifiers<'db> { + let ty = key.ty(db); let ( PlaceAndQualifiers { place: meta_attr, @@ -3635,9 +3659,9 @@ impl<'db> Type<'db> { meta_attr_kind, ) = Self::try_call_dunder_get_on_attribute( db, - self.instance_lookup_class_member_with_policy(db, name.into(), member_policy), + Self::instance_lookup_class_member_with_policy(db, key), Some(receiver), - self.to_meta_type(db), + ty.to_meta_type(db), ); let PlaceAndQualifiers { @@ -3752,7 +3776,7 @@ impl<'db> Type<'db> { /// lookup, like a failed `__get__` call on a descriptor. #[must_use] pub(crate) fn member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { - self.member_lookup_with_policy(db, name.into(), MemberLookupPolicy::default()) + self.member_lookup_with_policy(db, name, MemberLookupPolicy::default()) } /// Similar to [`Type::member`], but allows the caller to specify what policy should be used @@ -3760,7 +3784,7 @@ impl<'db> Type<'db> { pub(crate) fn member_lookup_with_policy( self, db: &'db dyn Db, - name: Name, + name: &str, policy: MemberLookupPolicy, ) -> PlaceAndQualifiers<'db> { self.member_lookup_with_policy_and_receiver(db, name, policy, None) @@ -3774,23 +3798,44 @@ impl<'db> Type<'db> { fn member_lookup_with_policy_and_receiver( self, db: &'db dyn Db, - name: Name, + name: &str, policy: MemberLookupPolicy, receiver: Option>, ) -> PlaceAndQualifiers<'db> { #[salsa::tracked( returns(copy), - cycle_initial=|_, id, _, _, _, _| Place::bound(Type::divergent(id)).into(), - cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _, _, _, _| { + cycle_initial=|_, id, _| Place::bound(Type::divergent(id)).into(), + cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _| { member.cycle_normalized(db, *previous, cycle) }, heap_size=ruff_memory_usage::heap_size )] fn member_lookup_with_policy_inner<'db>( db: &'db dyn Db, - this: Type<'db>, - name: Name, - policy: MemberLookupPolicy, + key: MemberLookupKey<'db>, + ) -> PlaceAndQualifiers<'db> { + member_lookup_with_policy_impl(db, key, None) + } + + #[salsa::tracked( + returns(copy), + cycle_initial=|_, id, _, _| Place::bound(Type::divergent(id)).into(), + cycle_fn=|db, cycle, previous: &PlaceAndQualifiers<'db>, member: PlaceAndQualifiers<'db>, _, _| { + member.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + fn member_lookup_with_policy_and_receiver_inner<'db>( + db: &'db dyn Db, + key: MemberLookupKey<'db>, + receiver: Type<'db>, + ) -> PlaceAndQualifiers<'db> { + member_lookup_with_policy_impl(db, key, Some(receiver)) + } + + fn member_lookup_with_policy_impl<'db>( + db: &'db dyn Db, + key: MemberLookupKey<'db>, receiver: Option>, ) -> PlaceAndQualifiers<'db> { fn promote_inferred_attribute_class_literals<'db>( @@ -3814,11 +3859,11 @@ impl<'db> Type<'db> { fn instance_like_member_lookup<'db>( db: &'db dyn Db, - this: Type<'db>, - name: &Name, - policy: MemberLookupPolicy, + key: MemberLookupKey<'db>, receiver: Type<'db>, ) -> PlaceAndQualifiers<'db> { + let this = key.ty(db); + let name = key.name(db); let name_str = name.as_str(); // Enum members can be accessed through enum instances and other enum members, @@ -3843,13 +3888,12 @@ impl<'db> Type<'db> { let fallback = this.instance_member(db, name_str); - let result = this.invoke_descriptor_protocol( + let result = Type::invoke_descriptor_protocol( db, + key, receiver, - name_str, fallback, InstanceFallbackShadowsNonDataDescriptor::No, - policy, ); if result.is_class_var() && this.is_typed_dict() { @@ -3858,28 +3902,27 @@ impl<'db> Type<'db> { return Place::Undefined.into(); } - let result = this.fallback_to_getattr(db, name, result, policy); + let result = this.fallback_to_getattr(db, name, result, key.policy(db)); // An inferred attribute accessed through an instance can resolve to an override // on a subclass, so an exact class object is not a safe public type here. let result = result.map_type(|ty| ty.bind_self_typevars(db, receiver)); promote_inferred_attribute_class_literals(db, result) } + let this = key.ty(db); + let name = key.name(db); + let name_str = name.as_str(); + let policy = key.policy(db); + tracing::trace!("member_lookup_with_policy: {}.{}", this.display(db), name); if let Some(fallback) = this.materialized_divergent_fallback() { - return fallback.member_lookup_with_policy_and_receiver(db, name, policy, receiver); + return fallback + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver); } - let name_str = name.as_str(); - match this { Type::Union(union) => union.map_with_boundness_and_qualifiers(db, |elem| { - elem.member_lookup_with_policy_and_receiver( - db, - name_str.into(), - policy, - receiver, - ) + elem.member_lookup_with_policy_and_receiver(db, name_str, policy, receiver) }), Type::Intersection(intersection) => { @@ -3889,10 +3932,7 @@ impl<'db> Type<'db> { let receiver = Some(receiver.unwrap_or(this)); intersection.map_with_boundness_and_qualifiers(db, |elem| { elem.member_lookup_with_policy_and_receiver( - db, - name_str.into(), - policy, - receiver, + db, name_str, policy, receiver, ) }) } @@ -4040,32 +4080,27 @@ impl<'db> Type<'db> { _ => { KnownClass::MethodType .to_instance(db) - .member_lookup_with_policy_and_receiver( - db, - name.clone(), - policy, - receiver, - ) + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver) .or_fall_back_to(db, || { // If an attribute is not available on the bound method object, // it will be looked up on the underlying function object. This // changes the lookup object, so do not forward the bound-method // receiver. Type::FunctionLiteral(bound_method.function(db)) - .member_lookup_with_policy(db, name, policy) + .member_lookup_with_policy(db, name_str, policy) }) } }, Type::KnownBoundMethod(method) => method .class() .to_instance(db) - .member_lookup_with_policy_and_receiver(db, name, policy, receiver), + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver), Type::WrapperDescriptor(_) => KnownClass::WrapperDescriptorType .to_instance(db) - .member_lookup_with_policy_and_receiver(db, name, policy, receiver), + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver), Type::DataclassDecorator(_) => KnownClass::FunctionType .to_instance(db) - .member_lookup_with_policy_and_receiver(db, name, policy, receiver), + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver), Type::Callable(_) | Type::DataclassTransformer(_) if name_str == "__call__" => { Place::bound(this).into() @@ -4074,15 +4109,14 @@ impl<'db> Type<'db> { Type::Callable(callable) if callable.is_function_like(db) => { KnownClass::FunctionType .to_instance(db) - .member_lookup_with_policy_and_receiver(db, name, policy, receiver) + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver) } Type::Callable(_) | Type::DataclassTransformer(_) => Type::object() - .member_lookup_with_policy_and_receiver(db, name, policy, receiver), + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver), Type::NominalInstance(instance) - if matches!(name.as_str(), "major" | "minor") - && instance.is_sys_version_info() => + if matches!(name_str, "major" | "minor") && instance.is_sys_version_info() => { let python_version = Program::get(db).python_version(db); let segment = if name == "major" { @@ -4145,22 +4179,21 @@ impl<'db> Type<'db> { Type::NewTypeInstance(new_type_instance) if this.as_union_like(db).is_some() => { new_type_instance .concrete_base_type(db) - .member_lookup_with_policy(db, name, policy) + .member_lookup_with_policy(db, name_str, policy) } Type::TypeAlias(alias) => alias .value_type(db) - .member_lookup_with_policy_and_receiver(db, name, policy, receiver), + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver), _ if policy.no_instance_fallback() => { let receiver = receiver.unwrap_or(this); - this.invoke_descriptor_protocol( + Type::invoke_descriptor_protocol( db, + key, receiver, - name_str, Place::Undefined.into(), InstanceFallbackShadowsNonDataDescriptor::No, - policy, ) .map_type(|ty| ty.bind_self_typevars(db, receiver)) } @@ -4217,13 +4250,13 @@ impl<'db> Type<'db> { // requires the full lookup path rather than instance-member lookup. return bound.member_lookup_with_policy_and_receiver( db, - name, + name_str, policy, Some(receiver), ); } - instance_like_member_lookup(db, this, &name, policy, receiver) + instance_like_member_lookup(db, key, receiver) } Type::NominalInstance(instance) @@ -4271,7 +4304,7 @@ impl<'db> Type<'db> { let nominal_lookup = partial .partial(db) .into_functools_partial_instance(db) - .member_lookup_with_policy_and_receiver(db, name.clone(), policy, receiver); + .member_lookup_with_policy_and_receiver(db, name_str, policy, receiver); if name_str == "func" { match nominal_lookup.place { Place::Defined(DefinedPlace { @@ -4310,7 +4343,7 @@ impl<'db> Type<'db> { | Type::TypeForm(..) | Type::TypedDict(_) => { let receiver = receiver.unwrap_or(this); - instance_like_member_lookup(db, this, &name, policy, receiver) + instance_like_member_lookup(db, key, receiver) } Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { @@ -4326,7 +4359,7 @@ impl<'db> Type<'db> { _ => None, }; if let Some(enum_class) = enum_class - && let Some(resolved_name) = enum_class.resolve_member(db, &name) + && let Some(resolved_name) = enum_class.resolve_member(db, name) { return Place::bound(Type::enum_literal(EnumLiteralType::new( db, @@ -4352,13 +4385,12 @@ impl<'db> Type<'db> { ) .0; - let result = this.invoke_descriptor_protocol( + let result = Type::invoke_descriptor_protocol( db, + key, receiver, - name_str, class_attr_fallback, InstanceFallbackShadowsNonDataDescriptor::Yes, - policy, ); // A class is an instance of its metaclass. If attribute lookup on the class @@ -4367,7 +4399,7 @@ impl<'db> Type<'db> { // attribute access falls back to `__getattr__`/`__getattribute__` on the // class. `try_call_dunder` adds `NO_INSTANCE_FALLBACK`, which causes the // lookup to hit the catch-all that only checks the meta-type (the metaclass). - let result = this.fallback_to_getattr(db, &name, result, policy); + let result = this.fallback_to_getattr(db, name, result, policy); // Unlike a specific class literal, `type[C]` can represent any subclass of // `C`, unless a `TypeVar` upper bound normalizes to a final class. let result = if let Type::SubclassOf(subclass_of) = this @@ -4422,7 +4454,11 @@ impl<'db> Type<'db> { } } - member_lookup_with_policy_inner(db, self, name, policy, receiver) + let key = MemberLookupKey::new(db, self, name, policy); + match receiver { + Some(receiver) => member_lookup_with_policy_and_receiver_inner(db, key, receiver), + None => member_lookup_with_policy_inner(db, key), + } } /// Return the type of `len()` on a type if it is known more precisely than `int`, @@ -4480,11 +4516,7 @@ impl<'db> Type<'db> { .unwrap_or(Type::unknown()); match self - .member_lookup_with_policy( - db, - Name::new_static("__getitem__"), - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) + .member_lookup_with_policy(db, "__getitem__", MemberLookupPolicy::NO_INSTANCE_FALLBACK) .place { Place::Defined(DefinedPlace { @@ -4504,11 +4536,7 @@ impl<'db> Type<'db> { /// or `None` if the object does not support unpacking. fn unpack_keys_and_items(self, db: &'db dyn Db) -> Option<(Type<'db>, Type<'db>)> { let key_ty = match self - .member_lookup_with_policy( - db, - Name::new_static("keys"), - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) + .member_lookup_with_policy(db, "keys", MemberLookupPolicy::NO_INSTANCE_FALLBACK) .place { Place::Defined(DefinedPlace { @@ -4828,7 +4856,7 @@ impl<'db> Type<'db> { match self .member_lookup_with_policy( db, - Name::new_static("__call__"), + "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place @@ -5359,7 +5387,7 @@ impl<'db> Type<'db> { // until call-time overload resolution. let metaclass_dunder_call = self_type.member_lookup_with_policy( db, - "__call__".into(), + "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ); @@ -5372,7 +5400,7 @@ impl<'db> Type<'db> { let init_method_no_object = constructor_instance_ty.member_lookup_with_policy( db, - "__init__".into(), + "__init__", MemberLookupPolicy::NO_INSTANCE_FALLBACK | MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, ); @@ -5421,7 +5449,7 @@ impl<'db> Type<'db> { (Place::Undefined, false) => { let init_method_with_object = constructor_instance_ty.member_lookup_with_policy( db, - "__init__".into(), + "__init__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ); match init_method_with_object.place { @@ -5575,10 +5603,7 @@ impl<'db> Type<'db> { // Implicit calls to dunder methods never access instance members, so we pass // `NO_INSTANCE_FALLBACK` here in addition to other policies: let policy = policy | MemberLookupPolicy::NO_INSTANCE_FALLBACK; - match self - .member_lookup_with_policy(db, name.into(), policy) - .place - { + match self.member_lookup_with_policy(db, name, policy).place { Place::Defined(DefinedPlace { ty: dunder_callable, definedness: boundness, @@ -7488,7 +7513,7 @@ impl<'db> UnionType<'db> { match element .member_lookup_with_policy( db, - name.into(), + name, policy | MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index f34f6ae8a9..b49f30532f 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -470,7 +470,7 @@ fn explicit_attribute_write_requirement<'db>( qualifiers: TypeQualifiers, ) -> ExplicitAttributeWriteRequirement<'db> { if let Place::Defined(DefinedPlace { ty: setter_ty, .. }) = attr_ty - .class_member_with_policy(db, "__set__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .class_member_with_policy(db, "__set__", MemberLookupPolicy::REQUIRE_CONCRETE) .place { ExplicitAttributeWriteRequirement::Descriptor { @@ -646,7 +646,7 @@ pub(super) fn assignment_attribute_members<'db>( ) { object_ty.member(db, attribute) } else { - object_ty.class_member(db, attribute.into()) + object_ty.class_member(db, attribute) }; if let Some(receiver_member) = class_member_preceding_non_data_metaclass_member(db, object_ty, attribute, type_member) diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index d25b57fc36..79015caf05 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -1,4 +1,3 @@ -use ruff_python_ast::name::Name; use rustc_hash::FxHashSet; use smallvec::{SmallVec, smallvec_inline}; @@ -114,7 +113,7 @@ impl<'db> Type<'db> { let call_symbol = self .member_lookup_with_policy( db, - Name::new_static("__call__"), + "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 2484b5a6db..3358e9dc42 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2056,7 +2056,7 @@ impl<'db> ClassType<'db> { let metaclass_dunder_call_function_symbol = self_ty .member_lookup_with_policy( db, - "__call__".into(), + "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ) @@ -2124,7 +2124,7 @@ impl<'db> ClassType<'db> { let dunder_init_function_symbol = self_ty .member_lookup_with_policy( db, - "__init__".into(), + "__init__", MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ) @@ -2210,7 +2210,7 @@ impl<'db> ClassType<'db> { let new_function_symbol = self_ty .member_lookup_with_policy( db, - "__new__".into(), + "__new__", MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ) .place; diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index fe1a418139..f7b9c8cc39 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -1448,7 +1448,7 @@ impl<'db> StaticClassLiteral<'db> { continue; } - let dunder_set = field_ty.class_member(db, "__set__".into()); + let dunder_set = field_ty.class_member(db, "__set__"); if let Place::Defined(DefinedPlace { ty: dunder_set, definedness: Definedness::AlwaysDefined, @@ -2811,10 +2811,7 @@ impl<'db> StaticClassLiteral<'db> { } } } else if self.is_own_dataclass_instance_field(db, name) - && declared_ty - .class_member(db, "__get__".into()) - .place - .is_undefined() + && declared_ty.class_member(db, "__get__").place.is_undefined() { // For dataclass-like classes, declared fields are assigned // by the synthesized `__init__`, so they are instance diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 86c684999e..9e4a745b9b 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -111,7 +111,7 @@ use crate::types::visitor::{ }; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, IntersectionType, Type, TypeContext, - TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, + TypeMapping, TypePair, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, }; use crate::{Db, FxIndexMap, FxIndexSet, FxOrderSet}; @@ -3694,16 +3694,13 @@ impl<'db> Type<'db> { #[salsa::tracked( returns(copy), - cycle_initial = |_, _, _, _| true, + cycle_initial = |_, _, _| true, heap_size = get_size2::GetSize::get_heap_size )] -fn is_possibly_constraint_set_assignable<'db>( - db: &'db dyn Db, - source: Type<'db>, - target: Type<'db>, -) -> bool { - source - .when_constraint_set_assignable_to_owned(db, target) +fn is_possibly_constraint_set_assignable<'db>(db: &'db dyn Db, types: TypePair<'db>) -> bool { + types + .first(db) + .when_constraint_set_assignable_to_owned(db, types.second(db)) .query(|_builder, when| !when.is_never_satisfied(db)) } @@ -3954,7 +3951,10 @@ impl<'db> PathBounds<'db> { } } - if !is_possibly_constraint_set_assignable(db, lower, declared_upper) { + if !is_possibly_constraint_set_assignable( + db, + TypePair::new(db, lower, declared_upper), + ) { // This path does not satisfy the typevar's declared upper bound, and is // therefore not a valid specialization. return Err(()); diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index 44cb56e0cc..43631e738b 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -429,7 +429,7 @@ pub(super) fn member_lookup_for_enum_complement<'db>( } else { complement .remaining_literal_union(db) - .member_lookup_with_policy(db, name.into(), policy) + .member_lookup_with_policy(db, name, policy) } } @@ -1152,7 +1152,7 @@ pub(crate) fn enum_metadata<'db>( let dunder_get = ty .member_lookup_with_policy( db, - "__get__".into(), + "__get__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place; diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index b48f7c9a15..824c034db2 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -4,7 +4,6 @@ //! constraints and definite truthiness while remaining conservative around custom comparison //! methods. -use ruff_python_ast::name::Name; use rustc_hash::FxHashSet; use crate::{Db, place::PlaceAndQualifiers}; @@ -1513,11 +1512,7 @@ fn lookup_dunder<'db>( ty: Type<'db>, name: &'static str, ) -> PlaceAndQualifiers<'db> { - ty.member_lookup_with_policy( - db, - Name::new_static(name), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ) + ty.member_lookup_with_policy(db, name, MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK) } /// Return the comparison result for two literals when their runtime values determine it. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e91cdfd717..4a8fb17eea 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4968,7 +4968,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { call_expression_tcx: TypeContext<'db>, ) -> Result, CallDunderError<'db>> { match object - .member_lookup_with_policy(db, name.into(), MemberLookupPolicy::NO_INSTANCE_FALLBACK) + .member_lookup_with_policy(db, name, MemberLookupPolicy::NO_INSTANCE_FALLBACK) .place { Place::Defined(DefinedPlace { @@ -9717,7 +9717,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let attribute_exists = match MethodDecorator::try_from_fn_type(self.db(), function_type) { Some(MethodDecorator::ClassMethod) => !Type::instance(self.db(), class) - .class_member(self.db(), id.clone()) + .class_member(self.db(), id) .place .is_undefined(), Some(MethodDecorator::None) => !Type::instance(self.db(), class) @@ -11825,7 +11825,7 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { }); // If the member is a data descriptor, the RHS value may differ from the value actually assigned. if value_ty - .class_member(db, attr.id.clone()) + .class_member(db, &attr.id) .place .ignore_possibly_undefined() .is_some_and(|ty| ty.may_be_data_descriptor(db)) diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs index 2f47b9a5d5..acef3233af 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -309,7 +309,7 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { if emit_diagnostics { let is_setattr_synthesized = match object_ty.class_member_with_policy( db, - "__setattr__".into(), + "__setattr__", MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, ) { PlaceAndQualifiers { diff --git a/crates/ty_python_semantic/src/types/infer/builder/class.rs b/crates/ty_python_semantic/src/types/infer/builder/class.rs index b56d4f2ee6..fda696d8fc 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/class.rs @@ -12,7 +12,6 @@ use crate::types::{ }, special_form::TypeQualifier, }; -use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, helpers::any_over_expr}; use ty_module_resolver::{KnownModule, file_to_module}; use ty_python_core::{definition::Definition, scope::NodeWithScopeRef}; @@ -664,7 +663,7 @@ impl ClassDecoratorUnknownResultPolicy { let call_symbol = decorator_ty .member_lookup_with_policy( db, - Name::new_static("__call__"), + "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place; diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 359f5985aa..0aba5a963c 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1278,7 +1278,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) = identity_instance .member_lookup_with_policy( db, - "__setitem__".into(), + "__setitem__", MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 3bab55b201..6eee77d877 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -877,7 +877,7 @@ fn variable_kind<'db>( .. }) = class_member.place && class_member_ty - .class_member(db, "__get__".into()) + .class_member(db, "__get__") .place .ignore_possibly_undefined() .is_some() diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 71797e3d6f..dfa312ffd2 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -23,7 +23,7 @@ use crate::{ types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, CallableType, ClassBase, ClassType, ErrorContext, FindLegacyTypeVarsVisitor, - InstanceFallbackShadowsNonDataDescriptor, IntersectionType, KnownFunction, + InstanceFallbackShadowsNonDataDescriptor, IntersectionType, KnownFunction, MemberLookupKey, MemberLookupPolicy, Parameter, PropertyInstanceType, ProtocolInstanceType, SelfBinding, Signature, StaticClassLiteral, Type, TypeMapping, TypeQualifiers, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, VarianceInferable, @@ -1527,7 +1527,7 @@ fn descriptor_decorated_protocol_member<'db>( definedness: Definedness::AlwaysDefined, .. }) = descriptor_ty - .class_member_with_policy(db, "__get__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .class_member_with_policy(db, "__get__", MemberLookupPolicy::REQUIRE_CONCRETE) .place else { return None; @@ -1600,7 +1600,7 @@ fn single_descriptor_setter_domain<'db>( }) = descriptor_ty .member_lookup_with_policy( db, - "__set__".into(), + "__set__", MemberLookupPolicy::REQUIRE_CONCRETE | MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place @@ -1762,15 +1762,19 @@ fn protocol_member_read_type<'db>( && !matches!(ty, Type::ModuleLiteral(_)) && (!is_class_object_type(ty) || member.uses_special_method_lookup()) { - ty.invoke_descriptor_protocol( + Type::invoke_descriptor_protocol( db, + MemberLookupKey::new( + db, + ty, + member.name, + // The undefined fallback excludes instance members. Keep the class + // member lookup from reintroducing dynamic instance fallbacks. + MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ), ty, - member.name, Place::Undefined.into(), InstanceFallbackShadowsNonDataDescriptor::No, - // The undefined fallback excludes instance members. Keep the class - // member lookup from reintroducing dynamic instance fallbacks. - MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) .place } else { @@ -2014,7 +2018,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let Place::Defined(DefinedPlace { ty: setattr_ty, .. }) = object_ty .member_lookup_with_policy( db, - "__setattr__".into(), + "__setattr__", MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK | MemberLookupPolicy::NO_INSTANCE_FALLBACK, ) @@ -2602,7 +2606,7 @@ impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { ty: Type::PropertyInstance(actual_property), definedness: Definedness::AlwaysDefined, .. - }) = ty.class_member(db, member.name().into()).place + }) = ty.class_member(db, member.name()).place else { return self.never(); }; diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index c8c178bf0c..31418c5d46 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use itertools::Itertools; -use ruff_python_ast::name::Name; use rustc_hash::FxHashSet; use crate::place::{DefinedPlace, Place}; @@ -24,7 +23,7 @@ use crate::types::{ use crate::{ Db, types::{ - ErrorContext, ErrorContextTree, Type, constraints::ConstraintSet, + ErrorContext, ErrorContextTree, Type, TypePair, constraints::ConstraintSet, generics::InferableTypeVars, }, }; @@ -464,16 +463,18 @@ impl<'db> Type<'db> { ) -> Cow<'db, OwnedConstraintSet<'db>> { #[salsa::tracked( returns(ref), - cycle_initial=|_, _, _, _| OwnedConstraintSet::always(), + cycle_initial=|_, _, _| OwnedConstraintSet::always(), heap_size=ruff_memory_usage::heap_size, )] fn when_constraint_set_assignable_to_owned_impl<'db>( db: &'db dyn Db, - source: Type<'db>, - target: Type<'db>, + types: TypePair<'db>, ) -> OwnedConstraintSet<'db> { let constraints = ConstraintSetBuilder::new(); constraints.into_owned(|constraints| { + let source = types.first(db); + let target = types.second(db); + source.has_relation_to_with_typevar_evaluation( db, target, @@ -490,7 +491,8 @@ impl<'db> Type<'db> { } Cow::Borrowed(when_constraint_set_assignable_to_owned_impl( - db, self, target, + db, + TypePair::new(db, self, target), )) } @@ -530,16 +532,13 @@ impl<'db> Type<'db> { /// /// See [`TypeRelation::Redundancy`] for more details. pub(super) fn is_redundant_with(self, db: &'db dyn Db, other: Type<'db>) -> bool { - #[salsa::tracked(returns(copy), cycle_initial=|_, _, _, _| true, heap_size=ruff_memory_usage::heap_size)] - fn is_redundant_with_impl<'db>( - db: &'db dyn Db, - self_ty: Type<'db>, - other: Type<'db>, - ) -> bool { - self_ty + #[salsa::tracked(returns(copy), cycle_initial=|_, _, _| true, heap_size=ruff_memory_usage::heap_size)] + fn is_redundant_with_impl<'db>(db: &'db dyn Db, types: TypePair<'db>) -> bool { + types + .first(db) .has_relation_to( db, - other, + types.second(db), &ConstraintSetBuilder::new(), InferableTypeVars::None, TypeRelation::Redundancy { pure: false }, @@ -551,7 +550,7 @@ impl<'db> Type<'db> { return true; } - is_redundant_with_impl(db, self, other) + is_redundant_with_impl(db, TypePair::new(db, self, other)) } pub(super) fn has_relation_to<'c>( @@ -3175,11 +3174,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { Type::NominalInstance(nominal), Type::Callable(_) | Type::DataclassDecorator(_) | Type::DataclassTransformer(_), ) if nominal.class(db).is_final(db) => Type::NominalInstance(nominal) - .member_lookup_with_policy( - db, - Name::new_static("__call__"), - MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ) + .member_lookup_with_policy(db, "__call__", MemberLookupPolicy::NO_INSTANCE_FALLBACK) .place .ignore_possibly_undefined() .when_none_or(db, self.constraints, |dunder_call| { diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index cb4ce82969..e0bf18d79a 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -7,7 +7,7 @@ use crate::place::{ }; use crate::types::class::KnownClass; use crate::types::enums::EnumComplement; -use crate::types::{Type, TypeQualifiers}; +use crate::types::{Type, TypePair, TypeQualifiers}; use crate::types::{TypeVarBoundOrConstraints, visitor}; use crate::{Db, FxOrderSet}; @@ -68,16 +68,23 @@ impl<'db> UnionType<'db> { } /// Create a union type `A | B` from two elements `A` and `B`. - #[salsa::tracked( - returns(copy), - cycle_initial=|_, id, _, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { - result.cycle_normalized(db, *previous, cycle) - }, - heap_size=ruff_memory_usage::heap_size - )] pub fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { - UnionBuilder::new(db).add(a).add(b).build() + #[salsa::tracked( + returns(copy), + cycle_initial=|_, id, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + fn union_from_two_elements<'db>(db: &'db dyn Db, types: TypePair<'db>) -> Type<'db> { + UnionBuilder::new(db) + .add(types.first(db)) + .add(types.second(db)) + .build() + } + + union_from_two_elements(db, TypePair::new(db, a, b)) } /// Create a union from a list of elements without unpacking type aliases. @@ -848,18 +855,22 @@ impl<'db> IntersectionType<'db> { } /// Create an intersection type `A & B` from two elements `A` and `B`. - #[salsa::tracked( - returns(copy), - cycle_initial=|_, id, _, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { - result.cycle_normalized(db, *previous, cycle) - }, - heap_size=ruff_memory_usage::heap_size - )] pub(crate) fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { - IntersectionBuilder::new(db) - .positive_elements([a, b]) - .build() + #[salsa::tracked( + returns(copy), + cycle_initial=|_, id, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + fn intersection_from_two_elements<'db>(db: &'db dyn Db, types: TypePair<'db>) -> Type<'db> { + IntersectionBuilder::new(db) + .positive_elements([types.first(db), types.second(db)]) + .build() + } + + intersection_from_two_elements(db, TypePair::new(db, a, b)) } pub(crate) fn recursive_type_normalized_impl( From 87fc38cacd7e2bd906674c6dc5b8be97aa3a5bc3 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 16 Jul 2026 12:38:21 +0200 Subject: [PATCH 111/222] [ty] Parallelize subtype hierarchy search (#26875) --- crates/ty_ide/src/type_hierarchy.rs | 17 ++++++++++++++--- .../ty_python_semantic/src/types/ide_support.rs | 16 ++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/ty_ide/src/type_hierarchy.rs b/crates/ty_ide/src/type_hierarchy.rs index d64385ebca..167015b463 100644 --- a/crates/ty_ide/src/type_hierarchy.rs +++ b/crates/ty_ide/src/type_hierarchy.rs @@ -1,9 +1,11 @@ use crate::Db; use crate::goto::find_goto_target; +use rayon::prelude::*; use ruff_db::files::File; use ruff_db::parsed::parsed_module; use ruff_python_ast::name::Name; use ruff_text_size::{TextRange, TextSize}; +use ty_project::parallel::ParallelIteratorExt; use ty_python_semantic::SemanticModel; use ty_python_semantic::TypeHierarchyClass; use ty_python_semantic::types::Type; @@ -56,6 +58,8 @@ pub fn type_hierarchy_supertypes( } /// Get the subtypes (derived classes) of a type hierarchy item. +/// +/// This scans all available modules and can be expensive in large projects. pub fn type_hierarchy_subtypes( db: &dyn Db, file: File, @@ -64,9 +68,16 @@ pub fn type_hierarchy_subtypes( let Some(ty) = resolve_type_at(db, file, offset) else { return vec![]; }; - ty_python_semantic::type_hierarchy_subtypes(db, ty) - .into_iter() - .map(|c| type_hierarchy_class_to_item(db, c)) + + ty_module_resolver::all_modules(db) + .into_par_iter() + .map_with_db(db, |db, module| { + ty_python_semantic::type_hierarchy_subtypes(db, ty, &[module]) + .into_iter() + .map(|class| type_hierarchy_class_to_item(db, class)) + .collect::>() + }) + .flat_map_iter(|items| items) .collect() } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 5b29a3c505..2375a4545d 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -20,6 +20,7 @@ use ruff_db::source::source_text; use ruff_python_ast::{self as ast, AnyNodeRef, name::Name}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; +use ty_module_resolver::Module; use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::{attribute_scopes, global_scope, semantic_index, use_def_map}; @@ -1977,15 +1978,15 @@ pub fn type_hierarchy_supertypes(db: &dyn Db, ty: Type<'_>) -> Vec) -> Vec { +pub fn type_hierarchy_subtypes( + db: &dyn Db, + ty: Type<'_>, + modules: &[Module<'_>], +) -> Vec { let Some(target_class) = extract_class_literal(db, ty) else { return vec![]; }; @@ -1993,8 +1994,7 @@ pub fn type_hierarchy_subtypes(db: &dyn Db, ty: Type<'_>) -> Vec Date: Thu, 16 Jul 2026 16:17:11 +0530 Subject: [PATCH 112/222] [ty] Support `TypeVarTuple` and `Unpack` (#25240) ## Summary closes: astral-sh/ty#156 closes: astral-sh/ty#1746 This PR adds initial support for `TypeVarTuple` and `Unpack` in ty. ### Supported - Legacy `TypeVarTuple(...)` definitions from `typing` and `typing_extensions`, including defaults, variance, and version validation. - PEP 695 `[*Ts]`, `Unpack[...]`, and starred unpack forms, with diagnostics for invalid uses. - Explicit specialization and substitution for generic classes and aliases. - Fixed and variable-length tuple construction, relations, display, hover, and goto-type-definition. - Legacy-solver inference from tuple arguments and returns, constructors, and positional callable signatures. - A `ParamSpec` following an unpacked variadic prefix. ### Representation and inference A `TypeVarTuple` occupies one generic specialization slot and is represented by a `TupleType`. Tuple specifications preserve a fixed prefix and suffix around a variable middle and an unresolved / invalid `TypeVarTuple` resolves as `tuple[Unknown, ...]`. Callable inference constructs `ConstraintSet`s while matching positional parameters in the signature relation. This keeps callable compatibility and inference in the same loop instead of adding a separate TypeVarTuple-specific path to `SpecializationBuilder`. The work was briefly split into a [type-form foundation](https://github.com/astral-sh/ruff/pull/26126) and generic logic PR. I recombined them because the foundation alone caused conformance and ecosystem regressions and did not represent complete, independently reviewable behavior. ### Deliberate follow-ups Call binding does not yet aggregate arguments matched to `*args` into a `tuple`, or validate calls against concrete unpacked tuple annotations. Per-argument inference is skipped because it would create incorrect scalar mappings. Nested tuple unpacking in callable parameter lists and some correlations involving generic, overloaded, union, and inherited callables also remain follow-ups. The ecosystem results show these callable cases account for the main remaining false positives. ## Test Plan - Added shared semantic coverage to the PEP 695 tests. - Added legacy definition and specialization coverage. - Kept the standalone `Unpack` coverage focused on its distinct syntax paths. - Reviewed the semantic suite, conformance results, and ecosystem changes. --- crates/mdtest/src/matcher.rs | 20 +- crates/ty_ide/src/goto_type_definition.rs | 45 +- crates/ty_ide/src/hover.rs | 8 +- .../resources/mdtest/annotations/callable.md | 10 +- .../resources/mdtest/annotations/invalid.md | 11 + .../resources/mdtest/annotations/starred.md | 6 +- .../annotations/unsupported_special_forms.md | 10 +- .../annotations/unsupported_special_types.md | 3 +- .../resources/mdtest/bidirectional.md | 8 +- .../mdtest/generics/legacy/classes.md | 29 +- .../mdtest/generics/legacy/typevartuple.md | 626 ++++++++++ .../mdtest/generics/legacy/unpack.md | 200 +++ .../mdtest/generics/pep695/aliases.md | 22 +- .../mdtest/generics/pep695/classes.md | 7 +- .../mdtest/generics/pep695/concatenate.md | 4 +- .../mdtest/generics/pep695/typevartuple.md | 1090 +++++++++++++++++ .../resources/mdtest/generics/scoping.md | 24 + ...aramet\342\200\246_(6bb09b09c131074).snap" | 28 +- .../resources/mdtest/type_form.md | 5 +- crates/ty_python_semantic/src/types.rs | 64 +- .../ty_python_semantic/src/types/call/bind.rs | 119 +- crates/ty_python_semantic/src/types/class.rs | 52 +- .../src/types/class/known.rs | 27 +- .../src/types/class_base.rs | 7 +- .../src/types/diagnostic.rs | 18 +- .../ty_python_semantic/src/types/display.rs | 122 +- .../ty_python_semantic/src/types/function.rs | 2 +- .../ty_python_semantic/src/types/generics.rs | 208 +++- crates/ty_python_semantic/src/types/infer.rs | 7 +- .../src/types/infer/builder.rs | 89 +- .../types/infer/builder/binary_expressions.rs | 22 +- .../src/types/infer/builder/function.rs | 30 +- .../infer/builder/post_inference/function.rs | 10 +- .../builder/post_inference/static_class.rs | 5 + .../post_inference/type_param_validation.rs | 65 +- .../src/types/infer/builder/subscript.rs | 342 +++++- .../types/infer/builder/type_expression.rs | 351 ++++-- .../src/types/infer/builder/type_form.rs | 8 +- .../src/types/infer/builder/typevar.rs | 343 +++++- .../src/types/infer/comparisons.rs | 4 +- .../src/types/known_instance.rs | 3 + .../src/types/list_members.rs | 1 + crates/ty_python_semantic/src/types/mro.rs | 5 +- crates/ty_python_semantic/src/types/narrow.rs | 18 +- .../ty_python_semantic/src/types/relation.rs | 27 + .../src/types/signatures.rs | 402 +++++- .../ty_python_semantic/src/types/subscript.rs | 16 +- crates/ty_python_semantic/src/types/tuple.rs | 857 +++++++++---- .../ty_python_semantic/src/types/typevar.rs | 41 +- .../ty_python_semantic/src/types/unpacker.rs | 4 +- .../ty_python_semantic/src/types/visitor.rs | 8 +- 51 files changed, 4646 insertions(+), 787 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md create mode 100644 crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md create mode 100644 crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md diff --git a/crates/mdtest/src/matcher.rs b/crates/mdtest/src/matcher.rs index b7fd536058..8f5eb6a3d0 100644 --- a/crates/mdtest/src/matcher.rs +++ b/crates/mdtest/src/matcher.rs @@ -273,31 +273,13 @@ impl UnmatchedWithColumn for &Diagnostic { /// Discard `@Todo`-type metadata from expected types, which is not available /// when running in release mode. -/// -/// Some `@Todo` variants (like `@Todo(StarredExpression)` and `@Todo(typing.Unpack)`) -/// are hardcoded enum variants that always display their message, so we preserve those. fn discard_todo_metadata(ty: &str) -> Cow<'_, str> { #[cfg(not(debug_assertions))] { - /// `@Todo` variants that are hardcoded and always display their message, - /// even in release mode. - const PRESERVED_TODO_VARIANTS: &[&str] = &[ - "@Todo(StarredExpression)", - "@Todo(typing.Unpack)", - "@Todo(TypeVarTuple)", - ]; - static TODO_METADATA_REGEX: LazyLock = LazyLock::new(|| regex::Regex::new(r"@Todo\([^)]*\)").unwrap()); - TODO_METADATA_REGEX.replace_all(ty, |caps: ®ex::Captures| { - let matched = caps.get(0).unwrap().as_str(); - if PRESERVED_TODO_VARIANTS.contains(&matched) { - matched.to_string() - } else { - "@Todo".to_string() - } - }) + TODO_METADATA_REGEX.replace_all(ty, "@Todo") } #[cfg(debug_assertions)] diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index db966d5822..d9ea73e894 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -753,20 +753,19 @@ mod tests { "#, ); - // TODO: Point to `Ts` once inference supports type-variable tuples here. - assert_snapshot!(test.goto_type_definition(), @" + assert_snapshot!(test.goto_type_definition(), @r" info[goto-type definition]: Go to type definition - --> main.py:LL:31 - | - LL | type Alias[*Ts = ()] = tuple[*Ts] - | ^^ Clicking here - | + --> main.py:2:31 + | + 2 | type Alias[*Ts = ()] = tuple[*Ts] + | ^^ Clicking here + | info: Found 1 type definition - --> stdlib/ty_extensions/_internal.pyi:LL:1 - | - LL | Todo: _SpecialForm - | ---- - | + --> main.py:2:13 + | + 2 | type Alias[*Ts = ()] = tuple[*Ts] + | -- + | "); } @@ -1581,19 +1580,19 @@ mod tests { "#, ); - assert_snapshot!(test.goto_type_definition(), @" + assert_snapshot!(test.goto_type_definition(), @r" info[goto-type definition]: Go to type definition - --> main.py:LL:38 - | - LL | type Alias3[*AB = ()] = tuple[tuple[*AB], tuple[*AB]] - | ^^ Clicking here - | + --> main.py:2:38 + | + 2 | type Alias3[*AB = ()] = tuple[tuple[*AB], tuple[*AB]] + | ^^ Clicking here + | info: Found 1 type definition - --> stdlib/ty_extensions/_internal.pyi:LL:1 - | - LL | Todo: _SpecialForm - | ---- - | + --> main.py:2:14 + | + 2 | type Alias3[*AB = ()] = tuple[tuple[*AB], tuple[*AB]] + | -- + | "); } diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index 90ab1fa408..a26550de3f 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -4136,10 +4136,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - @Todo + AB@Alias3 (covariant) --------------------------------------------- ```python - @Todo + AB@Alias3 (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4298,10 +4298,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - @Todo + Ts@Alias (covariant) --------------------------------------------- ```python - @Todo + Ts@Alias (covariant) ``` --------------------------------------------- info[hover]: Hovered content is diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index aaa63d74ac..25a5f044c8 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -436,9 +436,8 @@ from typing_extensions import Callable, TypeVarTuple Ts = TypeVarTuple("Ts") -def _(c: Callable[[int, *Ts], int]): - # TODO: Should reveal the correct signature - reveal_type(c) # revealed: (...) -> int +def unpack_operator(c: Callable[[int, *Ts], int]): + reveal_type(c) # revealed: (int, /, *Ts@unpack_operator) -> int ``` And, using the legacy syntax using `Unpack`: @@ -446,9 +445,8 @@ And, using the legacy syntax using `Unpack`: ```py from typing_extensions import Unpack -def _(c: Callable[[int, Unpack[Ts]], int]): - # TODO: Should reveal the correct signature - reveal_type(c) # revealed: (...) -> int +def unpack_special_form(c: Callable[[int, Unpack[Ts]], int]): + reveal_type(c) # revealed: (int, /, *Ts@unpack_special_form) -> int ``` ## Member lookup diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md index 2b992ed9f4..e96b974e8c 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md @@ -249,6 +249,10 @@ python-version = "3.11" ``` ```py +from typing import TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts") + t1: tuple[int, ...] # error: [invalid-type-form] "Invalid `tuple` specialization: `...` can only be used as the second element in a two-element `tuple` specialization" t2: tuple[int, int, ...] @@ -262,6 +266,13 @@ t5: tuple[int, ..., int] t6: tuple[*tuple[str], ...] # error: [invalid-type-form] "Invalid `tuple` specialization: `...` cannot be used after an unpacked element" t7: tuple[*tuple[str, ...], ...] + +def invalid_typevartuple_ellipsis( + # error: [invalid-type-form] "Invalid `tuple` specialization: `...` cannot be used after an unpacked element" + starred: tuple[*Ts, ...], + # error: [invalid-type-form] "Invalid `tuple` specialization: `...` cannot be used after an unpacked element" + unpacked: tuple[Unpack[Ts], ...], +) -> None: ... ``` ## Invalid AST nodes in string annotations diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/starred.md b/crates/ty_python_semantic/resources/mdtest/annotations/starred.md index afcc1e89fa..94c8935f4b 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/starred.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/starred.md @@ -13,12 +13,14 @@ from typing_extensions import TypeVarTuple Ts = TypeVarTuple("Ts") def append_int(*args: *Ts) -> tuple[*Ts, int]: - reveal_type(args) # revealed: @Todo(PEP 646) + reveal_type(args) # revealed: tuple[*Ts@append_int] return (*args, 1) # TODO should be tuple[Literal[True], Literal["a"], int] -reveal_type(append_int(True, "a")) # revealed: tuple[@Todo(TypeVarTuple), ...] +reveal_type(append_int(True, "a")) # revealed: tuple[*tuple[Unknown, ...], int] +# TODO should be tuple[int] +reveal_type(append_int()) # revealed: tuple[*tuple[Unknown, ...], int] def first_arg_int(*args: *tuple[int, *tuple[str, ...]]): ... diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md index 204ab5f5c3..46bf83727f 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_forms.md @@ -13,7 +13,7 @@ Ts = TypeVarTuple("Ts") R_co = TypeVar("R_co", covariant=True) def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: - reveal_type(args) # revealed: tuple[@Todo(`Unpack[]` special form), ...] + reveal_type(args) # revealed: tuple[*Ts@f] return args def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co: @@ -74,10 +74,10 @@ class Pair(Generic[T, U]): ... class Triple(Generic[T, U, Unpack[Us]]): ... def variadic_typevartuple(*args: Unpack[Ts]) -> None: - reveal_type(args) # revealed: tuple[@Todo(`Unpack[]` special form), ...] + reveal_type(args) # revealed: tuple[*Ts@variadic_typevartuple] def variadic_tuple(*args: Unpack[tuple[int, str]]) -> None: - reveal_type(args) # revealed: tuple[@Todo(`Unpack[]` special form), ...] + reveal_type(args) # revealed: tuple[int, str] def allowed( tuple_fixed: tuple[int, Unpack[tuple[str, bytes]]], @@ -95,8 +95,8 @@ def allowed( ) -> None: reveal_type(tuple_fixed) # revealed: tuple[int, str, bytes] reveal_type(tuple_variadic) # revealed: tuple[int, *tuple[str, ...], bytes] - reveal_type(callable_typevartuple) # revealed: (...) -> None - reveal_type(callable_tuple) # revealed: (tuple[int, str], /) -> None + reveal_type(callable_typevartuple) # revealed: (int, /, *Ts@allowed) -> None + reveal_type(callable_tuple) # revealed: (*tuple[int, str]) -> None reveal_type(pair) # revealed: Pair[int, str] reveal_type(quoted_pair_argument) # revealed: Pair[int, str] reveal_type(quoted_tuple) # revealed: tuple[int, str, bytes] diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md index 78741eb056..b9830cc29b 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md @@ -42,8 +42,7 @@ y: Bar[int, str, bytes] # fine class Baz[*Ts]: ... -# TODO: false positive -z: Baz[int, str, bytes] # error: [not-subscriptable] +z: Baz[int, str, bytes] ``` And we also provide some basic validation in some cases: diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index f3f6df06bc..2af7228ac2 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -1555,7 +1555,7 @@ reveal_type(f5_paramspec) # revealed: (x: int) -> int # TODO: This should not error once we support `Unpack`. # error: [invalid-assignment] f6: Callable[[*tuple[int, ...]], None] = lambda x, y, z: None -reveal_type(f6) # revealed: (tuple[int, ...], /) -> None +reveal_type(f6) # revealed: (*tuple[int, ...]) -> None f7: Callable[[int, str], None] = lambda *args: None reveal_type(f7) # revealed: (*args) -> None @@ -1565,9 +1565,11 @@ reveal_type(f7) # revealed: (*args) -> None f8: Callable[[int], None] = lambda *, x=1: None reveal_type(f8) # revealed: (int, /) -> None -# TODO: This should reveal `(*args: int, *, x=1) -> None` once we support `Unpack`. +# `Callable` annotations only describe positional parameters, so the keyword-only `x` is not +# compatible with the positional suffix in the annotation. +# error: [invalid-assignment] f9: Callable[[*tuple[int, ...], int], None] = lambda *args, x=1: None -reveal_type(f9) # revealed: (*args, *, x=1) -> None +reveal_type(f9) # revealed: (*tuple[int, ...], int) -> None f10: Callable[[str, int, str], tuple[str, int, str]] = lambda x, y, z: reveal_type((x, y, z)) # revealed: tuple[str, int, str] reveal_type(f10) # revealed: (x: str, y: int, z: str) -> tuple[str, int, str] diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index b3a02a64ff..a75580cb52 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -38,11 +38,14 @@ reveal_type(generic_context(SingleParamSpec)) # revealed: ty_extensions._internal.GenericContext[P@TypeVarAndParamSpec, T@TypeVarAndParamSpec] reveal_type(generic_context(TypeVarAndParamSpec)) -# TODO: support `TypeVarTuple` properly (these should not reveal `None`) -reveal_type(generic_context(SingleTypeVarTuple)) # revealed: None -reveal_type(generic_context(TypeVarAndTypeVarTuple)) # revealed: None -reveal_type(generic_context(StarredSingleTypeVarTuple)) # revealed: None -reveal_type(generic_context(StarredTypeVarAndTypeVarTuple)) # revealed: None +# revealed: ty_extensions._internal.GenericContext[Ts@SingleTypeVarTuple] +reveal_type(generic_context(SingleTypeVarTuple)) +# revealed: ty_extensions._internal.GenericContext[T@TypeVarAndTypeVarTuple, Ts@TypeVarAndTypeVarTuple] +reveal_type(generic_context(TypeVarAndTypeVarTuple)) +# revealed: ty_extensions._internal.GenericContext[Ts@StarredSingleTypeVarTuple] +reveal_type(generic_context(StarredSingleTypeVarTuple)) +# revealed: ty_extensions._internal.GenericContext[T@StarredTypeVarAndTypeVarTuple, Ts@StarredTypeVarAndTypeVarTuple] +reveal_type(generic_context(StarredTypeVarAndTypeVarTuple)) ``` Inheriting from `Generic` multiple times yields a `duplicate-base` diagnostic, just like any other @@ -60,11 +63,14 @@ You cannot use the same typevar more than once. class RepeatedTypevar(Generic[T, T]): ... ``` -You can only specialize `typing.Generic` with typevars (TODO: or param specs or typevar tuples). +You can only specialize `typing.Generic` with typevars, param specs, or typevar tuples. ```py # error: [invalid-argument-type] "`` is not a valid argument to `Generic`" class GenericOfType(Generic[int]): ... + +# error: [invalid-argument-type] "`` is not a valid argument to `Generic`" +class GenericOfInvalidUnpack(Generic[T, Unpack[int]]): ... ``` You can also define a generic class by inheriting from some _other_ generic class, and specializing @@ -111,12 +117,12 @@ class ParamSpecOuterClass(Generic[P]): ```snapshot error[shadowed-type-variable]: Generic class `InnerClass` uses ParamSpec `P` already bound by an enclosing scope - --> src/mdtest_snippet.py:64:7 + --> src/mdtest_snippet.py:70:7 | -64 | class ParamSpecOuterClass(Generic[P]): +70 | class ParamSpecOuterClass(Generic[P]): | ------------------------------- ParamSpec `P` is bound in this enclosing scope -65 | # snapshot: shadowed-type-variable -66 | class InnerClass(SingleParamSpec[P]): ... +71 | # snapshot: shadowed-type-variable +72 | class InnerClass(SingleParamSpec[P]): ... | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `P` used in class definition here | ``` @@ -1162,7 +1168,8 @@ class Quux(Generic[Unpack[Ts], T]): ... # TODO: should emit [invalid-type-variable-default] class Corge(Generic[Unpack[Us], P]): ... -# TODO: should emit [invalid-type-variable-default] +# TODO: should also emit [invalid-type-variable-default] +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" class Grault(Generic[Unpack[Us], Unpack[Ts2]]): ... # These are fine: diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md new file mode 100644 index 0000000000..82e2d7864f --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md @@ -0,0 +1,626 @@ +# Legacy `TypeVarTuple` + +```toml +[environment] +python-version = "3.11" +``` + +The tests in this file focus on how `TypeVarTuple`s are defined and specialized using the legacy +notation. Shared uses of `TypeVarTuple`s are tested with PEP 695 syntax in +`../pep695/typevartuple.md`; alternate `Unpack` spelling is tested in `unpack.md`. + +## Tuple representation + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple("Ts") + +def preserve(values: tuple[int, *Ts, str]) -> None: + reveal_type(values) # revealed: tuple[int, *Ts@preserve, str] +``` + +## Definition + +### Valid + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple("Ts") +reveal_type(type(Ts)) # revealed: +reveal_type(Ts) # revealed: TypeVarTuple +reveal_type(Ts.__name__) # revealed: Literal["Ts"] +``` + +The `TypeVarTuple` name can also be provided as a keyword argument: + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple(name="Ts") +reveal_type(Ts.__name__) # revealed: Literal["Ts"] +``` + +### Must be directly assigned to a variable + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple("Ts") + +# error: [invalid-legacy-type-variable] +Ts1: TypeVarTuple = TypeVarTuple("Ts1") + +# error: [invalid-legacy-type-variable] +tuple_with_typevartuple = ("foo", TypeVarTuple("Us")) +reveal_type(tuple_with_typevartuple[1]) # revealed: TypeVarTuple +``` + +### `TypeVarTuple` parameter must match variable name + +```py +from typing import Generic, TypeVarTuple + +Ts1 = TypeVarTuple("Ts1") + +# error: [mismatched-type-name] +Ts2 = TypeVarTuple("Ts3") + +class Array(Generic[*Ts2]): ... +``` + +### Bounds and constraints + +The `bound` parameter was added to `typing.TypeVarTuple` in Python 3.15. On older Python versions, +using it is invalid. Constraints are not supported in any Python version. + +#### Before Python 3.15 + +```toml +[environment] +python-version = "3.14" +``` + +```py +from typing import TypeVarTuple + +# error: [invalid-legacy-type-variable] "The `bound` parameter of `typing.TypeVarTuple` was added in Python 3.15" +Ts1 = TypeVarTuple("Ts1", bound=int) +# error: [invalid-legacy-type-variable] +Ts2 = TypeVarTuple("Ts2", int, str) +``` + +#### Python 3.15 + +ty does not yet support the `bound` parameter when targeting Python 3.15. + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import TypeVarTuple + +# error: [invalid-legacy-type-variable] "The `bound` argument for `TypeVarTuple` is not supported" +Ts = TypeVarTuple("Ts", bound=int) +``` + +#### `typing_extensions.TypeVarTuple` + +`typing_extensions.TypeVarTuple` exposes the `bound` parameter on older Python versions. ty +recognizes the backport but does not yet support the parameter. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing_extensions import TypeVarTuple + +# error: [invalid-legacy-type-variable] "The `bound` argument for `TypeVarTuple` is not supported" +Ts = TypeVarTuple("Ts", bound=int) +``` + +### Variance + +Legacy `TypeVarTuple` accepts `covariant` and `contravariant` arguments. A `TypeVarTuple` with no +variance specified is invariant, and a `TypeVarTuple` with `infer_variance=True` uses variance +inference. These parameters were added to `typing.TypeVarTuple` in Python 3.15. + +#### Before Python 3.15 + +```toml +[environment] +python-version = "3.14" +``` + +```py +from typing import TypeVarTuple + +# error: [invalid-legacy-type-variable] "The `covariant` parameter of `typing.TypeVarTuple` was added in Python 3.15" +Ts_Co = TypeVarTuple("Ts_Co", covariant=True) +# error: [invalid-legacy-type-variable] "The `contravariant` parameter of `typing.TypeVarTuple` was added in Python 3.15" +Ts_Contra = TypeVarTuple("Ts_Contra", contravariant=True) +# error: [invalid-legacy-type-variable] "The `infer_variance` parameter of `typing.TypeVarTuple` was added in Python 3.15" +Ts_Inferred = TypeVarTuple("Ts_Inferred", infer_variance=True) +``` + +#### Python 3.15 + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import Generic, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +class InvariantArray(Generic[*Ts]): + values: tuple[*Ts] + +invariant_out: InvariantArray[object] = InvariantArray[int]() # error: [invalid-assignment] +invariant_in: InvariantArray[int] = InvariantArray[object]() # error: [invalid-assignment] + +Ts_Co = TypeVarTuple("Ts_Co", covariant=True) + +class CovariantArray(Generic[*Ts_Co]): + def get(self) -> tuple[*Ts_Co]: + raise NotImplementedError + +covariant_ok: CovariantArray[object] = CovariantArray[int]() +covariant_error: CovariantArray[int] = CovariantArray[object]() # error: [invalid-assignment] + +Ts_Contra = TypeVarTuple("Ts_Contra", contravariant=True) + +class ContravariantArray(Generic[*Ts_Contra]): + def set(self, value: tuple[*Ts_Contra]) -> None: + raise NotImplementedError + +contravariant_ok: ContravariantArray[int] = ContravariantArray[object]() +contravariant_error: ContravariantArray[object] = ContravariantArray[int]() # error: [invalid-assignment] + +Ts_Inferred_Co = TypeVarTuple("Ts_Inferred_Co", infer_variance=True) + +class InferredCovariantArray(Generic[*Ts_Inferred_Co]): + def get(self) -> tuple[*Ts_Inferred_Co]: + raise NotImplementedError + +inferred_covariant_ok: InferredCovariantArray[object] = InferredCovariantArray[int]() +inferred_covariant_error: InferredCovariantArray[int] = InferredCovariantArray[object]() # error: [invalid-assignment] + +Ts_Inferred_Contra = TypeVarTuple("Ts_Inferred_Contra", infer_variance=True) + +class InferredContravariantArray(Generic[*Ts_Inferred_Contra]): + def set(self, value: tuple[*Ts_Inferred_Contra]) -> None: + raise NotImplementedError + +inferred_contravariant_ok: InferredContravariantArray[int] = InferredContravariantArray[object]() +# error: [invalid-assignment] +inferred_contravariant_error: InferredContravariantArray[object] = InferredContravariantArray[int]() +``` + +The variance arguments must have statically known boolean values, and `infer_variance=True` cannot +be combined with an explicit variance. + +```py +from typing import TypeVarTuple + +def cond() -> bool: + return True + +# error: [invalid-legacy-type-variable] +Both = TypeVarTuple("Both", covariant=True, contravariant=True) +# error: [invalid-legacy-type-variable] +AmbiguousCovariant = TypeVarTuple("AmbiguousCovariant", covariant=cond()) +# error: [invalid-legacy-type-variable] +AmbiguousContravariant = TypeVarTuple("AmbiguousContravariant", contravariant=cond()) +# error: [invalid-legacy-type-variable] +AmbiguousInferVariance = TypeVarTuple("AmbiguousInferVariance", infer_variance=cond()) +# error: [invalid-legacy-type-variable] +CovariantAndInferred = TypeVarTuple("CovariantAndInferred", covariant=True, infer_variance=True) +``` + +#### `typing_extensions.TypeVarTuple` + +`typing_extensions.TypeVarTuple` backports the variance parameters to older Python versions. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing_extensions import TypeVarTuple + +Ts_Co = TypeVarTuple("Ts_Co", covariant=True) +Ts_Contra = TypeVarTuple("Ts_Contra", contravariant=True) +Ts_Inferred = TypeVarTuple("Ts_Inferred", infer_variance=True) +``` + +## Generic Classes + +### Multiple `TypeVarTuple`s + +```py +from typing import Generic, TypeVarTuple + +Xs = TypeVarTuple("Xs") +Ys = TypeVarTuple("Ys") +Zs = TypeVarTuple("Zs") +# error: [invalid-legacy-type-variable] +Invalid = TypeVarTuple("Invalid", int, float) + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class Ambiguous(Generic[*Xs, *Ys]): ... + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class VeryAmbiguous(Generic[*Xs, *Ys, *Zs]): ... + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class InvalidAmbiguous(Generic[*Xs, *Invalid]): ... +``` + +### Explicit specialization + +```py +from typing import Generic, TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts = TypeVarTuple("Ts") +U = TypeVar("U") + +class Simple(Generic[*Ts]): + attr: tuple[*Ts] + +reveal_type(Simple[()]().attr) # revealed: tuple[()] +reveal_type(Simple[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Simple[*tuple[int, str]]().attr) # revealed: tuple[int, str] + +# error: [invalid-type-form] "List literals are not allowed in this context in a type expression" +reveal_type(Simple[[int, str]]().attr) # revealed: tuple[Unknown] +# error: [invalid-type-form] "List literals are not allowed in this context in a type expression" +reveal_type(Simple[*[int, str]]().attr) # revealed: tuple[Unknown, ...] +``` + +```py +class Prefix(Generic[T, *Ts]): + attr: tuple[T, *Ts] + +reveal_type(Prefix[int]().attr) # revealed: tuple[int] +reveal_type(Prefix[int, bool]().attr) # revealed: tuple[int, bool] +reveal_type(Prefix[int, bool, str]().attr) # revealed: tuple[int, bool, str] +reveal_type(Prefix[int, *tuple[bool, str]]().attr) # revealed: tuple[int, bool, str] + +# TODO: Should this raise an error? +reveal_type(Prefix().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...]] +``` + +```py +class Suffix(Generic[*Ts, T]): + attr: tuple[*Ts, T] + +reveal_type(Suffix[int]().attr) # revealed: tuple[int] +reveal_type(Suffix[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Suffix[int, str, bool]().attr) # revealed: tuple[int, str, bool] +reveal_type(Suffix[*tuple[int, str], bool]().attr) # revealed: tuple[int, str, bool] + +# TODO: Should this raise an error? +reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Unknown] +``` + +```py +class Between(Generic[T, *Ts, U]): + attr: tuple[T, *Ts, U] + +reveal_type(Between[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Between[int, bool, str]().attr) # revealed: tuple[int, bool, str] +reveal_type(Between[int, bool, bytes, str]().attr) # revealed: tuple[int, bool, bytes, str] +reveal_type(Between[int, *tuple[bool], str]().attr) # revealed: tuple[int, bool, str] + +reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +# error: [invalid-type-arguments] "No type argument provided for required type variable `U` of class `Between`" +reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +``` + +### `TypeVarTuple` with `ParamSpec` + +```py +from typing import Callable, Generic, TypeVarTuple, ParamSpec + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") + +class TypeVarTupleWithParamSpec(Generic[*Ts, P]): + fn: Callable[P, tuple[*Ts]] + +reveal_type(TypeVarTupleWithParamSpec[[str, int]]().fn) # revealed: (str, int, /) -> tuple[()] +reveal_type(TypeVarTupleWithParamSpec[int, [str, int]]().fn) # revealed: (str, int, /) -> tuple[int] +reveal_type(TypeVarTupleWithParamSpec[int, str, [str, int]]().fn) # revealed: (str, int, /) -> tuple[int, str] + +# error: [invalid-type-arguments] +reveal_type(TypeVarTupleWithParamSpec[str, int]().fn) # revealed: (...) -> tuple[str] + +reveal_type(TypeVarTupleWithParamSpec[str, int, []]().fn) # revealed: () -> tuple[str, int] +reveal_type(TypeVarTupleWithParamSpec[str, int, ...]().fn) # revealed: (...) -> tuple[str, int] +``` + +### Inferred specialization from construction + +Calling a generic class without explicit type arguments infers its specialization from the +constructor arguments. + +```py +from typing import Generic, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +class Positional(Generic[*Ts]): + def __init__(self, shape: tuple[*Ts]) -> None: + self.shape = shape + +class Variadic(Generic[*Ts]): + def __init__(self, *shape: *Ts) -> None: + self.shape = shape + +reveal_type(Positional(())) # revealed: Positional[()] +reveal_type(Positional((1, "a"))) # revealed: Positional[int, str] + +# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. +reveal_type(Variadic()) # revealed: Variadic[*tuple[Unknown, ...]] +reveal_type(Variadic(1, "a")) # revealed: Variadic[*tuple[Unknown, ...]] + +def _(i: int, s: str) -> None: + reveal_type(Positional((i, s))) # revealed: Positional[int, str] + reveal_type(Variadic(i, s)) # revealed: Variadic[*tuple[Unknown, ...]] +``` + +### Unspecified type arguments + +When a generic class parameterized by a type variable tuple is used without any type parameters and +the `TypeVarTuple` has no default value, it behaves as if the type variable tuple was substituted +with `tuple[Any, ...]`. ty represents the missing type information as `tuple[Unknown, ...]`, +distinguishing it from an explicitly provided `tuple[Any, ...]`. + +```py +from typing import Generic, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +class Unspecified(Generic[*Ts]): + attr: tuple[*Ts] + +unspecified = Unspecified() +reveal_type(unspecified) # revealed: Unspecified[*tuple[Unknown, ...]] +reveal_type(unspecified.attr) # revealed: tuple[Unknown, ...] +``` + +### Default type arguments + +A defaulted type variable tuple supplies its unpacked tuple when the generic class is not explicitly +specialized. Explicit type arguments override the default. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Generic, TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts", default=Unpack[tuple[int, str]]) + +# error: [invalid-legacy-type-variable] "The default value for `TypeVarTuple` must be an unpacked tuple type or another TypeVarTuple" +InvalidDefault = TypeVarTuple("InvalidDefault", default=tuple[int, str]) + +class WithDefault(Generic[*Ts]): + attr: tuple[*Ts] + +reveal_type(WithDefault().attr) # revealed: tuple[int, str] +reveal_type(WithDefault[bool, bytes]().attr) # revealed: tuple[bool, bytes] +``` + +### Backported default type arguments + +`typing_extensions.TypeVarTuple` backports the `default` parameter to older Python versions. + +```toml +[environment] +python-version = "3.10" +``` + +```py +from typing import Generic +from typing_extensions import TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts", default=Unpack[tuple[int, str]]) + +class WithBackportedDefault(Generic[Unpack[Ts]]): + attr: tuple[Unpack[Ts]] + +reveal_type(WithBackportedDefault().attr) # revealed: tuple[int, str] +``` + +## Type Aliases + +### Legacy generic aliases + +```py +from typing import TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts = TypeVarTuple("Ts") +U = TypeVar("U") + +Simple = tuple[*Ts] +Between = tuple[T, *Ts, U] +Prefix = tuple[T, *Ts] +Suffix = tuple[*Ts, U] + +def _( + a1: Simple[()], + a2: Simple[int, str], + a3: Between[int, str], + a4: Between[int, bool, str], + a5: Between[int, bool, bytes, str], + a6: Prefix[bool], + a7: Prefix[bool, int, str], + a8: Suffix[bool], + a9: Suffix[int, str, bool], + # error: [invalid-type-arguments] "No type argument provided for required type variable `U`" + a10: Between[int], +): + reveal_type(a1) # revealed: tuple[()] + reveal_type(a2) # revealed: tuple[int, str] + reveal_type(a3) # revealed: tuple[int, str] + reveal_type(a4) # revealed: tuple[int, bool, str] + reveal_type(a5) # revealed: tuple[int, bool, bytes, str] + reveal_type(a6) # revealed: tuple[bool] + reveal_type(a7) # revealed: tuple[bool, int, str] + reveal_type(a8) # revealed: tuple[bool] + reveal_type(a9) # revealed: tuple[int, str, bool] + reveal_type(a10) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +``` + +### Variadic arguments require variadic aliases + +An unpacked type variable tuple or arbitrary-length tuple cannot be used to specialize a +non-variadic alias. + +```py +from typing import TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts = TypeVarTuple("Ts") + +Alias = tuple[int, T] + +# error: [invalid-type-form] "`Unpack` can only be used with a fixed tuple type in this context" +InvalidTypeVarTuple = Alias[*Ts] + +# error: [invalid-type-form] "`Unpack` can only be used with a fixed tuple type in this context" +InvalidUnboundedTuple = Alias[*tuple[float, ...]] +``` + +### Unpacked tuple type arguments + +```py +from typing import TypeVarTuple + +Ts = TypeVarTuple("Ts") + +Alias = tuple[int, *Ts] + +def _(a1: Alias[*tuple[str, bool]], a2: Alias[*tuple[str, ...]]) -> None: + reveal_type(a1) # revealed: tuple[int, str, bool] + reveal_type(a2) # revealed: tuple[int, *tuple[str, ...]] +``` + +### Unspecified alias type arguments + +A bare variadic alias substitutes an unknown-length tuple of `Any`. + +```py +from typing import Any, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +Alias = tuple[bytes, *Ts] + +def _(a1: Alias, a2: Alias[*tuple[Any, ...]]) -> None: + reveal_type(a1) # revealed: tuple[bytes, *tuple[Unknown, ...]] + reveal_type(a2) # revealed: tuple[bytes, *tuple[Any, ...]] +``` + +### Splitting arbitrary-length tuples + +```py +from typing import TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts = TypeVarTuple("Ts") + +First = tuple[*Ts, T] +Second = tuple[T, *Ts] + +def _( + f1: First[*tuple[int, ...]], + f2: First[*tuple[int, ...], str], + s1: Second[*tuple[int, ...]], + s2: Second[str, *tuple[int, ...]], +): + reveal_type(f1) # revealed: tuple[*tuple[int, ...], int] + reveal_type(f2) # revealed: tuple[*tuple[int, ...], str] + reveal_type(s1) # revealed: tuple[int, *tuple[int, ...]] + reveal_type(s2) # revealed: tuple[str, *tuple[int, ...]] +``` + +### Type variable tuples cannot be split + +Unlike an arbitrary-length tuple, a type variable tuple cannot be split to satisfy a fixed type +parameter before or after another type variable tuple. + +```py +from typing import TypeVar, TypeVarTuple + +T = TypeVar("T") +Ts1 = TypeVarTuple("Ts1") +Ts2 = TypeVarTuple("Ts2") + +Prefix = tuple[T, *Ts1] +Suffix = tuple[*Ts1, T] + +# error: [invalid-type-form] "A TypeVarTuple cannot be split to provide a fixed type argument" +InvalidPrefix = Prefix[*Ts2] + +# error: [invalid-type-form] "A TypeVarTuple cannot be split to provide a fixed type argument" +InvalidSuffix = Suffix[*Ts2] +``` + +### Variadic substitutions + +Legacy aliases can forward a type variable tuple. + +```py +from typing import TypeVar, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +First = tuple[bytes, *Ts] +Second = First[int, *Ts] + +def f(a1: First[str, bool], a2: Second[str, bool]) -> None: + reveal_type(a1) # revealed: tuple[bytes, str, bool] + reveal_type(a2) # revealed: tuple[bytes, int, str, bool] +``` + +### Using Callable + +```py +from typing import Callable, TypeVarTuple + +Ts = TypeVarTuple("Ts") + +Alias = Callable[[*Ts], None] + +def test(fn: Alias[int, *Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def fn0(a: int) -> None: ... +def fn1(a: int, b: str) -> None: ... +def fn2(a: int, b: str, c: bytes) -> None: ... + +# TODO: Should reveal `tuple[()]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `(int, /, *args: tuple[Unknown, ...]) -> None`, found `def fn0(a: int) -> None`" +reveal_type(test(fn0)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[str]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `(int, /, *args: tuple[Unknown, ...]) -> None`, found `def fn1(a: int, b: str) -> None`" +reveal_type(test(fn1)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[str, bytes]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `(int, /, *args: tuple[Unknown, ...]) -> None`, found `def fn2(a: int, b: str, c: bytes) -> None`" +reveal_type(test(fn2)) # revealed: tuple[Unknown, ...] +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md new file mode 100644 index 0000000000..8547a24329 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/unpack.md @@ -0,0 +1,200 @@ +# Legacy `typing.Unpack` + +```toml +[environment] +python-version = "3.11" +``` + +`Unpack[Ts]` is the legacy spelling of `*Ts`. The shared semantics of type variable tuples are +covered in `../pep695/typevartuple.md`; this file checks the distinct syntax paths used by `Unpack`. + +## Generic specialization + +`Unpack` can introduce a type variable tuple in a legacy generic declaration. An unpacked fixed +tuple can also provide multiple type arguments when specializing the generic. + +```py +from typing import Generic, TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts") + +class Array(Generic[Unpack[Ts]]): + value: tuple[Unpack[Ts]] + +reveal_type(Array[()]().value) # revealed: tuple[()] +reveal_type(Array[int, str]().value) # revealed: tuple[int, str] +reveal_type(Array[Unpack[tuple[int, str]]]().value) # revealed: tuple[int, str] +``` + +## Variadic parameter inference + +An unpacked type variable tuple used for `*args` preserves the number and types of positional +arguments. + +```py +from typing import TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts") + +def collect(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: + reveal_type(args) # revealed: tuple[*Ts@collect] + raise NotImplementedError + +# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. +reveal_type(collect()) # revealed: tuple[Unknown, ...] +reveal_type(collect(1, "a")) # revealed: tuple[Unknown, ...] +``` + +## Callable parameters + +`Unpack` expands a type variable tuple into a callable's positional parameter list. The same tuple +can describe the arguments forwarded to that callable. + +```py +from typing import Callable, TypeVar, TypeVarTuple, Unpack + +R = TypeVar("R") +Ts = TypeVarTuple("Ts") + +def invoke( + callback: Callable[[Unpack[Ts]], R], + *args: Unpack[Ts], +) -> R: + raise NotImplementedError + +def format_value(value: int, label: str, /) -> str: + return f"{label}: {value}" + +reveal_type(invoke(format_value, 1, "value")) # revealed: str +# TODO: Validate arguments matched to the variadic parameter against the `TypeVarTuple` inferred +# from the callback. +reveal_type(invoke(format_value, 1)) # revealed: str +``` + +## Type aliases + +A legacy alias can use `Unpack[Ts]` and accept either individual types or an unpacked tuple type. + +```py +from typing import TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts") + +Alias = tuple[int, Unpack[Ts]] + +def f( + fixed: Alias[str, bool], + unbounded: Alias[Unpack[tuple[str, ...]]], +) -> None: + reveal_type(fixed) # revealed: tuple[int, str, bool] + reveal_type(unbounded) # revealed: tuple[int, *tuple[str, ...]] +``` + +## Unsupported union unpacking + +Unpacking a type variable tuple into `Union` is currently not supported. Both the rejected union and +runtime element access recover to `object`. + +```py +from typing import TypeVarTuple, Union, Unpack + +Ts = TypeVarTuple("Ts") + +# TODO: shouldn't error +# error: [invalid-type-form] +def reject_union(value: Union[Unpack[Ts]]) -> None: + # TODO: should reveal `Union[*Ts]` representation + reveal_type(value) # revealed: object + +def element_types(values: tuple[Unpack[Ts]]) -> None: + # TODO: should reveal `Union[*Ts]` representation + reveal_type(values[0]) # revealed: object + + for value in values: + # TODO: should reveal `Union[*Ts]` representation + reveal_type(value) # revealed: object +``` + +## Concrete and nested tuple unpacking + +`Unpack` can expand a concrete tuple annotation for `*args`, including a nested unbounded tuple. + +```py +from typing import Unpack + +def accept( + *args: Unpack[tuple[bool, Unpack[tuple[str, ...]], bytes]], +) -> None: ... + +accept(True, "phase", "status", b"ok") +accept(True, b"ok") +# TODO: error: [invalid-argument-type] "Argument to function `accept` is incorrect: Expected `tuple[bool, *tuple[str, ...], bytes]`" +accept(True, 1, b"bad") +``` + +## Defaults + +A type variable tuple default can use `Unpack`, and an explicit specialization overrides it. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Generic, TypeVarTuple, Unpack + +Ts = TypeVarTuple("Ts", default=Unpack[tuple[int, str]]) + +class WithDefault(Generic[Unpack[Ts]]): + value: tuple[Unpack[Ts]] + +reveal_type(WithDefault().value) # revealed: tuple[int, str] +reveal_type(WithDefault[bool, bytes]().value) # revealed: tuple[bool, bytes] +``` + +## Validation + +`Unpack` requires a tuple operand, and a tuple specialization can contain only one variadic unpack. + +```py +from typing import Generic, TypeVar, TypeVarTuple, Unpack + +U = TypeVar("U") +Ts = TypeVarTuple("Ts") +Xs = TypeVarTuple("Xs") +Ys = TypeVarTuple("Ys") + +class Pair(Generic[Unpack[Ts], U]): ... + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class MultipleUnpack(Generic[Unpack[Xs], Unpack[Ys]]): ... + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class StarThenUnpack(Generic[*Xs, Unpack[Ys]]): ... + +# error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" +class UnpackThenStar(Generic[Unpack[Xs], *Ys]): ... + +def invalid( + # error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" + non_tuple: Pair[Unpack[int], str], + # error: [invalid-type-form] "Multiple unpacked variadic tuples are not allowed in a `tuple` specialization" + multiple: tuple[Unpack[Ts], Unpack[tuple[str, ...]]], +) -> None: + reveal_type(non_tuple) # revealed: Pair[*tuple[Unknown, ...], str] + +# error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" +def invalid_vararg(*args: Unpack[int]) -> None: + reveal_type(args) # revealed: tuple[Unknown, ...] + +# error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" +def invalid_stringified_vararg(*args: "Unpack[int]") -> None: + reveal_type(args) # revealed: tuple[Unknown, ...] + +# error: [invalid-type-form] "`Unpack` cannot be nested" +def nested(*args: Unpack[Unpack[tuple[int, ...]]]) -> None: ... + +# error: [invalid-type-form] "Bare TypeVarTuple `Ts` is not valid in this context in a parameter annotation" +def nested_bare_typevartuple(*args: Unpack[tuple[Ts]]) -> None: ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md index 4814bb9acf..51dde06b26 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md @@ -26,15 +26,13 @@ reveal_type(generic_context(SingleTypevar)) # revealed: ty_extensions._internal.GenericContext[T@MultipleTypevars, S@MultipleTypevars] reveal_type(generic_context(MultipleTypevars)) -# TODO: support `TypeVarTuple` properly -# (these should include the `TypeVarTuple`s in their generic contexts) # revealed: ty_extensions._internal.GenericContext[P@SingleParamSpec] reveal_type(generic_context(SingleParamSpec)) # revealed: ty_extensions._internal.GenericContext[T@TypeVarAndParamSpec, P@TypeVarAndParamSpec] reveal_type(generic_context(TypeVarAndParamSpec)) -# revealed: ty_extensions._internal.GenericContext[] +# revealed: ty_extensions._internal.GenericContext[Ts@SingleTypeVarTuple] reveal_type(generic_context(SingleTypeVarTuple)) -# revealed: ty_extensions._internal.GenericContext[T@TypeVarAndTypeVarTuple] +# revealed: ty_extensions._internal.GenericContext[T@TypeVarAndTypeVarTuple, Ts@TypeVarAndTypeVarTuple] reveal_type(generic_context(TypeVarAndTypeVarTuple)) ``` @@ -721,18 +719,20 @@ info: See https://typing.python.org/en/latest/spec/generics.html#defaults-follow ```py # snapshot: invalid-type-variable-default +# error: [invalid-type-form] "Type alias `Alias4` cannot have multiple `TypeVarTuple` type parameters" +# error: [invalid-type-form] "Multiple unpacked variadic tuples are not allowed in a `tuple` specialization" type Alias4[*Us, *Ts = *tuple[int, str]] = tuple[*Us, *Ts] ``` ```snapshot error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter - --> src/mdtest_snippet.py:8:13 - | -8 | type Alias4[*Us, *Ts = *tuple[int, str]] = tuple[*Us, *Ts] - | --- ^^^^^^^^^^^^^^^^^^^^^^ `Ts` has a default - | | - | `Us` is a TypeVarTuple - | + --> src/mdtest_snippet.py:10:13 + | +10 | type Alias4[*Us, *Ts = *tuple[int, str]] = tuple[*Us, *Ts] + | --- ^^^^^^^^^^^^^^^^^^^^^^ `Ts` has a default + | | + | `Us` is a TypeVarTuple + | info: See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md index 16452e6204..60925489a0 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md @@ -25,15 +25,13 @@ reveal_type(generic_context(SingleTypevar)) # revealed: ty_extensions._internal.GenericContext[T@MultipleTypevars, S@MultipleTypevars] reveal_type(generic_context(MultipleTypevars)) -# TODO: support `TypeVarTuple` properly -# (these should include the `TypeVarTuple`s in their generic contexts) # revealed: ty_extensions._internal.GenericContext[P@SingleParamSpec] reveal_type(generic_context(SingleParamSpec)) # revealed: ty_extensions._internal.GenericContext[T@TypeVarAndParamSpec, P@TypeVarAndParamSpec] reveal_type(generic_context(TypeVarAndParamSpec)) -# revealed: ty_extensions._internal.GenericContext[] +# revealed: ty_extensions._internal.GenericContext[Ts@SingleTypeVarTuple] reveal_type(generic_context(SingleTypeVarTuple)) -# revealed: ty_extensions._internal.GenericContext[T@TypeVarAndTypeVarTuple] +# revealed: ty_extensions._internal.GenericContext[T@TypeVarAndTypeVarTuple, Ts@TypeVarAndTypeVarTuple] reveal_type(generic_context(TypeVarAndTypeVarTuple)) ``` @@ -997,6 +995,7 @@ class Quux[*Ts, T1 = int, **P = [int, str]]: ... class Corge[*Ts, T1 = int, T2 = str, **P = [int, str]]: ... # error: [invalid-type-variable-default] +# error: [invalid-type-form] "Generic class `Grault` cannot have multiple `TypeVarTuple` type parameters" class Grault[*Us, *Ts = *tuple[int, str]]: ... # These are fine: diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md index 3a7bcd345c..82c9f371c2 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/concatenate.md @@ -564,12 +564,10 @@ def only_variadic(*args: str, **kwargs: int) -> None: ... reveal_type(only_variadic) # revealed: (...) -> None -# TODO: This should accept the callable and reveal `(*args: str, **kwargs: int) -> None`. -# error: [invalid-argument-type] @decorator def unpack_variadic(*args: *tuple[int, *tuple[str, ...]], **kwargs: int) -> None: ... -reveal_type(unpack_variadic) # revealed: (...) -> None +reveal_type(unpack_variadic) # revealed: (*args: str, **kwargs: int) -> None ``` ## `Concatenate` with `ParamSpec` in generic function calls diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md new file mode 100644 index 0000000000..2f00ed577f --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md @@ -0,0 +1,1090 @@ +# PEP 695 `TypeVarTuple` + +```toml +[environment] +python-version = "3.12" +``` + +## Definition + +A PEP 695 type variable tuple is introduced with a single starred type parameter. + +```py +def foo[*Ts](*args: *Ts) -> None: + reveal_type(Ts) # revealed: TypeVarTuple + reveal_type(args) # revealed: tuple[*Ts@foo] +``` + +## Variance inference + +PEP 695 type variable tuples infer variance from how the class uses them. + +```py +class CovariantArray[*Ts]: + def get(self) -> tuple[*Ts]: + raise NotImplementedError + +covariant_ok: CovariantArray[object] = CovariantArray[int]() +covariant_error: CovariantArray[int] = CovariantArray[object]() # error: [invalid-assignment] + +class ContravariantArray[*Ts]: + def set(self, value: tuple[*Ts]) -> None: + raise NotImplementedError + +contravariant_ok: ContravariantArray[int] = ContravariantArray[object]() +contravariant_error: ContravariantArray[object] = ContravariantArray[int]() # error: [invalid-assignment] + +class InvariantArray[*Ts]: + values: tuple[*Ts] + +invariant_out: InvariantArray[object] = InvariantArray[int]() # error: [invalid-assignment] +invariant_in: InvariantArray[int] = InvariantArray[object]() # error: [invalid-assignment] +``` + +## Generic Classes + +### Explicit specialization + +```py +class Simple[*Ts]: + attr: tuple[*Ts] + +reveal_type(Simple[()]().attr) # revealed: tuple[()] +reveal_type(Simple[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Simple[*tuple[int, str]]().attr) # revealed: tuple[int, str] + +# error: [invalid-type-form] "List literals are not allowed in this context in a type expression" +reveal_type(Simple[[int, str]]().attr) # revealed: tuple[Unknown] +# error: [invalid-type-form] "List literals are not allowed in this context in a type expression" +reveal_type(Simple[*[int, str]]().attr) # revealed: tuple[Unknown, ...] +``` + +```py +class Prefix[T, *Ts]: + attr: tuple[T, *Ts] + +reveal_type(Prefix[int]().attr) # revealed: tuple[int] +reveal_type(Prefix[int, bool]().attr) # revealed: tuple[int, bool] +reveal_type(Prefix[int, bool, str]().attr) # revealed: tuple[int, bool, str] +reveal_type(Prefix[int, *tuple[bool, str]]().attr) # revealed: tuple[int, bool, str] + +# TODO: Should this raise an error? +reveal_type(Prefix().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...]] +``` + +```py +class Suffix[*Ts, T]: + attr: tuple[*Ts, T] + +reveal_type(Suffix[int]().attr) # revealed: tuple[int] +reveal_type(Suffix[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Suffix[int, str, bool]().attr) # revealed: tuple[int, str, bool] +reveal_type(Suffix[*tuple[int, str], bool]().attr) # revealed: tuple[int, str, bool] + +# TODO: Should this raise an error? +reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Unknown] +``` + +```py +class Between[T, *Ts, U]: + attr: tuple[T, *Ts, U] + +reveal_type(Between[int, str]().attr) # revealed: tuple[int, str] +reveal_type(Between[int, bool, str]().attr) # revealed: tuple[int, bool, str] +reveal_type(Between[int, bool, bytes, str]().attr) # revealed: tuple[int, bool, bytes, str] +reveal_type(Between[int, *tuple[bool], str]().attr) # revealed: tuple[int, bool, str] + +reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +# error: [invalid-type-arguments] "No type argument provided for required type variable `U` of class `Between`" +reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +``` + +### `TypeVarTuple` with `ParamSpec` + +```py +from typing import Callable + +class TypeVarTupleWithParamSpec[*Ts, **P]: + fn: Callable[P, tuple[*Ts]] + +reveal_type(TypeVarTupleWithParamSpec[[str, int]]().fn) # revealed: (str, int, /) -> tuple[()] +reveal_type(TypeVarTupleWithParamSpec[int, [str, int]]().fn) # revealed: (str, int, /) -> tuple[int] +reveal_type(TypeVarTupleWithParamSpec[int, str, [str, int]]().fn) # revealed: (str, int, /) -> tuple[int, str] + +# error: [invalid-type-arguments] +reveal_type(TypeVarTupleWithParamSpec[str, int]().fn) # revealed: (...) -> tuple[str] + +reveal_type(TypeVarTupleWithParamSpec[str, int, []]().fn) # revealed: () -> tuple[str, int] +reveal_type(TypeVarTupleWithParamSpec[str, int, ...]().fn) # revealed: (...) -> tuple[str, int] +``` + +### Inferred specialization from construction + +Calling a generic class without explicit type arguments infers its specialization from the +constructor arguments. + +```py +class Positional[*Ts]: + def __init__(self, shape: tuple[*Ts]) -> None: + self.shape = shape + +class Variadic[*Ts]: + def __init__(self, *shape: *Ts) -> None: + self.shape = shape + +reveal_type(Positional(())) # revealed: Positional[()] +reveal_type(Positional((1, "a"))) # revealed: Positional[int, str] + +# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. +reveal_type(Variadic()) # revealed: Variadic[*tuple[Unknown, ...]] +reveal_type(Variadic(1, "a")) # revealed: Variadic[*tuple[Unknown, ...]] + +def _(i: int, s: str) -> None: + reveal_type(Positional((i, s))) # revealed: Positional[int, str] + reveal_type(Variadic(i, s)) # revealed: Variadic[*tuple[Unknown, ...]] +``` + +### Unspecified type arguments + +An unsubscripted variadic generic behaves as if it used an unknown-length tuple of `Any` arguments. +ty represents the missing type information as `Unknown`, distinguishing it from explicitly provided +`Any`. + +```py +class Unspecified[*Ts]: + attr: tuple[*Ts] + +unspecified = Unspecified() +reveal_type(unspecified) # revealed: Unspecified[*tuple[Unknown, ...]] +reveal_type(unspecified.attr) # revealed: tuple[Unknown, ...] +``` + +### Default type arguments + +A defaulted type variable tuple supplies its unpacked tuple when the generic class is not explicitly +specialized. Explicit type arguments override the default. + +```toml +[environment] +python-version = "3.13" +``` + +```py +class WithDefault[*Ts = *tuple[int, str]]: + attr: tuple[*Ts] + +reveal_type(WithDefault().attr) # revealed: tuple[int, str] +reveal_type(WithDefault[bool, bytes]().attr) # revealed: tuple[bool, bytes] +``` + +### Gradual specializations + +A type variable tuple remains assignable to an explicitly gradual specialization of its generic +class. + +```py +from typing import Any + +class Array[*Ts]: + def erase_shape(self) -> "Array[*tuple[Any, ...]]": + return self +``` + +## Functions + +### Multiple type variable tuples + +Generic functions can declare multiple type variable tuples because their type parameters are +inferred from arguments; functions cannot be explicitly specialized. + +```py +def pair[*Ts1, *Ts2](first: tuple[*Ts1], second: tuple[*Ts2]) -> None: ... +``` + +### Tuple arguments and returns + +```py +def simple[*Ts](x: tuple[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def with_prefix[T, *Ts](x: T, y: tuple[*Ts]) -> tuple[T, *Ts]: + raise NotImplementedError + +def with_suffix[*Ts, U](x: tuple[*Ts], y: U) -> tuple[*Ts, U]: + raise NotImplementedError + +def both[T, *Ts, U](x: T, y: tuple[*Ts], z: U) -> tuple[T, *Ts, U]: + raise NotImplementedError + +def f(i: int, s: str, b: bool, t: tuple[int, str], vt: tuple[int, ...]) -> None: + reveal_type(simple(())) # revealed: tuple[()] + reveal_type(simple((i, s))) # revealed: tuple[int, str] + reveal_type(simple(t)) # revealed: tuple[int, str] + reveal_type(simple(vt)) # revealed: tuple[int, ...] + + reveal_type(with_prefix(i, (s, b))) # revealed: tuple[int, str, bool] + reveal_type(with_prefix(i, t)) # revealed: tuple[int, int, str] + reveal_type(with_prefix(i, vt)) # revealed: tuple[int, *tuple[int, ...]] + reveal_type(with_prefix(t, vt)) # revealed: tuple[tuple[int, str], *tuple[int, ...]] + + reveal_type(with_suffix((i, s), b)) # revealed: tuple[int, str, bool] + reveal_type(with_suffix(t, b)) # revealed: tuple[int, str, bool] + reveal_type(with_suffix(vt, b)) # revealed: tuple[*tuple[int, ...], bool] + reveal_type(with_suffix(vt, t)) # revealed: tuple[*tuple[int, ...], tuple[int, str]] + + reveal_type(both(i, (i, s), b)) # revealed: tuple[int, int, str, bool] + reveal_type(both(i, t, b)) # revealed: tuple[int, int, str, bool] + reveal_type(both(i, vt, b)) # revealed: tuple[int, *tuple[int, ...], bool] + + # TODO: Avoid also reporting an invalid argument type for the first unpacked element. + # error: [invalid-argument-type] "Argument to function `simple` is incorrect: Expected `tuple[Unknown, ...]`, found `int`" + # error: [too-many-positional-arguments] "Too many positional arguments to function `simple`: expected 1, got 2" + reveal_type(simple(*t)) # revealed: tuple[Unknown, ...] +``` + +### Assignability to fixed-length tuples + +An unspecialized type variable tuple can contain any number of elements, so a tuple containing one +cannot be assigned to a fixed-length tuple, even when its fixed prefix and suffix match. + +```py +def arbitrary_pack[*Ts](value: tuple[*Ts]) -> tuple[int, str]: + return value # error: [invalid-return-type] + +def middle_pack[*Ts](value: tuple[int, *Ts, str]) -> tuple[int, str]: + return value # error: [invalid-return-type] +``` + +### Assignability involving type variable tuples + +A symbolic type variable tuple can be erased to a homogeneous `object` tuple, but a homogeneous +tuple cannot be used to construct an arbitrary symbolic pack. Two independently bound packs are also +not interchangeable. + +```py +def erase_pack[*Ts](values: tuple[*Ts]) -> tuple[object, ...]: + return values + +def preserve_pack[*Ts](values: tuple[*Ts]) -> tuple[*Ts]: + return values + +def preserve_pack_with_boundaries[*Ts](values: tuple[int, *Ts, str]) -> tuple[object, *Ts, object]: + return values + +def reject_object_pack[*Ts](values: tuple[object, ...], witness: tuple[*Ts]) -> tuple[*Ts]: + return values # error: [invalid-return-type] + +def reject_int_pack[*Ts](values: tuple[int, ...], witness: tuple[*Ts]) -> tuple[*Ts]: + return values # error: [invalid-return-type] + +class Outer[*Ts]: + def reject_unrelated_pack[*Us](self, values: tuple[*Ts]) -> tuple[*Us]: + return values # error: [invalid-return-type] +``` + +Materializing a type variable tuple can change its default without changing the identity of the +bound type variable occurrence. + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Any +from ty_extensions import Top, static_assert +from ty_extensions._internal import is_assignable_to + +def materialized_default[*Ts = *tuple[Any, ...]]() -> None: + static_assert(is_assignable_to(tuple[*Ts], Top[tuple[*Ts]])) +``` + +### Starred variadic parameters + +An unpacked `TypeVarTuple` can annotate `*args`. Inferring the `TypeVarTuple` from arguments matched +to the variadic parameter is not yet supported, so these calls use a gradual specialization. + +```py +def simple[*Ts](*args: *Ts) -> tuple[*Ts]: + reveal_type(args) # revealed: tuple[*Ts@simple] + raise NotImplementedError + +def with_prefix[T, *Ts](prefix: T, *args: *Ts) -> tuple[T, *Ts]: + raise NotImplementedError + +def with_kw_only[T, *Ts](*args: *Ts, kw: T) -> tuple[*Ts, T]: + raise NotImplementedError + +def f(i: int, s: str, b: bool, t: tuple[int, str], vt: tuple[int, ...]) -> None: + reveal_type(simple()) # revealed: tuple[Unknown, ...] + reveal_type(simple(i, s)) # revealed: tuple[Unknown, ...] + reveal_type(simple(*(i, s))) # revealed: tuple[Unknown, ...] + reveal_type(simple(t)) # revealed: tuple[Unknown, ...] + reveal_type(simple(*t)) # revealed: tuple[Unknown, ...] + reveal_type(simple(*vt)) # revealed: tuple[Unknown, ...] + + reveal_type(with_prefix(i)) # revealed: tuple[int, *tuple[Unknown, ...]] + reveal_type(with_prefix(i, s, b)) # revealed: tuple[int, *tuple[Unknown, ...]] + reveal_type(with_prefix(*t)) # revealed: tuple[int, *tuple[Unknown, ...]] + reveal_type(with_prefix(i, *t)) # revealed: tuple[int, *tuple[Unknown, ...]] + reveal_type(with_prefix(*vt)) # revealed: tuple[int, *tuple[Unknown, ...]] + reveal_type(with_prefix(i, *vt)) # revealed: tuple[int, *tuple[Unknown, ...]] + + reveal_type(with_kw_only(kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + reveal_type(with_kw_only(i, s, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + reveal_type(with_kw_only(t, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + reveal_type(with_kw_only(*t, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + reveal_type(with_kw_only(vt, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + reveal_type(with_kw_only(*vt, kw=b)) # revealed: tuple[*tuple[Unknown, ...], bool] + + # error: [missing-argument] "No argument provided for required parameter `kw` of function `with_kw_only`" + reveal_type(with_kw_only(i, s, b)) # revealed: tuple[*tuple[Unknown, ...], Unknown] +``` + +### Callable inference + +`Callable` accepts unpacked `TypeVarTuple`s in its positional parameter list. + +```py +from typing import Callable + +def simple[*Ts](callback: Callable[[*Ts], tuple[*Ts]]) -> tuple[*Ts]: + reveal_type(callback) # revealed: (*Ts@simple) -> tuple[*Ts@simple] + raise NotImplementedError + +def positional_only(x: int, y: str, /) -> tuple[int, str]: + raise NotImplementedError + +def no_parameters() -> tuple[()]: + raise NotImplementedError + +def standard(x: int, y: str) -> tuple[int, str]: + raise NotImplementedError + +def positional_variadic(x: int, *args: str) -> tuple[int, *tuple[str, ...]]: + raise NotImplementedError + +def variadic1(*args: int) -> tuple[int, ...]: + raise NotImplementedError + +def variadic2(*args: int) -> tuple[str, ...]: + raise NotImplementedError + +def accepts_object(value: object, /) -> tuple[int]: + raise NotImplementedError + +def keyword_only(*, x: int) -> tuple[int]: + raise NotImplementedError + +def gradual(callback: Callable[..., tuple[int, ...]]) -> None: + reveal_type(simple(callback)) # revealed: tuple[int, ...] + +reveal_type(simple(no_parameters)) # revealed: tuple[()] +reveal_type(simple(positional_only)) # revealed: tuple[int, str] +reveal_type(simple(standard)) # revealed: tuple[int, str] +reveal_type(simple(positional_variadic)) # revealed: tuple[int, *tuple[str, ...]] +reveal_type(simple(variadic1)) # revealed: tuple[int, ...] +reveal_type(simple(accepts_object)) # revealed: tuple[int] + +# TODO: Report the incompatible return type after callable specialization fails. +reveal_type(simple(variadic2)) # revealed: tuple[Unknown, ...] +# error: [invalid-argument-type] "Argument to function `simple` is incorrect: Expected `(*args: Unknown) -> tuple[Unknown, ...]`, found `def keyword_only(*, x: int) -> tuple[int]`" +reveal_type(simple(keyword_only)) # revealed: tuple[Unknown, ...] +``` + +### Callable return inference + +An unpacked `TypeVarTuple` in a callable return type is inferred as one packed tuple, including +fixed elements surrounding it. + +```py +from typing import Callable + +def infer_return[*Ts](callback: Callable[[], tuple[*Ts]]) -> tuple[*Ts]: + raise NotImplementedError + +def empty_return() -> tuple[()]: + raise NotImplementedError + +def fixed_return() -> tuple[int, str]: + raise NotImplementedError + +def mixed_return() -> tuple[int, *tuple[str, ...]]: + raise NotImplementedError + +reveal_type(infer_return(empty_return)) # revealed: tuple[()] +reveal_type(infer_return(fixed_return)) # revealed: tuple[int, str] +reveal_type(infer_return(mixed_return)) # revealed: tuple[int, *tuple[str, ...]] + +def infer_return_middle[*Ts]( + callback: Callable[[], tuple[int, *Ts, bytes]], +) -> tuple[*Ts]: + raise NotImplementedError + +def fixed_middle() -> tuple[int, str, bytes]: + raise NotImplementedError + +def mixed_middle() -> tuple[int, *tuple[str, ...], bytes]: + raise NotImplementedError + +reveal_type(infer_return_middle(fixed_middle)) # revealed: tuple[str] +reveal_type(infer_return_middle(mixed_middle)) # revealed: tuple[str, ...] +``` + +### Callable inference with sub-call checking + +This usage pattern is similar to how `ParamSpec` can be used to accept a callable and its arguments +except that in the case of `TypeVarTuple` all parameters are positional-only. + +```py +from typing import Callable + +def invoke[*Ts, R](callback: Callable[[*Ts], R], *args: *Ts) -> R: + raise NotImplementedError + +def positional_only(x: int, y: str, /) -> tuple[int, str]: + raise NotImplementedError + +def standard(x: int, y: str) -> tuple[int, str]: + raise NotImplementedError + +def positional_variadic(x: int, *args: str) -> tuple[int, *tuple[str, ...]]: + raise NotImplementedError + +reveal_type(invoke(positional_only, 1, "a")) # revealed: tuple[int, str] +# TODO: Validate arguments matched to the variadic parameter against the `TypeVarTuple` inferred +# from the callback. +reveal_type(invoke(positional_only)) # revealed: tuple[int, str] +reveal_type(invoke(positional_only, 1)) # revealed: tuple[int, str] +reveal_type(invoke(positional_only, 1, 2)) # revealed: tuple[int, str] + +reveal_type(invoke(standard, 1, "a")) # revealed: tuple[int, str] +# error: [unknown-argument] "Argument `x` does not match any known parameter of function `invoke`" +# error: [unknown-argument] "Argument `y` does not match any known parameter of function `invoke`" +reveal_type(invoke(standard, x=1, y="a")) # revealed: tuple[int, str] + +reveal_type(invoke(positional_variadic, 1, "a", "b")) # revealed: tuple[int, *tuple[str, ...]] +reveal_type(invoke(positional_variadic, 1)) # revealed: tuple[int, *tuple[str, ...]] +reveal_type(invoke(positional_variadic)) # revealed: tuple[int, *tuple[str, ...]] + +def accept_forwarded[*Ts](callback: Callable[[*Ts], object], args: tuple[*Ts]) -> None: ... +def forward[*Ts](callback: Callable[[*Ts], object], *args: *Ts) -> None: + accept_forwarded(callback, args) + +def accept_mixed_forwarded[*Ts]( + callback: Callable[[int, *Ts, str], object], + args: tuple[int, *Ts, str], +) -> None: ... +def forward_mixed[*Ts]( + callback: Callable[[int, *Ts, str], object], + *args: *tuple[int, *Ts, str], +) -> None: + accept_mixed_forwarded(callback, args) +``` + +### Unsupported callable checks are deferred + +Until call binding can infer a `TypeVarTuple` from `*args`, a generic callback can leave the +expected callable with a gradual positional parameter list. Similarly, inferring each position from +an overload independently loses the correlation between overload branches. Avoid reporting these +cases until the missing inference is implemented. + +```py +from collections.abc import Awaitable, Callable +from typing import overload + +def start[*Ts](callback: Callable[[*Ts], Awaitable[object]], *args: *Ts) -> None: ... +async def waiter[T](value: T, mapping: dict[T, int]) -> None: ... + +values: dict[int, int] = {} +start(waiter, 1, values) + +def invoke[*Ts, R](callback: Callable[[*Ts], R], *args: *Ts) -> R: + raise NotImplementedError + +@overload +def correlated(left: str, right: str) -> str: ... +@overload +def correlated(left: bytes, right: bytes) -> bytes: ... +def correlated(left: str | bytes, right: str | bytes) -> str | bytes: + return left + +def wrapper[AnyStr: (str, bytes)](left: AnyStr, right: AnyStr) -> str | bytes: + return invoke(correlated, left, right) +``` + +### Callable inference with fixed positional parameters + +Fixed positional parameters surrounding an unpacked `TypeVarTuple` are excluded from the inferred +tuple. + +```py +from typing import Callable + +def infer_with_suffix[*Ts](callback: Callable[[int, *Ts, bytes], None]) -> tuple[*Ts]: + raise NotImplementedError + +def fixed_suffix(prefix: int, middle: str, suffix: bytes, /) -> None: ... +def empty_middle(prefix: int, suffix: bytes, /) -> None: ... +def unpacked_suffix(*args: *tuple[int, *tuple[str, ...], bytes]) -> None: ... + +reveal_type(infer_with_suffix(fixed_suffix)) # revealed: tuple[str] +reveal_type(infer_with_suffix(empty_middle)) # revealed: tuple[()] +reveal_type(infer_with_suffix(unpacked_suffix)) # revealed: tuple[str, ...] +``` + +### Nested unpacked callable parameters + +Nested unpacked tuple parameters are equivalent to their flattened form. + +```py +from typing import Callable + +def expect_nested( + callback: Callable[[int, *tuple[*tuple[str, ...], bytes], str], None], +) -> None: ... +def pass_flattened( + callback: Callable[[int, *tuple[str, ...], bytes, str], None], +) -> None: + # TODO: This should be assignable because the nested unpacking is equivalent to the flattened + # form. + # error: [invalid-argument-type] + expect_nested(callback) +``` + +### Callable inference with additional keyword parameters + +Additional keyword-only or variadic keyword parameters do not contribute to a `TypeVarTuple` +inferred from a `Callable`'s positional parameter list. + +```py +from typing import Callable + +def infer_positional[*Ts](callback: Callable[[*Ts], None]) -> tuple[*Ts]: + raise NotImplementedError + +def optional_keyword_only(x: int, y: str, *, debug: bool = False) -> None: ... +def extra_keywords(x: int, y: str, **kwargs: bool) -> None: ... + +reveal_type(infer_positional(optional_keyword_only)) # revealed: tuple[int, str] +reveal_type(infer_positional(extra_keywords)) # revealed: tuple[int, str] +``` + +### Callable protocol inference + +`Callable[[*Ts], R]` can only describe positional-only parameters. Callable protocols are used below +to test `TypeVarTuple` inference for signatures that combine variadic positional parameters with +keyword-only or variadic keyword parameters. + +#### Keyword-only parameters + +A callable protocol can combine a `TypeVarTuple` with required or optional keyword-only parameters +and a fixed positional prefix. + +```py +from typing import Protocol + +class KeywordOnlyCallback[*Ts](Protocol): + def __call__(self, *args: *Ts, flag: bool) -> None: ... + +def infer_keyword_only[*Ts](callback: KeywordOnlyCallback[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def explicit_keyword_only(x: int, y: str, *, flag: bool) -> None: ... +def positional_only_with_keyword(x: int, y: str, /, *, flag: bool) -> None: ... +def positional_or_keyword(x: int, y: str, flag: bool) -> None: ... +def keyword_catch_all(x: int, y: str, **kwargs: object) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_only` is incorrect: Expected `KeywordOnlyCallback[*tuple[Unknown, ...]]`, found `def explicit_keyword_only(x: int, y: str, *, flag: bool) -> None`" +reveal_type(infer_keyword_only(explicit_keyword_only)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_only` is incorrect: Expected `KeywordOnlyCallback[*tuple[Unknown, ...]]`, found `def positional_only_with_keyword(x: int, y: str, /, *, flag: bool) -> None`" +reveal_type(infer_keyword_only(positional_only_with_keyword)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_only` is incorrect: Expected `KeywordOnlyCallback[*tuple[Unknown, ...]]`, found `def positional_or_keyword(x: int, y: str, flag: bool) -> None`" +reveal_type(infer_keyword_only(positional_or_keyword)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_only` is incorrect: Expected `KeywordOnlyCallback[*tuple[Unknown, ...]]`, found `def keyword_catch_all(x: int, y: str, **kwargs: object) -> None`" +reveal_type(infer_keyword_only(keyword_catch_all)) # revealed: tuple[Unknown, ...] + +class OptionalKeywordCallback[*Ts](Protocol): + def __call__(self, *args: *Ts, flag: bool = False) -> None: ... + +def infer_optional_keyword[*Ts](callback: OptionalKeywordCallback[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def optional_keyword_callback(x: int, y: str, *, flag: bool = False) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_optional_keyword` is incorrect: Expected `OptionalKeywordCallback[*tuple[Unknown, ...]]`, found `def optional_keyword_callback(x: int, y: str, *, flag: bool = False) -> None`" +reveal_type(infer_optional_keyword(optional_keyword_callback)) # revealed: tuple[Unknown, ...] + +class PrefixedKeywordCallback[*Ts](Protocol): + def __call__(self, prefix: bytes, *args: *Ts, flag: bool) -> None: ... + +def infer_prefixed[*Ts](callback: PrefixedKeywordCallback[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def prefixed(prefix: bytes, x: int, y: str, *, flag: bool) -> None: ... +def prefixed_variadic(prefix: bytes, *args: str, flag: bool) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_prefixed` is incorrect: Expected `PrefixedKeywordCallback[*tuple[Unknown, ...]]`, found `def prefixed(prefix: bytes, x: int, y: str, *, flag: bool) -> None`" +reveal_type(infer_prefixed(prefixed)) # revealed: tuple[Unknown, ...] + +# An open-ended positional parameter can be inferred in an otherwise mixed signature. +reveal_type(infer_prefixed(prefixed_variadic)) # revealed: tuple[str, ...] +``` + +#### Variadic keyword parameters + +Variadic keyword parameters are matched separately from the positional parameters captured by a +`TypeVarTuple`. + +```py +from typing import Protocol + +class KeywordVariadicCallback[*Ts](Protocol): + def __call__(self, *args: *Ts, **kwargs: int) -> None: ... + +def infer_keyword_variadic[*Ts](callback: KeywordVariadicCallback[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def keyword_variadic(x: int, y: str, **kwargs: int) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_variadic` is incorrect: Expected `KeywordVariadicCallback[*tuple[Unknown, ...]]`, found `def keyword_variadic(x: int, y: str, **kwargs: int) -> None`" +reveal_type(infer_keyword_variadic(keyword_variadic)) # revealed: tuple[Unknown, ...] + +class KeywordOnlyAndVariadicCallback[*Ts](Protocol): + def __call__(self, *args: *Ts, flag: bool, **kwargs: int) -> None: ... + +def infer_keyword_only_and_variadic[*Ts]( + callback: KeywordOnlyAndVariadicCallback[*Ts], +) -> tuple[*Ts]: + raise NotImplementedError + +def keyword_only_and_variadic(x: int, y: str, *, flag: bool, **kwargs: int) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_keyword_only_and_variadic` is incorrect: Expected `KeywordOnlyAndVariadicCallback[*tuple[Unknown, ...]]`, found `def keyword_only_and_variadic(x: int, y: str, *, flag: bool, **kwargs: int) -> None`" +reveal_type(infer_keyword_only_and_variadic(keyword_only_and_variadic)) # revealed: tuple[Unknown, ...] + +class MultipleKeywordCallback[*Ts](Protocol): + def __call__(self, *args: *Ts, first: int, second: str) -> None: ... + +def infer_multiple_keywords[*Ts](callback: MultipleKeywordCallback[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def multiple_keyword_catch_all(x: int, y: str, **kwargs: object) -> None: ... + +# TODO: Should reveal `tuple[int, str]`. +# error: [invalid-argument-type] "Argument to function `infer_multiple_keywords` is incorrect: Expected `MultipleKeywordCallback[*tuple[Unknown, ...]]`, found `def multiple_keyword_catch_all(x: int, y: str, **kwargs: object) -> None`" +reveal_type(infer_multiple_keywords(multiple_keyword_catch_all)) # revealed: tuple[Unknown, ...] +``` + +### Length-sensitive inference + +If the same `TypeVarTuple` instance is used in multiple places in a signature or class, the exact +inference behavior is not specified in the typing spec. However, all usages must match in length. + +```py +def foo[*Ts](arg1: tuple[*Ts], arg2: tuple[*Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def f(i: int, s: str, b: bool) -> None: + reveal_type(foo((i, s), (b, i))) # revealed: tuple[int, str | int] + # error: [invalid-argument-type] "Argument to function `foo` is incorrect: Expected `tuple[int]`, found `tuple[str, bool]`" + reveal_type(foo((i,), (s, b))) # revealed: tuple[int] +``` + +## Type concatenation + +A type variable tuple can be combined with fixed leading or trailing types. + +```py +class Array[*Ts]: ... +class A: ... +class B: ... +class C: ... +class D: ... + +def add_letter_a[*Ts](x: Array[*Ts]) -> Array[A, *Ts]: + raise NotImplementedError + +def del_letter_a[*Ts](x: Array[A, *Ts]) -> Array[*Ts]: + raise NotImplementedError + +def add_letters[*Ts](x: Array[*Ts]) -> Array[A, *Ts, C]: + raise NotImplementedError + +def del_letter_c[*Ts](x: Array[*Ts, C]) -> Array[*Ts]: + raise NotImplementedError + +def generic[T, *Ts](x: T, y: Array[*Ts]) -> Array[T, *Ts]: + raise NotImplementedError + +reveal_type(add_letters(Array[B, D]())) # revealed: Array[A, B, D, C] +reveal_type(add_letter_a(Array[B, C]())) # revealed: Array[A, B, C] + +reveal_type(del_letter_a(Array[A, B]())) # revealed: Array[B] +# TODO: error: [invalid-argument-type] +reveal_type(del_letter_a(Array[B, C]())) # revealed: Array[C] + +reveal_type(del_letter_c(Array[A, B, C]())) # revealed: Array[A, B] +# TODO: error: [invalid-argument-type] +reveal_type(del_letter_c(Array[A, B]())) # revealed: Array[A] + +reveal_type(generic(A(), Array[B, D]())) # revealed: Array[A, B, D] +reveal_type(generic(A(), Array[()]())) # revealed: Array[A] +``` + +## Unpacking Unbounded Tuple Types + +An unpacked unbounded tuple can describe an unknown middle section while retaining fixed endpoints, +and it can be passed into a function that solves a type variable tuple. + +```py +from typing import Any + +def accept_any_in_between(x: tuple[bytes, *tuple[Any, ...], int]) -> None: ... +def carry_items[*Items](x: tuple[bytes, *Items, int]) -> tuple[*Items]: + raise NotImplementedError + +def f( + empty: tuple[bytes, int], + multi: tuple[bytes, str, bool, int], + truncated: tuple[bytes], + dynamic: tuple[bytes, *tuple[Any, ...], int], +) -> None: + accept_any_in_between(empty) + accept_any_in_between(multi) + # error: [invalid-argument-type] "Argument to function `accept_any_in_between` is incorrect: Expected `tuple[bytes, *tuple[Any, ...], int]`, found `tuple[bytes]`" + accept_any_in_between(truncated) + reveal_type(carry_items(dynamic)) # revealed: tuple[Any, ...] +``` + +When a mixed unbounded tuple is used to solve a `TypeVarTuple`, its fixed prefix and suffix remain +part of the solution. + +```py +def preserve[*Ts](value: tuple[*Ts]) -> tuple[*Ts]: + return value + +def f( + prefix: tuple[int, *tuple[str, ...]], + suffix: tuple[*tuple[str, ...], bytes], + mixed: tuple[int, *tuple[str, ...], bytes], +) -> None: + reveal_type(preserve(prefix)) # revealed: tuple[int, *tuple[str, ...]] + reveal_type(preserve(suffix)) # revealed: tuple[*tuple[str, ...], bytes] + reveal_type(preserve(mixed)) # revealed: tuple[int, *tuple[str, ...], bytes] +``` + +A tuple containing an unpacked tuple can precisely describe heterogeneous positional arguments, +including a variable-length middle portion or a type-variable prefix. + +```py +def accept_str_in_between(*args: *tuple[bool, *tuple[str, ...], bytes]) -> None: ... +def remove_bytes[*Prefix](*args: *tuple[*Prefix, bytes]) -> tuple[*Prefix]: + raise NotImplementedError + +accept_str_in_between(True, "phase", "status", b"ok") +accept_str_in_between(True, b"ok") +# TODO: error: [invalid-argument-type] "Argument to function `accept_str_in_between` is incorrect: Expected `tuple[bool, *tuple[str, ...], bytes]`" +accept_str_in_between(True, 1, b"bad") + +# TODO: Infer the `TypeVarTuple` from arguments matched to the variadic parameter. +reveal_type(remove_bytes(1, "record", b"sum")) # revealed: tuple[Unknown, ...] +``` + +## `@staticmethod` and `@classmethod` + +```py +from typing import Self + +class Foo[*Ts]: + @staticmethod + def static_method(*args: *Ts) -> None: ... + @classmethod + def class_method(cls, *args: *Ts) -> Self: + raise NotImplementedError + +reveal_type(Foo[int, str].class_method(1, "")) # revealed: Foo[int, str] + +foo = Foo[int, str]() +foo.static_method(1, "") +foo.class_method(1, "") + +# error: [invalid-argument-type] +foo.static_method(1, 2) +# error: [invalid-argument-type] +foo.class_method(1, 2) +``` + +## Type Aliases + +### Variadic aliases + +```py +type Simple[*Ts] = tuple[*Ts] +type Prefix[T, *Ts] = tuple[T, *Ts] +type Suffix[*Ts, T] = tuple[*Ts, T] +type Between[T, *Ts, U] = tuple[T, *Ts, U] + +def _( + a1: Simple[()], + a2: Simple[int, str], + a3: Between[int, str], + a4: Between[int, bool, str], + a5: Between[int, bool, bytes, str], + a6: Prefix[bool], + a7: Prefix[bool, int, str], + a8: Suffix[bool], + a9: Suffix[int, str, bool], + # error: [invalid-type-arguments] "No type argument provided for required type variable `U`" + a10: Between[int], +): + reveal_type(a1) # revealed: tuple[()] + reveal_type(a2) # revealed: tuple[int, str] + reveal_type(a3) # revealed: tuple[int, str] + reveal_type(a4) # revealed: tuple[int, bool, str] + reveal_type(a5) # revealed: tuple[int, bool, bytes, str] + reveal_type(a6) # revealed: tuple[bool] + reveal_type(a7) # revealed: tuple[bool, int, str] + reveal_type(a8) # revealed: tuple[bool] + reveal_type(a9) # revealed: tuple[int, str, bool] + reveal_type(a10) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +``` + +### Unpacked tuple type arguments + +```py +type Alias[*Ts] = tuple[int, *Ts] + +def _(a1: Alias[*tuple[str, bool]], a2: Alias[*tuple[str, ...]]) -> None: + reveal_type(a1) # revealed: tuple[int, str, bool] + reveal_type(a2) # revealed: tuple[int, *tuple[str, ...]] +``` + +### Unspecified alias type arguments + +A bare variadic alias substitutes an unknown-length tuple of `Any`, just like a bare variadic +generic class. + +```py +from typing import Any + +type Alias[*Fields] = tuple[bytes, *Fields] + +def _(a1: Alias, a2: Alias[*tuple[Any, ...]]) -> None: + reveal_type(a1) # revealed: tuple[bytes, *tuple[Unknown, ...]] + reveal_type(a2) # revealed: tuple[bytes, *tuple[Any, ...]] +``` + +### Splitting arbitrary-length tuples + +```py +type First[*Ts, T] = tuple[*Ts, T] +type Second[T, *Ts] = tuple[T, *Ts] + +reveal_type(First[*tuple[int, ...]]) # revealed: +reveal_type(First[*tuple[int, ...], str]) # revealed: +reveal_type(Second[*tuple[int, ...]]) # revealed: +reveal_type(Second[str, *tuple[int, ...]]) # revealed: +``` + +### Variadic substitutions + +A variadic alias can forward its remaining arguments to another variadic alias. + +```py +type First[*Ts] = tuple[bytes, *Ts] +type Second[*Ts] = First[int, *Ts] + +reveal_type(First[str, bool]) # revealed: +reveal_type(Second[str, bool]) # revealed: +``` + +### Unsupported union unpacking + +Unpacking a type variable tuple into `Union` is currently not supported. We recover to `object` +rather than interpreting the pack as a single union member. + +```py +from typing import Union + +# TODO: shouldn't error +# error: [invalid-type-form] +type VariadicUnion[*Ts] = Union[*Ts] + +def _(value: VariadicUnion[int, str]) -> None: + reveal_type(value) # revealed: object +``` + +### Using Callable + +```py +from typing import Callable + +type Alias[*Ts] = Callable[[*Ts], None] + +def test[*Ts](fn: Alias[int, *Ts]) -> tuple[*Ts]: + raise NotImplementedError + +def fn0(a: int) -> None: ... +def fn1(a: int, b: str) -> None: ... +def fn2(a: int, b: str, c: bytes) -> None: ... + +# TODO: Should reveal `tuple[()]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `Alias[*tuple[int, *tuple[Unknown, ...]]]`, found `def fn0(a: int) -> None`" +reveal_type(test(fn0)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[str]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `Alias[*tuple[int, *tuple[Unknown, ...]]]`, found `def fn1(a: int, b: str) -> None`" +reveal_type(test(fn1)) # revealed: tuple[Unknown, ...] +# TODO: Should reveal `tuple[str, bytes]` without an error. +# error: [invalid-argument-type] "Argument to function `test` is incorrect: Expected `Alias[*tuple[int, *tuple[Unknown, ...]]]`, found `def fn2(a: int, b: str, c: bytes) -> None`" +reveal_type(test(fn2)) # revealed: tuple[Unknown, ...] +``` + +### Indexing and iteration + +An unpacked type variable tuple represents the variable-length segment collectively. It is not the +type of each individual element in that segment. + +```py +def element_types[*Ts](values: tuple[*Ts]) -> None: + # TODO: should reveal `Union[*Ts]` representation + reveal_type(values[0]) # revealed: object + + for value in values: + # TODO: should reveal `Union[*Ts]` representation + reveal_type(value) # revealed: object + + reveal_type(values.__iter__()) # revealed: Iterator[object] + reveal_type(values * 2) # revealed: tuple[object, ...] + +def boundaries[*Ts](values: tuple[int, *Ts, str]) -> None: + reveal_type(values[0]) # revealed: int + reveal_type(values[-1]) # revealed: str + reveal_type(values[:]) # revealed: tuple[int, *Ts@boundaries, str] + +def materialize[*Ts](values: tuple[*Ts]) -> None: + reveal_type(list(values)) # revealed: list[object] + + runtime_elements: list[object] = list(values) + + # error: [invalid-assignment] "Object of type `list[object]` is not assignable to `list[tuple[object, ...]]`" + tuple_elements: list[tuple[object, ...]] = list(values) +``` + +### Slicing + +A slice preserves a symbolic pack only when it retains the complete pack in its original order. + +```py +def slices[*Ts](values: tuple[*Ts]) -> None: + reveal_type(values[:]) # revealed: tuple[*Ts@slices] + + reveal_type(values[1:]) # revealed: tuple[object, ...] + reveal_type(values[::-1]) # revealed: tuple[object, ...] + reveal_type(values[::2]) # revealed: tuple[object, ...] + +def reverse_boundaries[*Ts](values: tuple[int, *Ts, str]) -> None: + reveal_type(values[::-1]) # revealed: tuple[str, *tuple[object, ...], int] + reveal_type(values[:0:-1]) # revealed: tuple[str, *tuple[object, ...]] + +def trim_boundaries[*Ts](values: tuple[int, *Ts, str]) -> tuple[*Ts]: + reveal_type(values[1:-1]) # revealed: tuple[*Ts@trim_boundaries] + return values[1:-1] + +def reverse[*Ts](values: tuple[*Ts]) -> tuple[*Ts]: + # error: [invalid-return-type] "Return type does not match returned value: expected `tuple[*Ts@reverse]`, found `tuple[object, ...]`" + return values[::-1] + +def stride[*Ts](values: tuple[*Ts]) -> tuple[*Ts]: + # error: [invalid-return-type] "Return type does not match returned value: expected `tuple[*Ts@stride]`, found `tuple[object, ...]`" + return values[::2] +``` + +## Accessing Individual Types + +Operations that need to rearrange individual members of a type variable tuple can expose overloads +for each supported tuple length. + +```py +from typing import Any, overload + +class Row[*Cells]: + @overload + def get[A, B](self: "Row[A, B]") -> "Row[B, A]": ... + @overload + def get[A, B, C](self: "Row[A, B, C]") -> "Row[B, C, A]": ... + def get(self) -> "Row[*tuple[Any, ...]]": + raise NotImplementedError + +def f(pair: Row[int, str], triple: Row[int, str, bytes]) -> None: + reveal_type(pair.get()) # revealed: Row[str, int] + # TODO: Should reveal `Row[str, bytes, int]`. + reveal_type(triple.get()) # revealed: Row[Unknown, Unknown] +``` + +## Invalid Forms + +### Multiple Type Variable Tuples not allowed + +Only one type variable tuple can appear in a generic class or type alias type parameter list. Both +can be explicitly specialized, so multiple type variable tuples would make it ambiguous which pack +consumes each type argument. + +```py +# error: [invalid-type-form] "Generic class `Array` cannot have multiple `TypeVarTuple` type parameters" +class Array[*Ts1, *Ts2]: ... + +# error: [invalid-type-form] "Type alias `Alias` cannot have multiple `TypeVarTuple` type parameters" +type Alias[*Ts1, *Ts2] = tuple[*Ts1] | tuple[*Ts2] +``` + +### Must always be unpacked + +```py +def invalid[*Ts](x: Ts) -> None: ... # error: [invalid-type-form] +def invalid_args[*Ts](*args: Ts) -> None: ... # error: [invalid-type-form] + +class InvalidTupleElement[*Ts]: + # error: [invalid-type-form] "Bare TypeVarTuple `Ts` is not valid in this context in a type expression" + values: tuple[Ts] + +def valid[*Ts](x: tuple[*Ts]) -> tuple[*Ts]: + return x +``` + +### Invalid unpack operand + +Only tuple types and type variable tuples can be unpacked in a type expression. + +```py +# error: [invalid-type-form] "`*` can only unpack a tuple type or `TypeVarTuple`" +def invalid(*args: *int) -> None: + reveal_type(args) # revealed: tuple[Unknown, ...] + +class Pair[*Ts, U]: ... + +def invalid_generic( + # error: [invalid-type-form] "`*` can only unpack a tuple type or `TypeVarTuple`" + value: Pair[*int, str], +) -> None: + reveal_type(value) # revealed: Pair[*tuple[Unknown, ...], str] +``` + +### Only one variadic unpack + +```py +def f[*Ts]( + ok1: tuple[int, *Ts], + ok2: tuple[int, *Ts, str], + bad1: tuple[*Ts, *tuple[str, ...]], # error: [invalid-type-form] + bad2: tuple[*tuple[str, ...], *Ts], # error: [invalid-type-form] +) -> None: ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md index 1a5c9b57d0..814dc4af70 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/scoping.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/scoping.md @@ -289,6 +289,30 @@ def f[T](x: T, y: T) -> None: def bad[T](a: T, b: T) -> None: ... ``` +### Generic TypeVarTuple within generic function + +```py +def outer[*Ts](*args: *Ts) -> None: + def ok[*Us](*ok_args: *Us) -> None: ... + + # snapshot: shadowed-type-variable + def bad[*Ts](*bad_args: *Ts) -> None: ... +``` + +```snapshot +error[shadowed-type-variable]: Generic function `bad` uses TypeVarTuple `Ts` already bound by an enclosing scope + --> src/mdtest_snippet.py:5:9 + | +5 | def bad[*Ts](*bad_args: *Ts) -> None: ... + | ^^^ `Ts` used in function definition here + | + ::: src/mdtest_snippet.py:1:5 + | +1 | def outer[*Ts](*args: *Ts) -> None: + | ------------------------------ TypeVarTuple `Ts` is bound in this enclosing scope + | +``` + ### Generic method within generic class diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Default_type_paramet\342\200\246_(6bb09b09c131074).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Default_type_paramet\342\200\246_(6bb09b09c131074).snap" index f23aea169e..c77a605eb2 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Default_type_paramet\342\200\246_(6bb09b09c131074).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Default_type_paramet\342\200\246_(6bb09b09c131074).snap" @@ -36,11 +36,12 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/classes. 21 | class Corge[*Ts, T1 = int, T2 = str, **P = [int, str]]: ... 22 | 23 | # error: [invalid-type-variable-default] -24 | class Grault[*Us, *Ts = *tuple[int, str]]: ... -25 | -26 | # These are fine: -27 | class Ok1[T, *Ts]: ... -28 | class Ok3[*Ts]: ... +24 | # error: [invalid-type-form] "Generic class `Grault` cannot have multiple `TypeVarTuple` type parameters" +25 | class Grault[*Us, *Ts = *tuple[int, str]]: ... +26 | +27 | # These are fine: +28 | class Ok1[T, *Ts]: ... +29 | class Ok3[*Ts]: ... ``` # Diagnostics @@ -127,11 +128,24 @@ info: See https://typing.python.org/en/latest/spec/generics.html#defaults-follow ``` +``` +error[invalid-type-form]: Generic class `Grault` cannot have multiple `TypeVarTuple` type parameters + --> src/mdtest_snippet.py:25:14 + | +25 | class Grault[*Us, *Ts = *tuple[int, str]]: ... + | --- ^^^^^^^^^^^^^^^^^^^^^^ `Ts` is an additional TypeVarTuple + | | + | `Us` is the first TypeVarTuple + | +info: See https://typing.python.org/en/latest/spec/generics.html#multiple-type-variable-tuples-not-allowed + +``` + ``` error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter - --> src/mdtest_snippet.py:24:14 + --> src/mdtest_snippet.py:25:14 | -24 | class Grault[*Us, *Ts = *tuple[int, str]]: ... +25 | class Grault[*Us, *Ts = *tuple[int, str]]: ... | --- ^^^^^^^^^^^^^^^^^^^^^^ `Ts` has a default | | | `Us` is a TypeVarTuple diff --git a/crates/ty_python_semantic/resources/mdtest/type_form.md b/crates/ty_python_semantic/resources/mdtest/type_form.md index 1a41ce6290..36f2e98f55 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_form.md +++ b/crates/ty_python_semantic/resources/mdtest/type_form.md @@ -340,7 +340,10 @@ bad_literal_var: TypeForm = Literal[var] # error: [invalid-type-form] bad_literal_f_string: TypeForm = Literal[f""] # error: [invalid-type-form] bad_qualifier: TypeForm = ClassVar[int] # error: [invalid-type-form] bad_final: TypeForm = Final[int] # error: [invalid-type-form] -bad_unpack: TypeForm = Unpack[Ts] # error: [invalid-type-form] +# error: [invalid-type-form] "`Unpack` is not allowed in type expressions" +bad_unpack: TypeForm = Unpack[Ts] +# error: [invalid-type-form] "`Unpack` is not allowed in type expressions" +bad_concrete_unpack: TypeForm = Unpack[tuple[int, ...]] bad_optional: TypeForm = Optional # error: [invalid-type-form] bad_quoted_operator: TypeForm = "int + str" # error: [invalid-type-form] ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e72f4ada55..9ddd50e094 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1302,10 +1302,7 @@ impl<'db> Type<'db> { | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar | DynamicType::AmbiguousOverload => false, - DynamicType::Todo(_) - | DynamicType::TodoStarredExpression - | DynamicType::TodoUnpack - | DynamicType::TodoTypeVarTuple => true, + DynamicType::Todo(_) => true, }) } @@ -2039,11 +2036,8 @@ impl<'db> Type<'db> { DynamicType::Unknown | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar - | DynamicType::TodoUnpack - | DynamicType::TodoTypeVarTuple | DynamicType::Todo(_) | DynamicType::InvalidConcatenateUnknown - | DynamicType::TodoStarredExpression | DynamicType::AmbiguousOverload => false, }, } @@ -6095,6 +6089,16 @@ impl<'db> Type<'db> { fallback_type: Type::unknown(), }); } + if !inference_flags.contains(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT) + && typevar.is_typevartuple(db) + { + return Err(InvalidTypeExpressionError { + invalid_expressions: smallvec_inline![ + InvalidTypeExpression::InvalidBareTypeVarTuple(*typevar) + ], + fallback_type: Type::unknown(), + }); + } let index = semantic_index(db, scope_id.file(db)); Ok(bind_typevar( db, @@ -6226,10 +6230,16 @@ impl<'db> Type<'db> { Type::NominalInstance(instance) => match instance.known_class(db) { Some(KnownClass::NoneType) => Ok(Type::none(db)), + // TODO: Emit an invalid-type-form diagnostic and recover to `Unknown` for + // unrecognized `TypeVar` and `TypeVarTuple` instances. Some(KnownClass::TypeVar) => Ok(todo_type!( - "Support for `typing.TypeVar` instances in type expressions" + "unrecognized `typing.TypeVar` instances should be invalid type expressions" )), - Some(KnownClass::TypeVarTuple) => Ok(Type::Dynamic(DynamicType::TodoTypeVarTuple)), + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) => { + Ok(todo_type!( + "unrecognized `typing.TypeVarTuple` instances should be invalid type expressions" + )) + } _ => Err(InvalidTypeExpressionError { invalid_expressions: smallvec_inline![InvalidTypeExpression::InvalidType( *self, scope_id @@ -6840,6 +6850,14 @@ impl<'db> Type<'db> { { Some(*bound_typevar) } + TypeVarKind::LegacyTypeVarTuple + if binding_context.is_none_or(|binding_context| { + bound_typevar.binding_context(db) + == BindingContext::Definition(binding_context) + }) => + { + Some(*bound_typevar) + } TypeVarKind::LegacyParamSpec if binding_context.is_none_or(|binding_context| { bound_typevar.binding_context(db) @@ -7285,12 +7303,9 @@ impl<'db> Type<'db> { | DynamicType::AmbiguousOverload, ) => Type::SpecialForm(SpecialFormType::Unknown).definition(db), Self::Divergent(_) => Type::SpecialForm(SpecialFormType::Divergent).definition(db), - Self::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoUnpack - | DynamicType::TodoStarredExpression - | DynamicType::TodoTypeVarTuple, - ) => Type::SpecialForm(SpecialFormType::Todo).definition(db), + Self::Dynamic(DynamicType::Todo(_)) => { + Type::SpecialForm(SpecialFormType::Todo).definition(db) + } Self::AlwaysTruthy => Type::SpecialForm(SpecialFormType::AlwaysTruthy).definition(db), Self::AlwaysFalsy => Type::SpecialForm(SpecialFormType::AlwaysFalsy).definition(db), @@ -8010,12 +8025,6 @@ pub enum DynamicType<'db> { /// /// This variant should be created with the `todo_type!` macro. Todo(TodoType), - /// A special Todo-variant for `Unpack[Ts]`, so that we can treat it specially in `Generic[Unpack[Ts]]` - TodoUnpack, - /// A special Todo-variant for `*Ts`, so that we can treat it specially in `Generic[*Ts]` - TodoStarredExpression, - /// A special Todo-variant for `TypeVarTuple` instances encountered in type expressions - TodoTypeVarTuple, } impl DynamicType<'_> { @@ -8024,7 +8033,7 @@ impl DynamicType<'_> { } pub(crate) fn is_todo(&self) -> bool { - matches!(self, Self::Todo(_) | Self::TodoUnpack) + matches!(self, Self::Todo(_)) } } @@ -8040,9 +8049,6 @@ impl std::fmt::Display for DynamicType<'_> { // `DynamicType::Todo`'s display should be explicit that is not a valid display of // any other type DynamicType::Todo(todo) => write!(f, "@Todo{todo}"), - DynamicType::TodoUnpack => f.write_str("@Todo(typing.Unpack)"), - DynamicType::TodoStarredExpression => f.write_str("@Todo(StarredExpression)"), - DynamicType::TodoTypeVarTuple => f.write_str("@Todo(TypeVarTuple)"), } } } @@ -8259,6 +8265,7 @@ enum InvalidTypeExpression<'db> { /// Some types are always invalid in type expressions InvalidType(Type<'db>, ScopeId<'db>), InvalidBareParamSpec(TypeVarInstance<'db>), + InvalidBareTypeVarTuple(TypeVarInstance<'db>), } impl<'db> InvalidTypeExpression<'db> { @@ -8380,6 +8387,11 @@ impl<'db> InvalidTypeExpression<'db> { "Bare ParamSpec `{}` is not valid in this context in a {location}", paramspec.name(self.db) ), + InvalidTypeExpression::InvalidBareTypeVarTuple(typevartuple) => write!( + f, + "Bare TypeVarTuple `{}` is not valid in this context in a {location}", + typevartuple.name(self.db) + ), InvalidTypeExpression::Concatenate => write!( f, "`typing.Concatenate` is not allowed in this context in a {location}", @@ -8458,6 +8470,8 @@ impl<'db> InvalidTypeExpression<'db> { diagnostic.info(" - as the default type for another ParamSpec"); diagnostic.info(" - as part of a type parameter list when defining a generic class"); diagnostic.info(" - or as part of an argument list when specializing a generic class"); + } else if matches!(self, InvalidTypeExpression::InvalidBareTypeVarTuple(_)) { + diagnostic.info("A TypeVarTuple must be unpacked with `*` or `Unpack[]`."); } else if matches!(self, InvalidTypeExpression::Concatenate) { diagnostic.info("`typing.Concatenate` is only valid:"); diagnostic.info(" - as the first argument to `Callable`"); diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index a738bf326d..deae06b439 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -56,11 +56,12 @@ use crate::types::signatures::{ CallableSignature, Parameter, ParameterDisplayName, ParameterKind, Parameters, ParametersKind, PartialApplication, PartialSignatureApplication, }; -use crate::types::tuple::{TupleLength, TupleSpec, TupleType}; +use crate::types::tuple::{TupleLength, TupleSpec, TupleType, VariableSegment}; use crate::types::typed_dict::{TypedDictOpenness, extract_unpacked_typed_dict_from_value_type}; use crate::types::typevar::{BoundTypeVarIdentity, TypeVarNonceGenerator}; use crate::types::visitor::{ - TypeCollector, TypeKind, TypeVisitor, walk_non_atomic_type, walk_type_with_recursion_guard, + TypeCollector, TypeKind, TypeVisitor, any_over_type, walk_non_atomic_type, + walk_type_with_recursion_guard, }; use crate::types::{ BindingContext, BoundMethodType, BoundTypeVarInstance, CallableType, CallableTypes, @@ -4546,7 +4547,11 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { length: TupleLength, variable_element: Option>, }, - Other(Cow<'db, TupleSpec<'db>>), + Other { + argument_types: Vec>, + length: TupleLength, + variable_element: Option>, + }, None, } @@ -4589,14 +4594,14 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { let any_variable = tuple_specs.iter().any(|s| s.len().is_variable()); let max_elements = tuple_specs .iter() - .map(|s| s.all_elements().len()) + .map(|s| s.iter_element_types(db).count()) .max() .unwrap_or(0); let variable_element = { let var_types: Vec<_> = tuple_specs .iter() - .filter_map(|s| s.variable_element().copied()) + .filter_map(|s| s.variable_element_type(db)) .collect(); if var_types.is_empty() { None @@ -4631,7 +4636,14 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { variable_element, } } - _ => VariadicArgumentType::Other(argument_type.iterate(db)), + _ => { + let tuple = argument_type.iterate(db); + VariadicArgumentType::Other { + argument_types: tuple.iter_element_types(db).collect(), + length: tuple.len(), + variable_element: tuple.variable_element_type(db), + } + } }, }, None => VariadicArgumentType::None, @@ -4646,11 +4658,11 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { length, variable_element, } => (argument_types.as_slice(), *length, *variable_element), - VariadicArgumentType::Other(tuple) => ( - tuple.all_elements(), - tuple.len(), - tuple.variable_element().copied(), - ), + VariadicArgumentType::Other { + argument_types, + length, + variable_element, + } => (argument_types.as_slice(), *length, *variable_element), VariadicArgumentType::None => ([].as_slice(), TupleLength::unknown(), None), }; @@ -5320,11 +5332,29 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { { for matched_parameter in self.argument_matches[argument_index].iter() { let parameter_index = matched_parameter.index; + let parameter = ¶meters[parameter_index]; + let declared_type = parameter.annotated_type(); + // TODO: Infer a `TypeVarTuple` from all matched positional arguments as a single + // tuple. Until then, skip per-argument inference. + if parameter.has_starred_annotation() + && (matches!( + declared_type, + Type::TypeVar(typevar) if typevar.is_typevartuple(self.db) + ) || matches!( + declared_type.exact_tuple_instance_spec(self.db).as_deref(), + Some(TupleSpec::Variable(variable)) + if matches!( + variable.variable(), + VariableSegment::TypeVarTuple(_) + ) + )) + { + continue; + } if self.is_gradual_variadic_parameter(parameter_index) { continue; } - let declared_type = parameters[parameter_index].annotated_type(); let argument_type = argument_types.get_for_declared_type(declared_type); let specialization_result = builder.infer( declared_type, @@ -5388,6 +5418,11 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { && argument_type .when_assignable_to(self.db, expected_ty, constraints, self.inferable_typevars) .is_never_satisfied(self.db) + && !self.should_defer_typevartuple_callable_check( + parameter.annotated_type(), + expected_ty, + argument_type, + ) { let positional = matches!(argument, Argument::Positional | Argument::Synthetic) && !parameter.is_variadic(); @@ -5441,6 +5476,66 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { && (parameter.is_variadic() || parameter.is_keyword_variadic()) } + // TODO: Remove this workaround once call binding can infer a `TypeVarTuple` from `*args` and + // callable inference preserves correlations across overloads. + fn should_defer_typevartuple_callable_check( + &self, + declared_type: Type<'db>, + expected_type: Type<'db>, + argument_type: Type<'db>, + ) -> bool { + let Some(declared_callables) = declared_type.try_upcast_to_callable(self.db) else { + return false; + }; + let parameters_contain_typevartuple = declared_callables.iter().any(|callable| { + callable.signatures(self.db).iter().any(|signature| { + signature.parameters().iter().any(|parameter| { + any_over_type(self.db, parameter.annotated_type(), false, |ty| { + matches!( + ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(self.db) + ) + }) + }) + }) + }); + if !parameters_contain_typevartuple { + return false; + } + + let Some(argument_callables) = argument_type.try_upcast_to_callable(self.db) else { + return false; + }; + if argument_callables + .iter() + .any(|callable| callable.signatures(self.db).overloads.len() > 1) + { + return true; + } + + let argument_is_generic = argument_callables.iter().any(|callable| { + callable + .signatures(self.db) + .iter() + .any(|signature| signature.generic_context.is_some()) + }); + argument_is_generic + && expected_type + .try_upcast_to_callable(self.db) + .is_some_and(|callables| { + callables.iter().any(|callable| { + callable.signatures(self.db).iter().any(|signature| { + signature + .parameters() + .variadic() + .is_some_and(|(_, parameter)| { + parameter.annotated_type().is_dynamic() + }) + }) + }) + }) + } + fn check_argument_types(&mut self, constraints: &ConstraintSetBuilder<'db>) { let paramspec = self.signature.parameters().as_paramspec_with_prefix(); let paramspec_component_start = paramspec.and_then(|(prefix, paramspec)| { diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 3358e9dc42..6f117e1d02 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1681,6 +1681,8 @@ impl<'db> ClassType<'db> { }; let fallback_member_lookup = || { + let specialization = specialization + .map(|specialization| specialization.tuple_runtime_element_specialization(db)); class_literal .own_class_member(db, inherited_generic_context, specialization, name) .map_type(|ty| ty.apply_optional_specialization(db, specialization)) @@ -1760,13 +1762,15 @@ impl<'db> ClassType<'db> { ) { let overload_return = UnionType::from_elements( db, - std::iter::once(variable_length_tuple.variable()) - .chain( - variable_length_tuple - .iter_prefix_elements() - .rev() - .take(one_based_index), - ), + std::iter::once( + variable_length_tuple.variable().element_type(db), + ) + .chain( + variable_length_tuple + .iter_prefix_elements() + .rev() + .take(one_based_index), + ), ); element_type_to_indices .entry(overload_return) @@ -1794,12 +1798,14 @@ impl<'db> ClassType<'db> { ) { let overload_return = UnionType::from_elements( db, - std::iter::once(variable_length_tuple.variable()) - .chain( - variable_length_tuple - .iter_suffix_elements() - .take(index + 1), - ), + std::iter::once( + variable_length_tuple.variable().element_type(db), + ) + .chain( + variable_length_tuple + .iter_suffix_elements() + .take(index + 1), + ), ); element_type_to_indices .entry(overload_return) @@ -1810,8 +1816,7 @@ impl<'db> ClassType<'db> { } } - let all_elements_unioned = - UnionType::from_elements(db, tuple.all_elements()); + let all_elements_unioned = tuple.homogeneous_element_type(db); let mut overload_signatures = Vec::with_capacity(element_type_to_indices.len().saturating_add(2)); @@ -1899,15 +1904,16 @@ impl<'db> ClassType<'db> { if tuple_len.minimum() == 0 && tuple_len.maximum().is_none() { // If the tuple has no length restrictions, // any iterable is allowed as long as the iterable has the correct element type. - let mut tuple_elements = tuple.iter_all_elements(); - iterable_parameter = iterable_parameter.with_annotated_type( - KnownClass::Iterable - .to_specialized_instance(db, &[tuple_elements.next().unwrap()]), - ); assert_eq!( - tuple_elements.next(), - None, - "Tuple specialization should not have more than one element when it has no length restriction" + tuple.iter_element_types(db).count(), + 1, + "Tuple specialization should have exactly one element when it has no length restriction" + ); + iterable_parameter = iterable_parameter.with_annotated_type( + KnownClass::Iterable.to_specialized_instance( + db, + &[tuple.homogeneous_element_type(db)], + ), ); } else { // But if the tuple is of a fixed length, or has a minimum length, we require a tuple rather diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 684b680b30..0c4cc55dc1 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -110,6 +110,7 @@ pub enum KnownClass { ParamSpecKwargs, ProtocolMeta, TypeVarTuple, + ExtensionsTypeVarTuple, // must be distinct from typing.TypeVarTuple, backports new features TypeAliasType, NoDefaultType, NewType, @@ -197,6 +198,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::Super | Self::WrapperDescriptorType @@ -362,6 +364,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType @@ -472,6 +475,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType @@ -583,6 +587,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType @@ -700,6 +705,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::TypeAliasType | Self::NoDefaultType @@ -822,6 +828,7 @@ impl KnownClass { | KnownClass::ParamSpecKwargs | KnownClass::ProtocolMeta | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType @@ -915,6 +922,7 @@ impl KnownClass { Self::ParamSpecArgs => "ParamSpecArgs", Self::ParamSpecKwargs => "ParamSpecKwargs", Self::TypeVarTuple => "TypeVarTuple", + Self::ExtensionsTypeVarTuple => "TypeVarTuple", Self::Sentinel => "sentinel", Self::TypeAliasType => "TypeAliasType", Self::NoDefaultType => "_NoDefaultType", @@ -1325,13 +1333,20 @@ impl KnownClass { | Self::SupportsIndex => KnownModule::Typing, Self::TypeAliasType | Self::ExtensionsTypeVar - | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::ExtensionsParamSpec | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::Deprecated | Self::ExtensionTypedDictFallback | Self::NewType => KnownModule::TypingExtensions, + Self::TypeVarTuple => { + if Program::get(db).python_version(db) >= PythonVersion::PY311 { + KnownModule::Typing + } else { + KnownModule::TypingExtensions + } + } Self::Sentinel => { if Program::get(db).python_version(db) >= PythonVersion::PY315 { KnownModule::Builtins @@ -1443,6 +1458,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::Enum | Self::EnumProperty @@ -1558,6 +1574,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::Enum | Self::EnumProperty @@ -1666,7 +1683,9 @@ impl KnownClass { "ParamSpec" => &[Self::ParamSpec, Self::ExtensionsParamSpec], "ParamSpecArgs" => &[Self::ParamSpecArgs], "ParamSpecKwargs" => &[Self::ParamSpecKwargs], - "TypeVarTuple" => &[Self::TypeVarTuple], + // On Python 3.10, both candidates resolve to `typing_extensions`. Prefer the + // backport-aware variant so that we recognize features such as `default`. + "TypeVarTuple" => &[Self::ExtensionsTypeVarTuple, Self::TypeVarTuple], "sentinel" => &[Self::Sentinel], "ChainMap" => &[Self::ChainMap], "Counter" => &[Self::Counter], @@ -1796,6 +1815,8 @@ impl KnownClass { | Self::ExtensionsTypeVar | Self::ParamSpec | Self::ExtensionsParamSpec + | Self::TypeVarTuple + | Self::ExtensionsTypeVarTuple | Self::Sentinel | Self::NamedTupleLike | Self::ConstraintSet @@ -1824,7 +1845,6 @@ impl KnownClass { | Self::SupportsIndex | Self::ParamSpecArgs | Self::ParamSpecKwargs - | Self::TypeVarTuple | Self::Iterable | Self::Iterator | Self::AsyncIterator @@ -2145,6 +2165,7 @@ mod tests { KnownClass::BaseExceptionGroup | KnownClass::ExceptionGroup => { PythonVersion::PY311 } + KnownClass::TypeVarTuple => PythonVersion::PY311, KnownClass::GenericAlias => PythonVersion::PY39, KnownClass::EnumProperty | KnownClass::Member diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 7c0d1cd1a4..65e64241cf 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -70,12 +70,7 @@ impl<'db> ClassBase<'db> { | DynamicType::AmbiguousOverload, ) => "Unknown", ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar", - ClassBase::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoUnpack - | DynamicType::TodoStarredExpression - | DynamicType::TodoTypeVarTuple, - ) => "@Todo", + ClassBase::Dynamic(DynamicType::Todo(_)) => "@Todo", ClassBase::Divergent(_) => "Divergent", ClassBase::Protocol => "Protocol", ClassBase::Generic => "Generic", diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 14c2836e16..ce15354479 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -3684,6 +3684,7 @@ pub(crate) fn report_shadowed_type_variable<'db>( | TypeVarKind::TypingSelf | TypeVarKind::Pep613Alias => "type variable", TypeVarKind::LegacyParamSpec | TypeVarKind::Pep695ParamSpec => "ParamSpec", + TypeVarKind::LegacyTypeVarTuple | TypeVarKind::Pep695TypeVarTuple => "TypeVarTuple", }; let mut diagnostic = builder.into_diagnostic(format_args!( "Generic {kind} `{name}` uses {typevar_kind} `{typevar_name}` already bound by an enclosing scope", @@ -3702,15 +3703,16 @@ pub(crate) fn report_shadowed_type_variable<'db>( Type::FunctionLiteral(function) => function.spans(db).signature, _ => return, }; - if other_typevar.is_paramspec(db) { - diagnostic.annotate(Annotation::secondary(span).message(format_args!( - "ParamSpec `{typevar_name}` is bound in this enclosing scope" - ))); + let other_typevar_kind = if other_typevar.is_paramspec(db) { + "ParamSpec" + } else if other_typevar.is_typevartuple(db) { + "TypeVarTuple" } else { - diagnostic.annotate(Annotation::secondary(span).message(format_args!( - "Type variable `{typevar_name}` is bound in this enclosing scope" - ))); - } + "Type variable" + }; + diagnostic.annotate(Annotation::secondary(span).message(format_args!( + "{other_typevar_kind} `{typevar_name}` is bound in this enclosing scope" + ))); } // I tried refactoring this function to placate Clippy, diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index c49ec37434..60af0f7367 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -27,7 +27,7 @@ use crate::types::generics::{GenericContext, Specialization}; use crate::types::signatures::{ CallableSignature, Parameter, Parameters, ParametersKind, Signature, }; -use crate::types::tuple::TupleSpec; +use crate::types::tuple::{TupleSpec, VariableSegment}; use crate::types::typevar::BoundTypeVarIdentity; use crate::types::visitor::TypeVisitor; use crate::types::{ @@ -1535,20 +1535,33 @@ impl<'db> FmtDetailed<'db> for DisplayTuple<'_, 'db> { .fmt_detailed(f)?; f.write_str(", ")?; } - if !tuple.prefix_elements().is_empty() || !tuple.suffix_elements().is_empty() { - f.write_char('*')?; - // Might as well link the type again here too - f.with_type(KnownClass::Tuple.to_class_literal(self.db)) - .write_str("tuple")?; - f.write_char('[')?; - } - tuple - .variable() - .display_with(self.db, self.settings.singleline()) - .fmt_detailed(f)?; - f.write_str(", ...")?; - if !tuple.prefix_elements().is_empty() || !tuple.suffix_elements().is_empty() { - f.write_str("]")?; + match tuple.variable() { + VariableSegment::TypeVarTuple(typevar) => { + f.write_char('*')?; + Type::TypeVar(typevar) + .display_with(self.db, self.settings.singleline()) + .fmt_detailed(f)?; + } + VariableSegment::Homogeneous(variable) => { + if !tuple.prefix_elements().is_empty() + || !tuple.suffix_elements().is_empty() + { + f.write_char('*')?; + // Might as well link the type again here too + f.with_type(KnownClass::Tuple.to_class_literal(self.db)) + .write_str("tuple")?; + f.write_char('[')?; + } + variable + .display_with(self.db, self.settings.singleline()) + .fmt_detailed(f)?; + f.write_str(", ...")?; + if !tuple.prefix_elements().is_empty() + || !tuple.suffix_elements().is_empty() + { + f.write_str("]")?; + } + } } if !tuple.suffix_elements().is_empty() { f.write_str(", ")?; @@ -1874,6 +1887,8 @@ impl<'db> DisplayGenericContext<'_, 'db> { let typevar = bound_typevar.typevar(self.db); if typevar.is_paramspec(self.db) { f.write_str("**")?; + } else if typevar.is_typevartuple(self.db) { + f.write_char('*')?; } write!( f.with_type(Type::TypeVar(bound_typevar)), @@ -1959,13 +1974,64 @@ struct DisplaySpecialization<'db> { impl<'db> DisplaySpecialization<'db> { fn fmt_normal(&self, f: &mut TypeWriter<'_, '_, 'db>) -> fmt::Result { f.write_char('[')?; + let variables = self + .specialization + .generic_context(self.db) + .variables(self.db) + .collect::>(); let types = self.specialization.types(self.db); - for (idx, ty) in types.iter().enumerate() { - if idx > 0 { + let mut wrote_any = false; + for (typevar, ty) in variables.iter().zip(types) { + if typevar.is_typevartuple(self.db) { + let Some(tuple) = ty.exact_tuple_instance_spec(self.db) else { + if wrote_any { + f.write_str(", ")?; + } + ty.display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + wrote_any = true; + continue; + }; + match tuple.as_ref() { + TupleSpec::Fixed(fixed) if fixed.elements_slice().is_empty() => { + if variables.len() == 1 { + if wrote_any { + f.write_str(", ")?; + } + f.write_str("()")?; + wrote_any = true; + } + } + TupleSpec::Fixed(fixed) => { + for element in fixed.elements_slice() { + if wrote_any { + f.write_str(", ")?; + } + element + .display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + wrote_any = true; + } + } + TupleSpec::Variable(_) => { + if wrote_any { + f.write_str(", ")?; + } + f.write_char('*')?; + ty.display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + wrote_any = true; + } + } + continue; + } + + if wrote_any { f.write_str(", ")?; } ty.display_with(self.db, self.settings.clone()) .fmt_detailed(f)?; + wrote_any = true; } if self.tuple_specialization.is_yes() { f.write_str(", ...")?; @@ -2286,11 +2352,16 @@ impl<'db> FmtDetailed<'db> for DisplayParameters<'_, 'db> { ) -> fmt::Result { let mut star_added = false; let mut needs_slash = false; + let mut after_synthetic_unpack = false; let mut first = true; for parameter in parameters { + let is_synthetic_unpack = parameter.definition().is_none() + && parameter.is_variadic() + && parameter.has_starred_annotation(); + // Handle special separators - if parameter.is_positional_only() { + if parameter.is_positional_only() && !after_synthetic_unpack { needs_slash = true; } else if needs_slash { if !first { @@ -2323,6 +2394,7 @@ impl<'db> FmtDetailed<'db> for DisplayParameters<'_, 'db> { .display_with(display.db, display.settings.singleline()) .fmt_detailed(&mut f.with_detail(TypeDetail::Parameter(param_name)))?; + after_synthetic_unpack |= is_synthetic_unpack; first = false; } @@ -2435,6 +2507,18 @@ struct DisplayParameter<'a, 'db> { impl<'db> FmtDetailed<'db> for DisplayParameter<'_, 'db> { fn fmt_detailed(&self, f: &mut TypeWriter<'_, '_, 'db>) -> fmt::Result { + if self.param.definition().is_none() + && self.param.is_variadic() + && self.param.has_starred_annotation() + { + f.write_str("*")?; + self.param + .annotated_type() + .display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + return Ok(()); + } + if let Some(name) = self.param.display_name() { write!(f, "{name}")?; if self.param.should_annotation_be_displayed() { @@ -3170,6 +3254,8 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'db> { KnownInstanceType::TypeVar(typevar_instance) => { if typevar_instance.kind(self.db).is_paramspec() { f.with_type(ty).write_str("ParamSpec") + } else if typevar_instance.kind(self.db).is_typevartuple() { + f.with_type(ty).write_str("TypeVarTuple") } else { f.with_type(ty).write_str("TypeVar") } diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index aded2fc3fe..e87557f4ab 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1632,7 +1632,7 @@ fn check_classinfo_in_isinstance<'db>( Some(ast::Expr::Tuple(tuple_expr)) => Some(&tuple_expr.elts), _ => None, }; - for (index, element) in tuple_spec.iter_all_elements().enumerate() { + for (index, element) in tuple_spec.iter_element_types(db).enumerate() { let element_expr = element_exprs.and_then(|elts| elts.get(index)); check_classinfo_in_isinstance( db, diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index ba8915b993..b3a1cd59d9 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -20,7 +20,9 @@ use crate::types::relation::{ TypeRelationChecker, TypeVarEvaluation, }; use crate::types::signatures::{CallableSignature, Parameters, SignatureRelationVisitor}; -use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; +use crate::types::tuple::{ + TupleSpec, TupleSpecBuilder, TupleType, VariableSegment, walk_tuple_type, +}; use crate::types::type_alias::{walk_manual_pep_695_type_alias, walk_pep_695_type_alias}; use crate::types::typevar::{ BoundTypeVarIdentity, TypeVarIdentity, TypeVarInstance, walk_type_var_bounds, @@ -570,8 +572,16 @@ impl<'db> GenericContext<'db> { }; Some(typevar.with_binding_context(db, binding_context)) } - // TODO: Support this! - ast::TypeParam::TypeVarTuple(_) => None, + ast::TypeParam::TypeVarTuple(node) => { + let definition = index.expect_single_definition(node); + let declared = inferred_declaration(db, definition).declared()?; + let Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) = + declared.inner_type() + else { + return None; + }; + Some(typevar.with_binding_context(db, binding_context)) + } } } @@ -883,12 +893,20 @@ impl<'db> GenericContext<'db> { } pub(crate) fn unknown_specialization(self, db: &'db dyn Db) -> Specialization<'db> { - match self.len(db) { - 0 => self.specialize(db, &[]), - 1 => self.specialize(db, &[Type::unknown(); 1]), - 2 => self.specialize(db, &[Type::unknown(); 2]), - len => self.specialize(db, vec![Type::unknown(); len]), - } + self.specialize( + db, + self.variables(db) + .map(|typevar| match typevar.kind(db) { + TypeVarKind::LegacyTypeVarTuple | TypeVarKind::Pep695TypeVarTuple => { + Type::homogeneous_tuple(db, Type::unknown()) + } + TypeVarKind::LegacyParamSpec | TypeVarKind::Pep695ParamSpec => { + Type::paramspec_value_callable(db, Parameters::unknown()) + } + _ => Type::unknown(), + }) + .collect::>(), + ) } pub(crate) fn is_subset_of(self, db: &'db dyn Db, other: GenericContext<'db>) -> bool { @@ -1026,11 +1044,15 @@ impl<'db> GenericContext<'db> { // this, we repeatedly apply the specialization to itself, until we reach a fixed point. let mut expanded = Vec::with_capacity(types.len()); for typevar in variables.clone() { - if typevar.is_paramspec(db) { - expanded.push(Type::paramspec_value_callable(db, Parameters::unknown())); - } else { - expanded.push(Type::unknown()); - } + expanded.push(match typevar.kind(db) { + TypeVarKind::LegacyTypeVarTuple | TypeVarKind::Pep695TypeVarTuple => { + Type::homogeneous_tuple(db, Type::unknown()) + } + TypeVarKind::LegacyParamSpec | TypeVarKind::Pep695ParamSpec => { + Type::paramspec_value_callable(db, Parameters::unknown()) + } + _ => Type::unknown(), + }); } for (idx, (ty, typevar)) in types.zip(variables).enumerate() { @@ -1214,6 +1236,37 @@ impl<'db> Specialization<'db> { self.tuple_inner(db).map(|tuple_type| tuple_type.tuple(db)) } + /// Returns the specialization to use when the builtin tuple type parameter represents an + /// individual runtime element. + /// + /// The builtin tuple class's type parameter describes the type produced by runtime element + /// access. An unpacked `TypeVarTuple` describes those elements collectively, so project it to + /// `object` for tuple members and base classes while retaining the symbolic pack in the tuple's + /// own specialization. + pub(crate) fn tuple_runtime_element_specialization(self, db: &'db dyn Db) -> Self { + let Some(tuple) = self.tuple_inner(db) else { + return self; + }; + // Ordinary tuple specializations already use their runtime element type as the tuple + // class's generic argument. Rebuilding them would add allocation and interning work to + // every tuple member and MRO lookup, both of which are hot paths in tuple-heavy programs. + if !matches!( + tuple.tuple(db), + TupleSpec::Variable(tuple) + if matches!(tuple.variable(), VariableSegment::TypeVarTuple(_)) + ) { + return self; + } + + Self::new( + db, + self.generic_context(db), + [tuple.tuple(db).homogeneous_element_type(db)].as_slice(), + self.materialization_kind(db), + None, + ) + } + /// Returns the type that a typevar is mapped to, or None if the typevar isn't part of this /// mapping. pub(crate) fn get( @@ -2102,10 +2155,11 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { generic_context: GenericContext<'db>, choose: &mut impl FnMut(BoundTypeVarInstance<'db>, Option<&PathBound<'db>>) -> Option>, ) -> FxHashMap, Type<'db>> { + // TODO: Move `ParamSpec` and `TypeVarTuple` handling to the new constraint solver. if generic_context .variables_inner(self.db) .values() - .any(|typevar| typevar.is_paramspec(self.db)) + .any(|typevar| typevar.is_paramspec(self.db) || typevar.is_typevartuple(self.db)) { return self.solve_hash_map_with(generic_context, choose); } @@ -2360,17 +2414,45 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { let identity = bound_typevar.identity(self.db); match self.types.entry(identity) { Entry::Occupied(mut entry) => { - // TODO: The spec says that when a ParamSpec is used multiple times in a signature, - // the type checker can solve it to a common behavioral supertype. We don't - // implement that yet so in case there are multiple ParamSpecs, use the - // specialization from the first occurrence. - // https://github.com/astral-sh/ty/issues/1778 - // https://github.com/astral-sh/ruff/pull/21445#discussion_r2591510145 - if bound_typevar.is_paramspec(self.db) { - return; + match bound_typevar.kind(self.db) { + TypeVarKind::LegacyParamSpec | TypeVarKind::Pep695ParamSpec => { + // TODO: The spec says that when a ParamSpec is used multiple times in a signature, + // the type checker can solve it to a common behavioral supertype. We don't + // implement that yet so in case there are multiple ParamSpecs, use the + // specialization from the first occurrence. + // https://github.com/astral-sh/ty/issues/1778 + // https://github.com/astral-sh/ruff/pull/21445#discussion_r2591510145 + } + TypeVarKind::LegacyTypeVarTuple | TypeVarKind::Pep695TypeVarTuple => { + // Repeated uses of a `TypeVarTuple` must have the same length, but the typing + // spec leaves the exact inference behavior unspecified. Merge equal-length + // candidates element-wise using unions. + // https://typing.python.org/en/latest/spec/generics.html#type-variable-tuple-equality + let accumulator = entry.get_mut(); + let existing = accumulator.get_or_build(self.db); + if existing == ty { + return; + } + let Some(existing_tuple) = existing.exact_tuple_instance_spec(self.db) + else { + return; + }; + let Some(new_tuple) = ty.exact_tuple_instance_spec(self.db) else { + return; + }; + if existing_tuple.len() != new_tuple.len() { + return; + } + let unioned = TupleSpecBuilder::from(existing_tuple.as_ref()) + .union(self.db, &new_tuple) + .build(); + *accumulator = + UnionAccumulator::new(Type::tuple(TupleType::new(self.db, &unioned))); + } + _ => { + entry.get_mut().add(self.db, ty); + } } - - entry.get_mut().add(self.db, ty); } Entry::Vacant(entry) => { entry.insert(UnionAccumulator::new(ty)); @@ -2459,7 +2541,11 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { }; for solution in solutions { for binding in solution { - self.insert_hash_map_type_mapping(binding.bound_typevar, binding.solution); + let solution = self.remove_inferable_typevar_artifacts_from_solution( + binding.bound_typevar, + binding.solution, + ); + self.insert_hash_map_type_mapping(binding.bound_typevar, solution); } } Ok(()) @@ -3097,6 +3183,69 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { formal.tuple_instance_spec(self.db), actual_nominal.tuple_spec(self.db), ) { + if let TupleSpec::Variable(formal_variable) = formal_tuple.as_ref() + && let VariableSegment::TypeVarTuple(typevartuple) = + formal_variable.variable() + { + let formal_prefix_len = formal_variable.prefix_elements().len(); + let formal_suffix_len = formal_variable.suffix_elements().len(); + let (actual_prefix, packed, actual_suffix) = match actual_tuple.as_ref() { + TupleSpec::Fixed(actual) => { + let Some(middle_end) = actual.len().checked_sub(formal_suffix_len) + else { + return Ok(()); + }; + if middle_end < formal_prefix_len { + return Ok(()); + } + + let elements = actual.elements_slice(); + ( + &elements[..formal_prefix_len], + Type::heterogeneous_tuple( + self.db, + elements[formal_prefix_len..middle_end].iter().copied(), + ), + &elements[middle_end..], + ) + } + TupleSpec::Variable(actual) => { + let actual_prefix_elements = actual.prefix_elements(); + let actual_suffix_elements = actual.suffix_elements(); + if actual_prefix_elements.len() < formal_prefix_len + || actual_suffix_elements.len() < formal_suffix_len + { + return Ok(()); + } + + let suffix_start = actual_suffix_elements.len() - formal_suffix_len; + ( + &actual_prefix_elements[..formal_prefix_len], + Type::tuple(TupleType::mixed_with_segment( + self.db, + actual_prefix_elements[formal_prefix_len..].iter().copied(), + actual.variable(), + actual_suffix_elements[..suffix_start].iter().copied(), + )), + &actual_suffix_elements[suffix_start..], + ) + } + }; + let variance = TypeVarVariance::Covariant.compose(polarity); + for (formal_element, actual_element) in + formal_variable.prefix_elements().iter().zip(actual_prefix) + { + self.infer_map_impl(*formal_element, *actual_element, variance, seen)?; + } + for (formal_element, actual_element) in + formal_variable.suffix_elements().iter().zip(actual_suffix) + { + self.infer_map_impl(*formal_element, *actual_element, variance, seen)?; + } + self.add_type_mapping(typevartuple, packed, variance); + return Ok(()); + } + let Some(most_precise_length) = formal_tuple.len().most_precise(actual_tuple.len()) else { @@ -3109,12 +3258,11 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { return Ok(()); }; for (formal_element, actual_element) in formal_tuple - .all_elements() - .iter() - .zip(actual_tuple.all_elements()) + .iter_element_types(self.db) + .zip(actual_tuple.iter_element_types(self.db)) { let variance = TypeVarVariance::Covariant.compose(polarity); - self.infer_map_impl(*formal_element, *actual_element, variance, seen)?; + self.infer_map_impl(formal_element, actual_element, variance, seen)?; } return Ok(()); } diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 4218d57812..4c69d22176 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -80,8 +80,11 @@ bitflags::bitflags! { /// Metadata for expressions inferred as type expressions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub(crate) struct TypeExpressionFlags: u8 { - /// The expression is syntactically an `Unpack[...]` type expression. + /// The expression syntactically unpacks a type using either `Unpack[...]` or `*...`. const UNPACK = 1 << 0; + + /// The operand of an `Unpack[...]` expression is neither a tuple nor a `TypeVarTuple`. + const INVALID_UNPACK = 1 << 1; } } @@ -1879,7 +1882,7 @@ bitflags::bitflags! { /// Whether the visitor is currently visiting a nested position in a type expression. const IN_NESTED_TYPE_EXPRESSION = 1 << 13; - /// Whether the visitor is currently visiting the argument to `Unpack[...]`. + /// Whether the visitor is currently visiting the argument to `Unpack[...]` or `*`. const IN_UNPACK_TYPE_ARGUMENT = 1 << 14; } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4a8fb17eea..26c463f6d7 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -100,7 +100,7 @@ use crate::types::signatures::{CallableSignature, ReturnCallableTypeVarScope}; use crate::types::special_form::TypeQualifier; use crate::types::subclass_of::SubclassOfInner; use crate::types::tuple::promotion::TupleSizePromotionConstraints; -use crate::types::tuple::{Tuple, TupleLength, TupleSpecBuilder, TupleType}; +use crate::types::tuple::{Tuple, TupleLength, TupleSpecBuilder, TupleType, VariableSegment}; use crate::types::type_alias::{ManualPEP695TypeAliasType, PEP695TypeAliasType}; use crate::types::typed_dict::{TypedDictAssignmentKind, TypedDictKeyAssignment}; use crate::types::typevar::{BoundTypeVarIdentity, TypeVarConstraints, TypeVarIdentity}; @@ -1333,6 +1333,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { DefinitionKind::ParamSpec(paramspec) => { self.infer_paramspec_deferred(paramspec.node(self.module())); } + DefinitionKind::TypeVarTuple(typevartuple) => { + self.infer_typevartuple_deferred(typevartuple.node(self.module())); + } DefinitionKind::Assignment(assignment) => { self.infer_assignment_deferred( assignment.target(self.module()), @@ -2014,9 +2017,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { type_alias: &ast::StmtTypeAlias, definition: Definition<'db>, ) { + let alias_name = &type_alias.name.as_name_expr().unwrap().id; + // Check that no type parameter with a default follows a TypeVarTuple // in the type alias's PEP 695 type parameter list. if let Some(type_params) = type_alias.type_params.as_deref() { + post_inference::type_param_validation::check_single_typevar_tuple_pep695( + &self.context, + type_params, + post_inference::type_param_validation::TypeParameterOwner::TypeAlias(alias_name), + ); post_inference::type_param_validation::check_no_default_after_typevar_tuple_pep695( &self.context, type_params, @@ -2028,14 +2038,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .node_scope(NodeWithScopeRef::TypeAlias(type_alias)) .to_scope_id(self.db(), self.file()); - let type_alias_ty = Type::KnownInstance(KnownInstanceType::TypeAliasType( - TypeAliasType::PEP695(PEP695TypeAliasType::new( - self.db(), - &type_alias.name.as_name_expr().unwrap().id, - rhs_scope, - None, - )), - )); + let type_alias_ty = + Type::KnownInstance(KnownInstanceType::TypeAliasType(TypeAliasType::PEP695( + PEP695TypeAliasType::new(self.db(), alias_name, rhs_scope, None), + ))); self.store_expression_type(&type_alias.name, type_alias_ty); @@ -2227,7 +2233,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut builder = UnionBuilder::new(self.db()); let mut invalid_elements = vec![]; - for (index, element) in tuple_spec.all_elements().iter().enumerate() { + for (index, element) in tuple_spec.iter_element_types(self.db()).enumerate() { builder = builder.add( if element.is_assignable_to(self.db(), type_base_exception) { element.to_instance(self.db()).expect( @@ -2250,7 +2256,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let invalid_elements = invalid_elements .iter() - .map(|(index, ty)| (&tuple.elts[*index], **ty)); + .map(|(index, ty)| (&tuple.elts[*index], *ty)); report_invalid_exception_tuple_caught( &self.context, @@ -2309,7 +2315,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // `except (ValueError, TypeError) as e:` UnionType::try_from_elements( self.db(), - tuple_spec.all_elements().iter().map(|element| { + tuple_spec.iter_element_types(self.db()).map(|element| { if element.is_assignable_to(self.db(), type_base_exception) { Some(element.to_instance(self.db()).expect( "`Type::to_instance()` should always return `Some()` \ @@ -3064,7 +3070,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(tuple_spec) = assigned_ty.and_then(|ty| ty.tuple_instance_spec(self.db())) { - let assigned_tys = tuple_spec.all_elements().to_vec(); + let assigned_tys = tuple_spec.iter_element_types(self.db()).collect::>(); for (i, element) in elts.iter().enumerate() { match assigned_tys.get(i).copied() { @@ -3234,6 +3240,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { definition, paramspec_class, ), + Some( + typevartuple_class @ (KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple), + ) => self.infer_legacy_typevartuple( + target, + call_expr, + definition, + typevartuple_class, + ), Some(KnownClass::NewType) => { self.infer_newtype_expression(target, call_expr, definition) } @@ -3560,6 +3575,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } if let Some(default) = arguments.find_keyword("default") { if matches!( + known_class, + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) + ) { + self.infer_typevartuple_default(&default.value, None); + } else if matches!( known_class, Some(KnownClass::ParamSpec | KnownClass::ExtensionsParamSpec) ) { @@ -6406,6 +6426,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Tuple::Variable(tuple) = spec && tuple.prefix_elements().is_empty() && tuple.suffix_elements().is_empty() + && matches!(tuple.variable(), VariableSegment::Homogeneous(_)) { is_homogeneous_tuple_annotation = true; } @@ -6421,12 +6442,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let can_use_type_context = is_homogeneous_tuple_annotation || elts.iter().all(|elt| !elt.is_starred_expr()); - let mut annotated_elt_tys = annotated_tuple + let annotated_elt_tys = annotated_tuple .as_ref() - .map(Tuple::all_elements) - .unwrap_or_default() - .iter() - .copied(); + .map(|tuple| tuple.iter_element_types(self.db()).collect::>()) + .unwrap_or_default(); + let mut annotated_elt_tys = annotated_elt_tys.into_iter(); let db = self.db(); @@ -8633,6 +8653,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } } + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) => { + if let Some(builder) = self + .context + .report_lint(&INVALID_LEGACY_TYPE_VARIABLE, call_expression) + { + builder.into_diagnostic( + "A `TypeVarTuple` definition must be a simple variable assignment", + ); + } + } Some(KnownClass::NewType) => { if let Some(builder) = self.context.report_lint(&INVALID_NEWTYPE, call_expression) @@ -8847,6 +8877,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); let iterable_type = self.infer_expression(value, tcx); + let typevartuple = match iterable_type { + Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) + if typevar.is_typevartuple(db) => + { + bind_typevar( + db, + self.index, + self.scope().file_scope_id(db), + self.typevar_binding_context, + typevar, + ) + } + Type::TypeVar(typevar) if typevar.is_typevartuple(db) => Some(typevar), + _ => None, + }; + if let Some(typevartuple) = typevartuple { + return Type::tuple(TupleType::new( + db, + &TupleSpecBuilder::with_capacity(0) + .concat_variadic_typevar(db, typevartuple) + .build(), + )); + } iterable_type .try_iterate(db) .map(|spec| Type::tuple(TupleType::new(db, &spec))) diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index 82cf6d2734..8c8489fd87 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs @@ -526,26 +526,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }) } - ( - todo @ Type::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoUnpack - | DynamicType::TodoStarredExpression - | DynamicType::TodoTypeVarTuple, - ), - _, - _, - ) - | ( - _, - todo @ Type::Dynamic( - DynamicType::Todo(_) - | DynamicType::TodoUnpack - | DynamicType::TodoStarredExpression - | DynamicType::TodoTypeVarTuple, - ), - _, - ) => Some(todo), + (todo @ Type::Dynamic(DynamicType::Todo(_)), _, _) + | (_, todo @ Type::Dynamic(DynamicType::Todo(_)), _) => Some(todo), (Type::Never, _, _) | (_, Type::Never, _) => Some(Type::Never), diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index 3428a2c16a..fb47f50f02 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -28,7 +28,7 @@ use crate::{ }, infer_definition_types, infer_scope_types, signatures::ReturnCallableTypeVarScope, - todo_type, + tuple::{TupleSpecBuilder, TupleType}, typed_dict::extract_unpacked_typed_dict_keys_from_kwargs_annotation, }, }; @@ -437,8 +437,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let kind = match type_param { ast::TypeParam::TypeVar(_) => TypeVarKind::Pep695TypeVar, ast::TypeParam::ParamSpec(_) => TypeVarKind::Pep695ParamSpec, - // TODO: should be `TypeVarKind::Pep695TypeVarTuple` - ast::TypeParam::TypeVarTuple(_) => TypeVarKind::Pep695TypeVar, + ast::TypeParam::TypeVarTuple(_) => TypeVarKind::Pep695TypeVarTuple, }; report_shadowed_type_variable( &self.context, @@ -894,13 +893,23 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); if let Some(annotation) = parameter.annotation() { - let ty = if annotation.is_starred_expr() { - todo_type!("PEP 646") - } else { - let annotated_type = self.file_expression_type(annotation); - if let Type::TypeVar(typevar) = annotated_type - && typevar.is_paramspec(db) + let annotated_type = self.file_expression_type(annotation); + let has_unpacked_annotation = self + .file_type_expression_flags(annotation) + .contains(TypeExpressionFlags::UNPACK); + let ty = match annotated_type { + Type::TypeVar(typevar) + if has_unpacked_annotation && typevar.is_typevartuple(db) => { + Type::tuple(TupleType::new( + db, + &TupleSpecBuilder::with_capacity(0) + .concat_variadic_typevar(db, typevar) + .build(), + )) + } + _ if has_unpacked_annotation => annotated_type, + Type::TypeVar(typevar) if typevar.is_paramspec(db) => { match typevar.paramspec_attr(db) { // `*args: P.args` Some(ParamSpecAttrKind::Args) => annotated_type, @@ -930,9 +939,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::homogeneous_tuple(db, Type::unknown()) } } - } else { - Type::homogeneous_tuple(db, annotated_type) } + _ => Type::homogeneous_tuple(db, annotated_type), }; self.add_declaration_with_binding( diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs index 0cdb91f12f..e077088c70 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/function.rs @@ -208,7 +208,10 @@ fn check_legacy_typevar_defaults<'db>( // by `check_default_for_outer_scope_typevars` in the type parameter scope. if !matches!( typevar.kind(db), - TypeVarKind::LegacyTypeVar | TypeVarKind::Pep613Alias | TypeVarKind::LegacyParamSpec + TypeVarKind::LegacyTypeVar + | TypeVarKind::Pep613Alias + | TypeVarKind::LegacyParamSpec + | TypeVarKind::LegacyTypeVarTuple ) { continue; } @@ -333,7 +336,10 @@ fn check_legacy_typevar_ordering<'db>( // Only check legacy TypeVars; PEP 695 ordering is validated by the parser. if !matches!( typevar.kind(db), - TypeVarKind::LegacyTypeVar | TypeVarKind::Pep613Alias | TypeVarKind::LegacyParamSpec + TypeVarKind::LegacyTypeVar + | TypeVarKind::Pep613Alias + | TypeVarKind::LegacyParamSpec + | TypeVarKind::LegacyTypeVarTuple ) { continue; } diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 05411543d2..9e0bab2f64 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -761,6 +761,11 @@ pub(crate) fn check_static_class_definitions<'db>( // This is prohibited by the typing spec because a TypeVarTuple consumes // all remaining positional type arguments. if let Some(type_params) = class_node.type_params.as_deref() { + super::type_param_validation::check_single_typevar_tuple_pep695( + context, + type_params, + super::type_param_validation::TypeParameterOwner::GenericClass(&class_node.name.id), + ); super::type_param_validation::check_no_default_after_typevar_tuple_pep695( context, type_params, diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs index 4a55cd33b9..472fdcb7cd 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/type_param_validation.rs @@ -1,8 +1,69 @@ -use ruff_python_ast as ast; +use ruff_python_ast::{self as ast, name::Name}; use ruff_text_size::Ranged; use crate::diagnostic::format_enumeration; -use crate::types::{context::InferContext, diagnostic::INVALID_TYPE_VARIABLE_DEFAULT}; +use crate::types::{ + context::InferContext, + diagnostic::{INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_DEFAULT}, +}; + +#[derive(Clone, Copy)] +pub(crate) enum TypeParameterOwner<'a> { + GenericClass(&'a Name), + TypeAlias(&'a Name), +} + +/// Check that a PEP 695 class or type alias parameter list contains at most one `TypeVarTuple`. +/// +/// Classes and type aliases can be explicitly specialized, so multiple `TypeVarTuple`s would make +/// it ambiguous which pack consumes each type argument. Generic functions cannot be explicitly +/// specialized and intentionally do not use this validation. +pub(crate) fn check_single_typevar_tuple_pep695( + context: &InferContext<'_, '_>, + type_params: &ast::TypeParams, + owner: TypeParameterOwner<'_>, +) { + let (owner_kind, owner_name) = match owner { + TypeParameterOwner::GenericClass(name) => ("Generic class", name), + TypeParameterOwner::TypeAlias(name) => ("Type alias", name), + }; + let mut first_typevar_tuple: Option<&ast::TypeParamTypeVarTuple> = None; + + for type_param in type_params { + let ast::TypeParam::TypeVarTuple(typevar_tuple) = type_param else { + continue; + }; + + let Some(first_typevar_tuple) = first_typevar_tuple else { + first_typevar_tuple = Some(typevar_tuple); + continue; + }; + + let Some(builder) = context.report_lint(&INVALID_TYPE_FORM, typevar_tuple) else { + return; + }; + + let mut diagnostic = builder.into_diagnostic(format_args!( + "{owner_kind} `{owner_name}` cannot have multiple `TypeVarTuple` type parameters" + )); + + diagnostic.set_primary_message(format_args!( + "`{}` is an additional TypeVarTuple", + typevar_tuple.name + )); + + diagnostic.annotate(context.secondary(first_typevar_tuple).message(format_args!( + "`{}` is the first TypeVarTuple", + first_typevar_tuple.name + ))); + + diagnostic.info( + "See https://typing.python.org/en/latest/spec/generics.html#multiple-type-variable-tuples-not-allowed", + ); + + return; + } +} /// Check that no type parameter with a default follows a `TypeVarTuple` in a PEP 695 /// type parameter list. This is prohibited by the typing spec because a `TypeVarTuple` diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 0aba5a963c..662edd2fab 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -22,7 +22,7 @@ use crate::types::infer::builder::{ArgExpr, ArgumentsIter, MultiInferenceGuard}; use crate::types::infer::{InferenceFlags, TypeExpressionFlags}; use crate::types::special_form::AliasSpec; use crate::types::subscript::{LegacyGenericOrigin, SubscriptError, SubscriptErrorKind}; -use crate::types::tuple::{Tuple, TupleType}; +use crate::types::tuple::{Tuple, TupleSpecBuilder, TupleType, VariableSegment}; use crate::types::typed_dict::{TypedDictAssignmentKind, TypedDictKeyAssignment}; use crate::types::{ BoundTypeVarInstance, CallArguments, CallDunderError, CallableBinding, CycleDetector, @@ -362,6 +362,36 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return Type::KnownInstance(KnownInstanceType::Callable(callable)); } + SpecialFormType::Unpack => { + self.store_type_expression_flags( + ast::ExprRef::from(subscript), + TypeExpressionFlags::UNPACK, + ); + + let previously_in_unpack_type_argument = self + .context + .inference_flags + .replace(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, true); + let inner_ty = self.infer_type_expression(slice); + self.context.inference_flags.set( + InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, + previously_in_unpack_type_argument, + ); + + return if matches!( + inner_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || inner_ty.exact_tuple_instance_spec(db).is_some() + { + inner_ty + } else { + self.store_type_expression_flags( + ast::ExprRef::from(subscript), + TypeExpressionFlags::INVALID_UNPACK, + ); + Type::unknown() + }; + } SpecialFormType::LegacyStdlibAlias(alias) => { let AliasSpec { class, @@ -538,6 +568,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } /// A type argument after expanding any allowed `Unpack[tuple[...]]` syntax. + #[derive(Clone, Copy)] struct TypeArgument<'ast, 'db> { /// The source expression used for diagnostics and deferred inference. node: &'ast ast::Expr, @@ -566,11 +597,30 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let typevars = generic_context.variables(db).collect::>(); let typevars_len = typevars.len(); + let typevartuple_index = typevars + .iter() + .position(|typevar| typevar.is_typevartuple(db)); let mut expanded_type_arguments = Vec::with_capacity(type_arguments.len()); for (source_index, expr) in type_arguments.iter().enumerate() { - let typevar = typevars.get(expanded_type_arguments.len()).copied(); + let typevar = if let Some(typevartuple_index) = typevartuple_index { + let suffix_len = typevars_len - typevartuple_index - 1; + let suffix_source_start = type_arguments.len().saturating_sub(suffix_len); + if suffix_len > 0 + && type_arguments.len() >= typevartuple_index + suffix_len + && source_index >= suffix_source_start + { + let suffix_index = source_index - suffix_source_start; + typevars + .get(typevars_len - suffix_len + suffix_index) + .copied() + } else { + typevars.get(expanded_type_arguments.len()).copied() + } + } else { + typevars.get(expanded_type_arguments.len()).copied() + }; if exactly_one_paramspec || typevar.is_some_and(|typevar| typevar.is_paramspec(db)) { expanded_type_arguments.push(TypeArgument { node: expr, @@ -600,9 +650,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { inferred_type_arguments[source_index] = Some(provided_type); - let is_unpack = self - .type_expression_flags(expr) - .contains(TypeExpressionFlags::UNPACK); + let type_expression_flags = self.type_expression_flags(expr); + let is_unpack = type_expression_flags.contains(TypeExpressionFlags::UNPACK); if is_unpack && let Some(tuple) = provided_type.exact_tuple_instance_spec(db) @@ -628,14 +677,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && !self .inference_flags() .contains(InferenceFlags::IN_KWARG_ANNOTATION) - && !matches!( - value_ty, - Type::GenericAlias(alias) - if alias - .specialization(db) - .types(db) - .contains(&Type::Dynamic(DynamicType::TodoTypeVarTuple)) - ) + && typevartuple_index.is_none() + && !type_expression_flags.contains(TypeExpressionFlags::INVALID_UNPACK) && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, expr) { builder.into_diagnostic( @@ -651,6 +694,162 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + let expanded_type_arguments = if let Some(typevartuple_index) = typevartuple_index { + let suffix_len = typevars_len - typevartuple_index - 1; + let typevartuple_end = expanded_type_arguments + .len() + .saturating_sub(suffix_len) + .max(typevartuple_index); + let mut packed = Vec::with_capacity(typevars_len); + + let mut tuple_builder = TupleSpecBuilder::with_capacity( + typevartuple_end.saturating_sub(typevartuple_index), + ); + for type_argument in + &expanded_type_arguments[..expanded_type_arguments.len().min(typevartuple_index)] + { + let provided_type = type_argument.ty.unwrap_or_else(Type::unknown); + let is_unpack = self + .type_expression_flags(type_argument.node) + .contains(TypeExpressionFlags::UNPACK); + if is_unpack + && let Some(tuple) = provided_type.exact_tuple_instance_spec(db) + && let Tuple::Variable(variable) = tuple.as_ref() + && variable.prefix_elements().is_empty() + && variable.suffix_elements().is_empty() + && let Some(variable_type) = variable.variable().homogeneous_type() + { + tuple_builder = tuple_builder.concat(db, &tuple); + packed.push(TypeArgument { + ty: Some(variable_type), + ..*type_argument + }); + } else if is_unpack + && (matches!( + provided_type, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || matches!( + provided_type.exact_tuple_instance_spec(db).as_deref(), + Some(Tuple::Variable(variable)) + if variable.variable().typevartuple().is_some() + )) + { + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPE_FORM, type_argument.node) + { + builder.into_diagnostic( + "A TypeVarTuple cannot be split to provide a fixed type argument", + ); + } + packed.push(TypeArgument { + ty: Some(Type::unknown()), + ..*type_argument + }); + } else { + packed.push(*type_argument); + } + } + + let typevartuple_start = expanded_type_arguments.len().min(typevartuple_index); + for type_argument in &expanded_type_arguments + [typevartuple_start..expanded_type_arguments.len().min(typevartuple_end)] + { + let provided_type = type_argument.ty.unwrap_or_else(|| { + let previously_in_valid_unpack_context = self + .context + .inference_flags + .replace(InferenceFlags::IN_VALID_UNPACK_CONTEXT, true); + let provided_type = self.infer_type_expression(type_argument.node); + self.context.inference_flags.set( + InferenceFlags::IN_VALID_UNPACK_CONTEXT, + previously_in_valid_unpack_context, + ); + inferred_type_arguments[type_argument.source_index] = Some(provided_type); + provided_type + }); + let is_unpack = self + .type_expression_flags(type_argument.node) + .contains(TypeExpressionFlags::UNPACK); + if is_unpack && let Some(tuple) = provided_type.exact_tuple_instance_spec(db) { + tuple_builder = tuple_builder.concat(db, &tuple); + } else if is_unpack + && let Type::TypeVar(typevar) = provided_type + && typevar.is_typevartuple(db) + { + tuple_builder = tuple_builder.concat_variadic_typevar(db, typevar); + } else { + tuple_builder.push(provided_type); + } + } + + let mut packed_suffix = Vec::with_capacity(suffix_len); + if suffix_len > 0 && expanded_type_arguments.len() >= typevartuple_index + suffix_len { + for type_argument in + &expanded_type_arguments[expanded_type_arguments.len() - suffix_len..] + { + let provided_type = type_argument.ty.unwrap_or_else(Type::unknown); + let is_unpack = self + .type_expression_flags(type_argument.node) + .contains(TypeExpressionFlags::UNPACK); + if is_unpack + && let Some(tuple) = provided_type.exact_tuple_instance_spec(db) + && let Tuple::Variable(variable) = tuple.as_ref() + && variable.prefix_elements().is_empty() + && variable.suffix_elements().is_empty() + && let Some(variable_type) = variable.variable().homogeneous_type() + { + tuple_builder = tuple_builder.concat(db, &tuple); + packed_suffix.push(TypeArgument { + ty: Some(variable_type), + ..*type_argument + }); + } else if is_unpack + && (matches!( + provided_type, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || matches!( + provided_type.exact_tuple_instance_spec(db).as_deref(), + Some(Tuple::Variable(variable)) + if variable.variable().typevartuple().is_some() + )) + { + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPE_FORM, type_argument.node) + { + builder.into_diagnostic( + "A TypeVarTuple cannot be split to provide a fixed type argument", + ); + } + packed_suffix.push(TypeArgument { + ty: Some(Type::unknown()), + ..*type_argument + }); + } else { + packed_suffix.push(*type_argument); + } + } + } + + if expanded_type_arguments.len() >= typevartuple_index { + packed.push(TypeArgument { + node: expanded_type_arguments + .get(typevartuple_index) + .map_or(slice_node, |argument| argument.node), + ty: Some(Type::tuple(TupleType::new(db, &tuple_builder.build()))), + source_index: expanded_type_arguments + .get(typevartuple_index) + .map_or(0, |argument| argument.source_index), + }); + } + packed.extend(packed_suffix); + + packed + } else { + expanded_type_arguments + }; + let mut specialization_types = Vec::with_capacity(typevars_len); let mut typevar_with_defaults = 0; let mut missing_typevars = vec![]; @@ -848,15 +1047,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } if let Some(first_excess_type_argument_index) = first_excess_type_argument_index { - if let Type::GenericAlias(alias) = value_ty - && alias - .specialization(db) - .types(db) - .contains(&Type::Dynamic(DynamicType::TodoTypeVarTuple)) - { - // Avoid false-positive errors when specializing a class - // that's generic over a legacy TypeVarTuple - } else if typevars_len == 0 { + if typevars_len == 0 { // Type parameter list cannot be empty, so if we reach here, `value_ty` is not a generic type. if let Some(builder) = self.context.report_lint(&NOT_SUBSCRIPTABLE, subscript) { let mut diagnostic = builder.into_diagnostic(format_args!( @@ -927,6 +1118,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .map(|typevar| { Some(if typevar.is_paramspec(db) { Type::paramspec_value_callable(db, Parameters::unknown()) + } else if typevar.is_typevartuple(db) { + Type::homogeneous_tuple(db, Type::unknown()) } else { Type::unknown() }) @@ -1157,6 +1350,77 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ) -> Type<'db> { let db = self.db(); + if let Some(origin) = match value_ty { + Type::SpecialForm(SpecialFormType::Generic) => Some(LegacyGenericOrigin::Generic), + Type::SpecialForm(SpecialFormType::Protocol) => Some(LegacyGenericOrigin::Protocol), + _ => None, + } { + let arguments = if let ast::Expr::Tuple(tuple) = subscript.slice.as_ref() { + &*tuple.elts + } else { + std::slice::from_ref(subscript.slice.as_ref()) + }; + let has_invalid_unpack_argument = arguments.iter().any(|argument| { + self.type_expression_flags(argument) + .contains(TypeExpressionFlags::INVALID_UNPACK) + }); + let is_unpacked_typevartuple = |argument: &ast::Expr| { + let operand = match argument { + ast::Expr::Starred(starred) => &*starred.value, + ast::Expr::Subscript(subscript) + if self.expression_type(&subscript.value) + == Type::SpecialForm(SpecialFormType::Unpack) => + { + &*subscript.slice + } + _ => return false, + }; + let argument_ty = self.expression_type(argument); + let operand_ty = self.expression_type(operand); + matches!( + argument_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || matches!( + argument_ty.exact_tuple_instance_spec(db).as_deref(), + Some(Tuple::Variable(variable)) + if variable.variable().typevartuple().is_some() + ) || matches!( + operand_ty, + Type::NominalInstance(instance) + if matches!( + instance.known_class(db), + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) + ) + ) + }; + // A tuple type can preserve only one variable segment, so count unpacked + // `TypeVarTuple`s before the argument tuple is lowered to its type. + let has_multiple_typevartuple_arguments = arguments + .iter() + .filter(|argument| is_unpacked_typevartuple(argument)) + .nth(1) + .is_some(); + if has_multiple_typevartuple_arguments { + let error = SubscriptError::new( + Type::unknown(), + SubscriptErrorKind::MultipleTypeVarTuples { origin }, + ); + error.report_diagnostics(&self.context, subscript); + return error.result_type(); + } + if has_invalid_unpack_argument { + let error = SubscriptError::new( + Type::unknown(), + SubscriptErrorKind::InvalidLegacyGenericArgument { + origin, + argument_ty: Type::SpecialForm(SpecialFormType::Unpack), + }, + ); + error.report_diagnostics(&self.context, subscript); + return error.result_type(); + } + } + // Special typing forms for which subscriptions are context-dependent are parsed here, // outside of `Type::subscript`, which is a pure function that doesn't depend on the // semantic index or any context-dependent state. @@ -2103,11 +2367,21 @@ fn legacy_generic_class_context<'db>( ) -> Result, LegacyGenericContextError<'db>> { let typevars_class_tuple_spec = typevars.exact_tuple_instance_spec(db); + let unpacked_typevars; let typevars = if let Some(tuple_spec) = typevars_class_tuple_spec.as_deref() { match tuple_spec { Tuple::Fixed(typevars) => typevars.elements_slice(), - Tuple::Variable(_) => { - return Err(LegacyGenericContextError::VariadicTupleArguments); + Tuple::Variable(variable) => { + if let VariableSegment::TypeVarTuple(typevartuple) = variable.variable() { + unpacked_typevars = variable + .iter_prefix_elements() + .chain(std::iter::once(Type::TypeVar(typevartuple))) + .chain(variable.iter_suffix_elements()) + .collect::>(); + &unpacked_typevars + } else { + return Err(LegacyGenericContextError::VariadicTupleArguments); + } } } } else { @@ -2120,18 +2394,32 @@ fn legacy_generic_class_context<'db>( if let Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) = argument_ty { let bound = bind_typevar(db, index, file_scope_id, typevar_binding_context, typevar) .ok_or(LegacyGenericContextError::InvalidArgument(argument_ty))?; + if bound.is_typevartuple(db) { + return Err(LegacyGenericContextError::TypeVarTupleMustBeUnpacked); + } if !validated_typevars.insert(bound) { return Err(LegacyGenericContextError::DuplicateTypevar( typevar.name(db), )); } + } else if let Type::TypeVar(bound) = argument_ty + && bound.is_typevartuple(db) + { + if !validated_typevars.insert(bound) { + return Err(LegacyGenericContextError::DuplicateTypevar(bound.name(db))); + } } else if let Type::NominalInstance(instance) = argument_ty - && instance.has_known_class(db, KnownClass::TypeVarTuple) + && matches!( + instance.known_class(db), + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) + ) { return Err(LegacyGenericContextError::TypeVarTupleMustBeUnpacked); } else if any_over_type(db, argument_ty, true, |inner_ty| match inner_ty { - Type::Dynamic(DynamicType::TodoUnpack | DynamicType::TodoStarredExpression) => true, - Type::NominalInstance(nominal) => nominal.has_known_class(db, KnownClass::TypeVarTuple), + Type::NominalInstance(nominal) => matches!( + nominal.known_class(db), + Some(KnownClass::TypeVarTuple | KnownClass::ExtensionsTypeVarTuple) + ), _ => false, }) { return Err(LegacyGenericContextError::NotYetSupported); diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index b189e3b1a2..716e9c64b3 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1,5 +1,6 @@ use itertools::Either; use ruff_python_ast::helpers::is_dotted_name; +use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, PythonVersion}; use ruff_text_size::Ranged; @@ -199,7 +200,29 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let value_ty = self.infer_expression(value, TypeContext::default()); if is_dotted_name(value) { - self.infer_subscript_type_expression_no_store(subscript, slice, value_ty) + // Preserve the flag for another `Unpack` so that nested unpacking emits a + // diagnostic. Other subscripts are no longer the direct unpack operand. + let previously_in_unpack_type_argument = + if value_ty == Type::SpecialForm(SpecialFormType::Unpack) { + None + } else { + Some( + self.context + .inference_flags + .replace(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, false), + ) + }; + let ty = + self.infer_subscript_type_expression_no_store(subscript, slice, value_ty); + if let Some(previously_in_unpack_type_argument) = + previously_in_unpack_type_argument + { + self.context.inference_flags.set( + InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, + previously_in_unpack_type_argument, + ); + } + ty } else { if !self.in_string_annotation() { self.infer_expression(slice, TypeContext::default()); @@ -931,11 +954,38 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ctx: _, } = starred; + self.store_type_expression_flags(ast::ExprRef::from(starred), TypeExpressionFlags::UNPACK); + + let previously_in_unpack_type_argument = self + .context + .inference_flags + .replace(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, true); let starred_type = self.infer_type_expression(value); - if starred_type.exact_tuple_instance_spec(self.db()).is_some() { + self.context.inference_flags.set( + InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, + previously_in_unpack_type_argument, + ); + + if starred_type.exact_tuple_instance_spec(self.db()).is_some() + || matches!( + starred_type, + Type::TypeVar(typevar) if typevar.is_typevartuple(self.db()) + ) + { starred_type } else { - Type::Dynamic(DynamicType::TodoStarredExpression) + self.store_type_expression_flags( + ast::ExprRef::from(starred), + TypeExpressionFlags::INVALID_UNPACK, + ); + if !starred_type.is_unknown() + && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, starred) + { + diagnostic::add_type_expression_reference_link( + builder.into_diagnostic("`*` can only unpack a tuple type or `TypeVarTuple`"), + ); + } + Type::homogeneous_tuple(self.db(), Type::unknown()) } } @@ -1012,47 +1062,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { &mut self, tuple: &ast::ExprSubscript, ) -> Option> { - /// In most cases, if a subelement of the tuple is inferred as `Todo`, - /// we should only infer `Todo` for that specific subelement. - /// Certain specific AST nodes can however change the meaning of the entire tuple, - /// however: for example, `tuple[int, ...]` or `tuple[int, *tuple[str, ...]]` are a - /// homogeneous tuple and a partly homogeneous tuple (respectively) due to the `...` - /// and the starred expression (respectively), Neither is supported by us right now, - /// so we should infer `Todo` for the *entire* tuple if we encounter one of those elements. - fn element_could_alter_type_of_whole_tuple( - element: &ast::Expr, - element_ty: Type, - builder: &mut TypeInferenceBuilder, - ) -> bool { - if !element_ty.is_todo() { - return false; - } - - match element { - ast::Expr::Starred(_) => { - element_ty.exact_tuple_instance_spec(builder.db()).is_none() - } - ast::Expr::Subscript(ast::ExprSubscript { value, .. }) => { - let value_ty = builder.expression_type(value); - - value_ty == Type::SpecialForm(SpecialFormType::Unpack) - } - _ => false, - } - } - - // TODO: TypeVarTuple match &*tuple.slice { ast::Expr::Tuple(elements) => { if let [element, ellipsis @ ast::Expr::EllipsisLiteral(_)] = &*elements.elts { - if element.is_starred_expr() - && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, tuple) - { - let mut diagnostic = - builder.into_diagnostic("Invalid `tuple` specialization"); - diagnostic - .set_primary_message("`...` cannot be used after an unpacked element"); - } self.infer_expression(ellipsis, TypeContext::default()); let previously_in_valid_unpack_context = self .context @@ -1063,6 +1075,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { InferenceFlags::IN_VALID_UNPACK_CONTEXT, previously_in_valid_unpack_context, ); + if self + .type_expression_flags(element) + .contains(TypeExpressionFlags::UNPACK) + && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, tuple) + { + let mut diagnostic = + builder.into_diagnostic("Invalid `tuple` specialization"); + diagnostic + .set_primary_message("`...` cannot be used after an unpacked element"); + } let result = TupleType::homogeneous(self.db(), element_ty); self.store_expression_type(&tuple.slice, Type::tuple(Some(result))); return Some(result); @@ -1070,10 +1092,6 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut element_types = TupleSpecBuilder::with_capacity(elements.len()); - // Whether to infer `Todo` for the whole tuple - // (see docstring for `element_could_alter_type_of_whole_tuple`) - let mut return_todo = false; - let mut first_unpacked_variadic_tuple = None; for element in elements { @@ -1099,25 +1117,16 @@ impl<'db> TypeInferenceBuilder<'db, '_> { InferenceFlags::IN_VALID_UNPACK_CONTEXT, previously_in_valid_unpack_context, ); - return_todo |= - element_could_alter_type_of_whole_tuple(element, element_ty, self); - // Determine if this element unpacks a tuple: either `*expr` or `Unpack[expr]` - let unpack_inner = if let ast::Expr::Starred(ast::ExprStarred { - value, .. - }) = element - { - Some(&**value) - } else if let ast::Expr::Subscript(ast::ExprSubscript { value, slice, .. }) = - element - && self.expression_type(value) == Type::SpecialForm(SpecialFormType::Unpack) - { - Some(&**slice) - } else { - None - }; + let is_unpack = matches!(element, ast::Expr::Starred(_)) + || matches!( + element, + ast::Expr::Subscript(ast::ExprSubscript { value, .. }) + if self.expression_type(value) + == Type::SpecialForm(SpecialFormType::Unpack) + ); - if let Some(unpack_inner) = unpack_inner { + if is_unpack { let mut report_too_many_unpacked_tuples = || { if let Some(first_unpacked_variadic_tuple) = first_unpacked_variadic_tuple @@ -1151,10 +1160,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if inner_tuple.is_variadic() { report_too_many_unpacked_tuples(); } - } else if self.expression_type(unpack_inner) - == Type::Dynamic(DynamicType::TodoTypeVarTuple) + } else if let Type::TypeVar(typevar) = element_ty + && typevar.is_typevartuple(self.db()) { report_too_many_unpacked_tuples(); + element_types = + element_types.concat_variadic_typevar(self.db(), typevar); } else { // TODO: emit a diagnostic } @@ -1163,14 +1174,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } } - let ty = if return_todo { - Some(TupleType::homogeneous( - self.db(), - Type::Dynamic(DynamicType::TodoTypeVarTuple), - )) - } else { - TupleType::new(self.db(), &element_types.build()) - }; + let ty = TupleType::new(self.db(), &element_types.build()); // Here, we store the type for the inner `int, str` tuple-expression, // while the type for the outer `tuple[int, str]` slice-expression is @@ -1201,15 +1205,30 @@ impl<'db> TypeInferenceBuilder<'db, '_> { InferenceFlags::IN_VALID_UNPACK_CONTEXT, previously_in_valid_unpack_context, ); - if element_could_alter_type_of_whole_tuple(single_element, single_element_ty, self) - { - Some(TupleType::homogeneous( - self.db(), - Type::Dynamic(DynamicType::TodoTypeVarTuple), - )) - } else { - TupleType::heterogeneous(self.db(), std::iter::once(single_element_ty)) + let single_element_is_unpack = matches!(single_element, ast::Expr::Starred(_)) + || matches!( + single_element, + ast::Expr::Subscript(ast::ExprSubscript { value, .. }) + if self.expression_type(value) + == Type::SpecialForm(SpecialFormType::Unpack) + ); + if single_element_is_unpack { + if let Some(inner_tuple) = + single_element_ty.exact_tuple_instance_spec(self.db()) + { + return TupleType::new(self.db(), &inner_tuple); + } else if let Type::TypeVar(typevar) = single_element_ty + && typevar.is_typevartuple(self.db()) + { + return TupleType::new( + self.db(), + &TupleSpecBuilder::with_capacity(0) + .concat_variadic_typevar(self.db(), typevar) + .build(), + ); + } } + TupleType::heterogeneous(self.db(), std::iter::once(single_element_ty)) } } } @@ -2012,17 +2031,70 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let param_type = self.infer_type_expression(arguments_slice); UnionType::from_elements_leave_aliases(db, [param_type, Type::none(db)]) } - SpecialFormType::Union => match arguments_slice { - ast::Expr::Tuple(t) => { - let union_ty = UnionType::from_elements_leave_aliases( - db, - t.iter().map(|elt| self.infer_type_expression(elt)), - ); - self.store_expression_type(arguments_slice, union_ty); + SpecialFormType::Union => { + // TODO: Support the union of a `TypeVarTuple`'s elements. Until then, reject + // `Union[*Ts]` and recover to `object` rather than treating `Ts` as one member. + let arguments = if let ast::Expr::Tuple(tuple) = arguments_slice { + &*tuple.elts + } else { + std::slice::from_ref(arguments_slice) + }; + let mut has_unpacked_typevartuple = false; + let union_ty = UnionType::from_elements_leave_aliases( + db, + arguments.iter().map(|argument| { + let ty = self.infer_type_expression(argument); + if self + .type_expression_flags(argument) + .contains(TypeExpressionFlags::UNPACK) + { + let is_typevartuple = matches!( + ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || if let ast::Expr::Subscript(subscript) = argument { + let previously_in_unpack_type_argument = self + .context + .inference_flags + .replace(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, true); + let inner_ty = self.infer_type_expression(&subscript.slice); + self.context.inference_flags.set( + InferenceFlags::IN_UNPACK_TYPE_ARGUMENT, + previously_in_unpack_type_argument, + ); + matches!( + inner_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) + } else { + false + }; + if is_typevartuple { + has_unpacked_typevartuple = true; + if !ty.is_unknown() + && let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, argument) + { + diagnostic::add_type_expression_reference_link( + builder.into_diagnostic( + "Unpacking a `TypeVarTuple` in `Union` is not supported", + ), + ); + } + } + } + ty + }), + ); + let ty = if has_unpacked_typevartuple { + Type::object() + } else { union_ty + }; + if arguments_slice.is_tuple_expr() { + self.store_expression_type(arguments_slice, ty); } - _ => self.infer_type_expression(arguments_slice), - }, + ty + } SpecialFormType::TypingCallable | SpecialFormType::CollectionsAbcCallable => { self.infer_callable_type(subscript) } @@ -2388,10 +2460,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if self .inference_flags() - .contains(InferenceFlags::IN_KWARG_ANNOTATION) - && self - .inference_flags() - .contains(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT) + .contains(InferenceFlags::IN_UNPACK_TYPE_ARGUMENT) { if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { diagnostic::add_type_expression_reference_link( @@ -2449,20 +2518,29 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return inner_ty; } - // When the argument is a tuple type, return it directly so that - // `Unpack[tuple[int, ...]]` behaves identically to `*tuple[int, ...]`. - // - // However, we still need a Todo type for things like - // `def f(*args: Unpack[tuple[int, Unpack[tuple[str, ...]]]]): ...`, - // which we don't yet support. - if self - .inference_flags() - .contains(InferenceFlags::IN_VARARG_ANNOTATION) - || inner_ty.exact_tuple_instance_spec(self.db()).is_none() + // Preserve valid unpack targets so that `Unpack[...]` follows the same + // argument-binding path as an equivalent starred annotation. + if inner_ty.exact_tuple_instance_spec(self.db()).is_some() + || matches!( + inner_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(self.db()) + ) { - todo_type!("`Unpack[]` special form") - } else { inner_ty + } else { + self.store_type_expression_flags( + ast::ExprRef::from(subscript), + TypeExpressionFlags::INVALID_UNPACK, + ); + if !inner_ty.is_unknown() + && let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, subscript) + { + diagnostic::add_type_expression_reference_link(builder.into_diagnostic( + "`Unpack` can only unpack a tuple type or `TypeVarTuple`", + )); + } + Type::homogeneous_tuple(self.db(), Type::unknown()) } } SpecialFormType::NoReturn @@ -2508,12 +2586,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let arguments_as_tuple = arguments.exact_tuple_instance_spec(db); - let mut argument_elements = arguments_as_tuple - .as_ref() - .map(|tup| tup.all_elements()) - .unwrap_or(std::slice::from_ref(&arguments)) - .iter() - .copied(); + let argument_elements = arguments_as_tuple.as_ref().map_or_else( + || vec![arguments], + |tuple| tuple.iter_element_types(db).collect(), + ); + let mut argument_elements = argument_elements.into_iter(); let probably_meant_literal = argument_elements.all(|ty| match ty { Type::LiteralValue(literal) @@ -2702,10 +2779,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return None; } - let mut parameter_types = Vec::with_capacity(params.len()); - - // Whether to infer `Todo` for the parameters - let mut return_todo = false; + let mut parameters = Vec::with_capacity(params.len()); let previously_in_valid_unpack_context = self .context @@ -2713,30 +2787,41 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .replace(InferenceFlags::IN_VALID_UNPACK_CONTEXT, true); for param in params { let param_type = self.infer_type_expression(param); - // This is similar to what we currently do for inferring tuple type expression. - // We currently infer `Todo` for the parameters to avoid invalid diagnostics - // when trying to check for assignability or any other relation. For example, - // `*tuple[int, str]`, `Unpack[]`, etc. are not yet supported. - return_todo |= param_type.is_todo() - && matches!(param, ast::Expr::Starred(_) | ast::Expr::Subscript(_)); - parameter_types.push(param_type); + let is_unpack = self + .type_expression_flags(param) + .contains(TypeExpressionFlags::UNPACK); + + if is_unpack { + if let Type::TypeVar(typevar) = param_type + && typevar.is_typevartuple(self.db()) + { + parameters.push( + Parameter::variadic(Name::new_static("args")) + .with_annotated_type(Type::TypeVar(typevar)) + .with_starred_annotation(), + ); + continue; + } + + if param_type.exact_tuple_instance_spec(self.db()).is_some() { + parameters.push( + Parameter::variadic(Name::new_static("args")) + .with_annotated_type(param_type) + .with_starred_annotation(), + ); + continue; + } + } + + parameters + .push(Parameter::positional_only(None).with_annotated_type(param_type)); } self.context.inference_flags.set( InferenceFlags::IN_VALID_UNPACK_CONTEXT, previously_in_valid_unpack_context, ); - return Some(if return_todo { - // TODO: `Unpack` - Parameters::todo() - } else { - Parameters::from_annotation( - self.db(), - parameter_types.iter().map(|param_type| { - Parameter::positional_only(None).with_annotated_type(*param_type) - }), - ) - }); + return Some(Parameters::from_annotation(self.db(), parameters)); } ast::Expr::Subscript(subscript) => { let value_ty = self.infer_expression(&subscript.value, TypeContext::default()); diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs index 012d9dcd7c..a68089ffa1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs @@ -2,7 +2,7 @@ use ruff_python_ast::{self as ast}; use super::TypeInferenceBuilder; use crate::types::diagnostic::INVALID_TYPE_FORM; -use crate::types::{CycleDetector, DynamicType, KnownClass, Type, TypeContext, TypeFormType}; +use crate::types::{CycleDetector, KnownClass, Type, TypeContext, TypeFormType}; impl<'db> TypeInferenceBuilder<'db, '_> { /// In a `TypeForm` context, keep the ordinary value interpretation if it is @@ -56,11 +56,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let contextual_ty = self .speculate_without_diagnostics() .infer_value_expression_impl(expression, TypeContext::new(Some(target))); - // TODO: Remove this exception once `Unpack` produces a precise type instead of a - // dynamic placeholder in ordinary expression inference. - if contextual_ty.is_assignable_to(self.db(), target) - && contextual_ty != Type::Dynamic(DynamicType::TodoUnpack) - { + if contextual_ty.is_assignable_to(self.db(), target) { return None; } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs index 9640d65eaf..15b411278b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs @@ -11,10 +11,9 @@ use crate::{ report_mismatched_type_name, }, infer::{ - InferenceFlags, TypeInferenceBuilder, + InferenceFlags, TypeExpressionFlags, TypeInferenceBuilder, builder::{BoundOrConstraintsNodes, DeclaredAndInferredType, DeferredExpressionState}, }, - todo_type, typevar::{ TypeVarBoundOrConstraintsEvaluation, TypeVarConstraints, TypeVarDefaultEvaluation, TypeVarIdentity, TypeVarInstance, @@ -664,18 +663,350 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let ast::TypeParamTypeVarTuple { range: _, node_index: _, - name: _, + name, default, } = node; - self.infer_optional_expression(default.as_deref(), TypeContext::default()); - let pep_695_todo = todo_type!("PEP-695 TypeVarTuple definition types"); + + let db = self.db(); + + if default.is_some() { + self.deferred.insert(definition); + } + let identity = TypeVarIdentity::new( + db, + &name.id, + Some(definition), + TypeVarKind::Pep695TypeVarTuple, + ); + let ty = Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( + db, + identity, + None, + None, // explicit_variance + default.as_deref().map(|_| TypeVarDefaultEvaluation::Lazy), + ))); self.add_declaration_with_binding( node.into(), definition, - &DeclaredAndInferredType::are_the_same_type(pep_695_todo), + &DeclaredAndInferredType::are_the_same_type(ty), ); } + pub(super) fn infer_typevartuple_deferred(&mut self, node: &ast::TypeParamTypeVarTuple) { + let ast::TypeParamTypeVarTuple { + range: _, + node_index: _, + name, + default: Some(default), + } = node + else { + return; + }; + let previous_deferred_state = + std::mem::replace(&mut self.deferred_state, DeferredExpressionState::Deferred); + self.infer_typevartuple_default(default, Some(&name.id)); + self.deferred_state = previous_deferred_state; + } + + pub(super) fn infer_typevartuple_default( + &mut self, + default_expr: &ast::Expr, + typevartuple_name: Option<&str>, + ) { + let previously_in_valid_unpack_context = self + .context + .inference_flags + .replace(InferenceFlags::IN_VALID_UNPACK_CONTEXT, true); + let default_ty = self.infer_type_expression(default_expr); + self.context.inference_flags.set( + InferenceFlags::IN_VALID_UNPACK_CONTEXT, + previously_in_valid_unpack_context, + ); + + if let Some(name) = typevartuple_name + && self.check_default_for_outer_scope_typevars(default_ty, default_expr, name) + { + return; + } + + if !self + .type_expression_flags(default_expr) + .contains(TypeExpressionFlags::UNPACK) + && !matches!( + default_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(self.db()) + ) + && let Some(builder) = self + .context + .report_lint(&INVALID_LEGACY_TYPE_VARIABLE, default_expr) + { + builder.into_diagnostic( + "The default value for `TypeVarTuple` must be an unpacked tuple type \ + or another TypeVarTuple", + ); + } + } + + pub(super) fn infer_legacy_typevartuple( + &mut self, + target: &ast::Expr, + call_expr: &ast::ExprCall, + definition: Definition<'db>, + known_class: KnownClass, + ) -> Type<'db> { + fn error<'db>( + context: &InferContext<'db, '_>, + message: impl std::fmt::Display, + node: impl Ranged, + ) -> Type<'db> { + if let Some(builder) = context.report_lint(&INVALID_LEGACY_TYPE_VARIABLE, node) { + builder.into_diagnostic(message); + } + KnownClass::TypeVarTuple.to_instance(context.db()) + } + + let db = self.db(); + let arguments = &call_expr.arguments; + let is_typing_extensions = known_class == KnownClass::ExtensionsTypeVarTuple; + let assume_all_features = self.in_stub() || is_typing_extensions; + let python_version = Program::get(db).python_version(db); + let have_features_from = + |version: PythonVersion| assume_all_features || python_version >= version; + + let mut default = None; + let mut covariant = false; + let mut contravariant = false; + let mut infer_variance = false; + let mut name_param_ty = None; + let mut name_param_node = None; + + if arguments.args.len() > 1 { + return error( + &self.context, + "`TypeVarTuple` can only have one positional argument", + call_expr, + ); + } + + if let Some(starred) = arguments.args.iter().find(|arg| arg.is_starred_expr()) { + return error( + &self.context, + "Starred arguments are not supported in `TypeVarTuple` creation", + starred, + ); + } + + for kwarg in &arguments.keywords { + let Some(identifier) = kwarg.arg.as_ref() else { + return error( + &self.context, + "Starred arguments are not supported in `TypeVarTuple` creation", + kwarg, + ); + }; + match identifier.id().as_str() { + "name" => { + if !arguments.args.is_empty() { + return error( + &self.context, + "The `name` parameter of `TypeVarTuple` can only be provided once", + kwarg, + ); + } + name_param_node = Some(&kwarg.value); + name_param_ty = + Some(self.infer_expression(&kwarg.value, TypeContext::default())); + } + "default" => { + if !have_features_from(PythonVersion::PY313) { + error( + &self.context, + "The `default` parameter of `typing.TypeVarTuple` was added in Python 3.13", + kwarg, + ); + } + default = Some(TypeVarDefaultEvaluation::Lazy); + } + "bound" => { + if !have_features_from(PythonVersion::PY315) { + return error( + &self.context, + "The `bound` parameter of `typing.TypeVarTuple` was added in Python 3.15", + kwarg, + ); + } + return error( + &self.context, + "The `bound` argument for `TypeVarTuple` is not supported", + call_expr, + ); + } + "covariant" => { + if !have_features_from(PythonVersion::PY315) { + error( + &self.context, + "The `covariant` parameter of `typing.TypeVarTuple` was added in Python 3.15", + kwarg, + ); + } + match self + .infer_expression(&kwarg.value, TypeContext::default()) + .bool(db) + { + Truthiness::AlwaysTrue => covariant = true, + Truthiness::AlwaysFalse => {} + Truthiness::Ambiguous => { + return error( + &self.context, + "The `covariant` parameter of `TypeVarTuple` \ + cannot have an ambiguous truthiness", + &kwarg.value, + ); + } + } + } + "contravariant" => { + if !have_features_from(PythonVersion::PY315) { + error( + &self.context, + "The `contravariant` parameter of `typing.TypeVarTuple` was added in Python 3.15", + kwarg, + ); + } + match self + .infer_expression(&kwarg.value, TypeContext::default()) + .bool(db) + { + Truthiness::AlwaysTrue => contravariant = true, + Truthiness::AlwaysFalse => {} + Truthiness::Ambiguous => { + return error( + &self.context, + "The `contravariant` parameter of `TypeVarTuple` \ + cannot have an ambiguous truthiness", + &kwarg.value, + ); + } + } + } + "infer_variance" => { + if !have_features_from(PythonVersion::PY315) { + error( + &self.context, + "The `infer_variance` parameter of `typing.TypeVarTuple` was added in Python 3.15", + kwarg, + ); + } + match self + .infer_expression(&kwarg.value, TypeContext::default()) + .bool(db) + { + Truthiness::AlwaysTrue => infer_variance = true, + Truthiness::AlwaysFalse => {} + Truthiness::Ambiguous => { + return error( + &self.context, + "The `infer_variance` parameter of `TypeVarTuple` \ + cannot have an ambiguous truthiness", + &kwarg.value, + ); + } + } + } + name => { + error( + &self.context, + format_args!( + "Unknown keyword argument `{name}` in `TypeVarTuple` creation" + ), + kwarg, + ); + self.infer_expression(&kwarg.value, TypeContext::default()); + } + } + } + + let variance = match (covariant, contravariant, infer_variance) { + (true, true, _) => { + return error( + &self.context, + "A `TypeVarTuple` cannot be both covariant and contravariant", + call_expr, + ); + } + (true, false, true) | (false, true, true) => { + return error( + &self.context, + "A `TypeVarTuple` cannot specify variance when `infer_variance=True`", + call_expr, + ); + } + (true, false, false) => Some(TypeVarVariance::Covariant), + (false, true, false) => Some(TypeVarVariance::Contravariant), + (false, false, false) => Some(TypeVarVariance::Invariant), + (false, false, true) => None, + }; + + let Some(name_param_ty) = name_param_ty.or_else(|| { + arguments + .find_positional(0) + .map(|arg| self.infer_expression(arg, TypeContext::default())) + }) else { + return error( + &self.context, + "The `name` parameter of `TypeVarTuple` is required.", + call_expr, + ); + }; + + let Some(name_param) = name_param_ty.as_string_literal().map(|name| name.value(db)) else { + return error( + &self.context, + "The first argument to `TypeVarTuple` must be a string literal", + call_expr, + ); + }; + let name_param_node = name_param_node.or_else(|| arguments.find_positional(0)); + + let ast::Expr::Name(ast::ExprName { + id: target_name, .. + }) = target + else { + return error( + &self.context, + "A `TypeVarTuple` definition must be a simple variable assignment", + target, + ); + }; + + if name_param != target_name { + report_mismatched_type_name( + &self.context, + name_param_node + .map(Ranged::range) + .unwrap_or_else(|| call_expr.range()), + "TypeVarTuple", + target_name, + Some(name_param), + name_param_ty, + ); + } + + if default.is_some() { + self.deferred.insert(definition); + } + + let identity = TypeVarIdentity::new( + db, + target_name.clone(), + Some(definition), + TypeVarKind::LegacyTypeVarTuple, + ); + Type::KnownInstance(KnownInstanceType::TypeVar(TypeVarInstance::new( + db, identity, None, variance, default, + ))) + } + pub(super) fn infer_legacy_paramspec( &mut self, target: &ast::Expr, diff --git a/crates/ty_python_semantic/src/types/infer/comparisons.rs b/crates/ty_python_semantic/src/types/infer/comparisons.rs index 6d3305191d..f507d2e00d 100644 --- a/crates/ty_python_semantic/src/types/infer/comparisons.rs +++ b/crates/ty_python_semantic/src/types/infer/comparisons.rs @@ -626,7 +626,7 @@ pub(super) fn infer_binary_type_comparison<'db>( let mut any_eq = false; let mut any_ambiguous = false; - for ty in rhs_tuple.iter_all_elements() { + for ty in rhs_tuple.iter_element_types(db) { let eq_result = infer_binary_type_comparison( context, left, @@ -1084,7 +1084,7 @@ fn infer_tuple_rich_comparison<'db>( // We use `try_for_each_element_pair` to iterate over all possible pairings. (left @ TupleSpec::Variable(_), right) | (left, right @ TupleSpec::Variable(_)) => { let mut results = SmallVec::<[Type<'db>; 8]>::new(); - left.try_for_each_element_pair(right, |l_ty, r_ty| { + left.try_for_each_element_pair(db, right, |l_ty, r_ty| { results.push(infer_binary_type_comparison( context, l_ty, diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index 295fb74d41..c912aa050e 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -285,6 +285,9 @@ impl<'db> KnownInstanceType<'db> { Self::TypeVar(typevar_instance) if typevar_instance.is_paramspec(db) => { KnownClass::ParamSpec } + Self::TypeVar(typevar_instance) if typevar_instance.is_typevartuple(db) => { + KnownClass::TypeVarTuple + } Self::TypeVar(_) => KnownClass::TypeVar, Self::TypeAliasType(TypeAliasType::PEP695(alias)) if alias.is_specialized(db) => { KnownClass::GenericAlias diff --git a/crates/ty_python_semantic/src/types/list_members.rs b/crates/ty_python_semantic/src/types/list_members.rs index 7e4f4c1270..d2ced82160 100644 --- a/crates/ty_python_semantic/src/types/list_members.rs +++ b/crates/ty_python_semantic/src/types/list_members.rs @@ -409,6 +409,7 @@ impl<'db> AllMembers<'db> { Some( KnownClass::TypeVar | KnownClass::TypeVarTuple + | KnownClass::ExtensionsTypeVarTuple | KnownClass::ParamSpec | KnownClass::UnionType ) diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 58210d5d35..659dff017b 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -625,7 +625,10 @@ impl<'db> MroIterator<'db> { self.subsequent_elements .get_or_insert_with(|| match self.class { ClassLiteral::Static(literal) => { - let mut full_mro_iter = match literal.try_mro(self.db, self.specialization) { + let specialization = self.specialization.map(|specialization| { + specialization.tuple_runtime_element_specialization(self.db) + }); + let mut full_mro_iter = match literal.try_mro(self.db, specialization) { Ok(mro) => mro.iter(), Err(error) => error.fallback_mro().iter(), }; diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index a4aca8077a..d405b3a3d7 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -7,9 +7,7 @@ use crate::subscript::PyIndex; use crate::types::function::KnownFunction; use crate::types::infer::{ExpressionInference, infer_same_file_expression_type}; use crate::types::special_form::TypeQualifier; -use crate::types::tuple::{ - Tuple, TupleLength, TupleSpec, TupleSpecBuilder, TupleType, TupleUnpacker, -}; +use crate::types::tuple::{TupleLength, TupleSpec, TupleSpecBuilder, TupleType, TupleUnpacker}; use crate::types::typed_dict::{ TypedDictField, TypedDictFieldBuilder, TypedDictSchema, TypedDictType, }; @@ -533,7 +531,7 @@ impl ClassInfoConstraintFunction { UnionType::try_from_elements( db, tuple - .iter_all_elements() + .iter_element_types(db) .map(|element| self.generate_constraint(db, element, is_positive)), ) }), @@ -2429,11 +2427,13 @@ impl<'db> PatternSuccessAnalyzer<'db> { let tuple = subject_ty.try_iterate(self.db).unwrap_or_else(|error| { let fallback_element_ty = error.fallback_element_type(self.db); - Cow::Owned(Tuple::homogeneous(if fallback_element_ty.is_unknown() { - Type::object() - } else { - fallback_element_ty - })) + Cow::Owned(TupleSpec::homogeneous( + if fallback_element_ty.is_unknown() { + Type::object() + } else { + fallback_element_ty + }, + )) }); let mut unpacker = TupleUnpacker::new(self.db, target_len); unpacker.unpack_tuple(tuple.as_ref()).ok()?; diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 31418c5d46..5e7f996f06 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -14,6 +14,7 @@ use crate::types::enums::is_single_member_enum; use crate::types::function::FunctionDecorators; use crate::types::set_theoretic::RecursivelyDefined; use crate::types::signatures::{ParametersKind, SignatureRelationVisitor}; +use crate::types::tuple::TupleType; use crate::types::{ ApplyTypeMappingVisitor, CallableType, ClassBase, ClassLiteral, ClassType, CycleDetector, IntersectionType, KnownBoundMethodType, KnownClass, KnownInstanceType, LiteralValueTypeKind, @@ -1398,6 +1399,32 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.check_type_pair(db, instance, Type::TypeVar(type_var)) } + // A TypeVarTuple specialization is represented by one tuple value. Keep inferable + // TypeVarTuples bare for constraint solving, but compare fixed symbolic values using + // the same tuple relation as concrete specializations. + (Type::TypeVar(bound_typevar), target) + if !bound_typevar.is_inferable(db, self.inferable) + && bound_typevar.is_typevartuple(db) + && target.exact_tuple_instance_spec(db).is_some() => + { + self.check_type_pair( + db, + Type::tuple(Some(TupleType::unpacked_typevartuple(db, bound_typevar))), + target, + ) + } + (source, Type::TypeVar(bound_typevar)) + if !bound_typevar.is_inferable(db, self.inferable) + && bound_typevar.is_typevartuple(db) + && source.exact_tuple_instance_spec(db).is_some() => + { + self.check_type_pair( + db, + source, + Type::tuple(Some(TupleType::unpacked_typevartuple(db, bound_typevar))), + ) + } + // A gradual `ParamSpec` value (`...`) is assignability-consistent with any concrete // `ParamSpec` value. This only applies to fixed `ParamSpec` values in already- // specialized generic aliases; inferable `ParamSpec`s are handled by the inference diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index e15e71b801..4d8597d40d 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -32,6 +32,7 @@ use crate::types::infer::{TypeExpressionFlags, infer_deferred_types}; use crate::types::relation::{ HasRelationToVisitor, IsDisjointVisitor, TypeRelation, TypeRelationChecker, TypeVarEvaluation, }; +use crate::types::tuple::{Tuple, TupleType, VariableSegment}; use crate::types::typed_dict::extract_unpacked_typed_dict_keys_from_kwargs_annotation; use crate::types::typevar::max_typevar_freshness_matching_generic_context; use crate::types::{ @@ -2078,6 +2079,20 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } + /// Move to the next target parameter while reusing the current source parameter. + fn next_reusing_source( + &mut self, + ) -> Option, &'a Parameter<'db>>> { + match (self.current_source, self.next_target()) { + (Some(source_param), Some(target_param)) => { + Some(EitherOrBoth::Both(source_param, target_param)) + } + (Some(source_param), None) => Some(EitherOrBoth::Left(source_param)), + (None, Some(target_param)) => Some(EitherOrBoth::Right(target_param)), + (None, None) => None, + } + } + /// Move to the next parameter in the `source` parameter iterator, [`None`] if the /// iterator is exhausted. fn next_source(&mut self) -> Option<&'a Parameter<'db>> { @@ -2115,13 +2130,30 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } + let target_typevartuple = if self.typevar_evaluation == TypeVarEvaluation::Lazy { + target.parameters.variadic().and_then(|(index, parameter)| { + if parameter.has_starred_annotation() + && let Type::TypeVar(typevartuple) = parameter.annotated_type() + && typevartuple.is_typevartuple(db) + { + Some((index, typevartuple)) + } else { + None + } + }) + } else { + None + }; + // Fast path: if the target accepts positional calls that the source cannot accept, reject // without checking return types or individual parameter types. The full parameter // comparison below reaches the same result, but only after doing work that is expensive for - // large overload sets. + // large overload sets. An unpacked target TypeVarTuple bypasses this fast path so it can be + // constrained from the source parameters. if source.parameters.is_standard() && target.parameters.is_standard() && source.parameters.variadic().is_none() + && target_typevartuple.is_none() { let source_positional = source.parameters.positional().count(); let target_positional = target.parameters.positional().count(); @@ -2164,10 +2196,11 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }); } - let mut check_types = |target_ty: Type<'db>, - source_ty: Type<'db>, - target_name: Option<&Name>, - target_index: usize| { + let check_types = |result: &mut ConstraintSet<'db, 'c>, + target_ty: Type<'db>, + source_ty: Type<'db>, + target_name: Option<&Name>, + target_index: usize| { match (target_ty, source_ty) { // This is a special case where the _same_ components of two different `ParamSpec` // type variables are assignable to each other when they're both in an inferable @@ -2335,6 +2368,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2368,6 +2402,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2476,12 +2511,14 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // self: callable without ParamSpec // other: `Concatenate[, P]` (None, Some((target_prefix_params, target_bound_typevar))) => { + let source_parameters = + source.parameters.expand_starred_variadic_annotations(db); // Loop over self parameters and target_prefix_params in a similar manner to the // above loop let mut parameters = ParametersZip { current_source: None, current_target: None, - source_iter: source.parameters.iter(), + source_iter: source_parameters.iter(), target_iter: target_prefix_params.iter(), }; @@ -2516,6 +2553,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2543,6 +2581,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2558,6 +2597,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | ParameterKind::PositionalOrKeyword { .. }, ) => { if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2569,6 +2609,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { while let Some(target_param) = parameters.peek_target() { target_index += 1; if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2680,6 +2721,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2708,6 +2750,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2784,8 +2827,12 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } // If either of the parameter lists is gradual (`...`), then it is assignable to and from - // any other parameter list, but not a subtype or supertype of any other parameter list. - if source.parameters.is_gradual() || target.parameters.is_gradual() { + // any other parameter list, but not a subtype or supertype of any other parameter list. An + // unpacked target TypeVarTuple instead continues to the ordinary parameter comparison so it + // can be constrained from the source parameters. + if target_typevartuple.is_none() + && (source.parameters.is_gradual() || target.parameters.is_gradual()) + { match (source.parameters.kind(), target.parameters.kind()) { // Both parameter lists are `Concatenate` with gradual forms. All prefix parameters // are going to be positional-only. @@ -2804,6 +2851,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { .enumerate() { if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2855,6 +2903,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2916,6 +2965,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2926,6 +2976,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } ParameterKind::Variadic { .. } => { if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2937,6 +2988,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { while let Some(target_param) = parameters.peek_target() { target_index += 1; if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -2976,10 +3028,72 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }; } + // TODO: Normalize starred variadic annotations for all signature comparisons. Restricting + // expansion to target TypeVarTuple inference means equivalent nested unpackings such as + // `*tuple[*tuple[str, ...], bytes]` and `*tuple[str, ...], bytes` are not related correctly. + let source_parameters = if target_typevartuple.is_some() { + source.parameters.expand_starred_variadic_annotations(db) + } else { + source.parameters.clone() + }; + // Align the fixed target prefix and suffix before entering the parameter loop so that the + // target TypeVarTuple captures only the source parameter entries between them. + let typevartuple_source_parameter_count = if let Some((typevartuple_index, _)) = + target_typevartuple + { + let source_positional_len = source_parameters + .iter() + .take_while(|parameter| parameter.is_positional() || parameter.is_variadic()) + .count(); + let target_suffix_len = target.parameters.as_slice()[typevartuple_index + 1..] + .iter() + .take_while(|parameter| parameter.is_positional()) + .count(); + + let source_capture_start = if let Some((source_variadic_index, _)) = source_parameters + .variadic() + .filter(|(index, _)| *index < source_positional_len) + { + let source_suffix_len = source_positional_len - source_variadic_index - 1; + if source_suffix_len < target_suffix_len { + return self.never(); + } + + if source_variadic_index < typevartuple_index { + source_variadic_index + 1 + } else { + typevartuple_index + } + } else { + typevartuple_index + }; + let Some(source_parameter_count) = source_positional_len + .checked_sub(source_capture_start) + .and_then(|len| len.checked_sub(target_suffix_len)) + else { + return self.never(); + }; + Some(source_parameter_count) + } else { + None + }; + let as_target_typevartuple = |parameter: &Parameter<'db>| match ( + target_typevartuple, + typevartuple_source_parameter_count, + ) { + (Some((_, typevartuple)), Some(source_parameter_count)) + if parameter.is_variadic() + && parameter.has_starred_annotation() + && parameter.annotated_type() == Type::TypeVar(typevartuple) => + { + Some(source_parameter_count) + } + _ => None, + }; let mut parameters = ParametersZip { current_source: None, current_target: None, - source_iter: source.parameters.iter(), + source_iter: source_parameters.iter(), target_iter: target.parameters.iter(), }; @@ -2987,9 +3101,15 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // parameter which means that the keyword variant is still unmatched. let mut target_keywords = Vec::new(); let mut target_index = 0usize; + let mut reuse_current_source = false; loop { - let Some(next_parameter) = parameters.next() else { + let next_parameter = if std::mem::take(&mut reuse_current_source) { + parameters.next_reusing_source() + } else { + parameters.next() + }; + let Some(next_parameter) = next_parameter else { if target_keywords.is_empty() { // All parameters have been checked or both the parameter lists were empty. // In either case, `source` is a subtype of `target`. @@ -3002,39 +3122,67 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { }; match next_parameter { - EitherOrBoth::Left(source_parameter) => match source_parameter.kind() { - ParameterKind::KeywordOnly { .. } | ParameterKind::KeywordVariadic { .. } - if !target_keywords.is_empty() => + EitherOrBoth::Left(source_parameter) => { + if source_parameter.is_variadic() + && source_parameter.has_starred_annotation() + && let Type::TypeVar(typevartuple) = source_parameter.annotated_type() + && typevartuple.is_typevartuple(db) { - // If there are any unmatched keyword parameters in `other`, they need to - // be checked against the keyword-only / keyword-variadic parameters that - // will be done after this loop. - break; + return self.never(); } - ParameterKind::PositionalOnly { default_type, .. } - | ParameterKind::PositionalOrKeyword { default_type, .. } - | ParameterKind::KeywordOnly { default_type, .. } => { - // For `source <: target` to be valid, if there are no more parameters in - // `target`, then the non-variadic parameters in `source` must have a default - // value. - if default_type.is_none() { - if let Some(context) = self.report_context() { - let parameter = ParameterDescription::new( - target_index, - source_parameter.name(), - ); - context.push(ErrorContext::ExtraRequiredParameter { parameter }); + match source_parameter.kind() { + ParameterKind::KeywordOnly { .. } + | ParameterKind::KeywordVariadic { .. } + if !target_keywords.is_empty() => + { + // If there are any unmatched keyword parameters in `other`, they need + // to be checked against the keyword-only / keyword-variadic parameters + // that will be done after this loop. + break; + } + ParameterKind::PositionalOnly { default_type, .. } + | ParameterKind::PositionalOrKeyword { default_type, .. } + | ParameterKind::KeywordOnly { default_type, .. } => { + // For `source <: target` to be valid, if there are no more parameters + // in `target`, then the non-variadic parameters in `source` must have a + // default value. + if default_type.is_none() { + if let Some(context) = self.report_context() { + let parameter = ParameterDescription::new( + target_index, + source_parameter.name(), + ); + context + .push(ErrorContext::ExtraRequiredParameter { parameter }); + } + return self.never(); } - return self.never(); + } + ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => { + // Variadic parameters don't have any restrictions in this context, so + // we'll just continue to the next parameter set. } } - ParameterKind::Variadic { .. } | ParameterKind::KeywordVariadic { .. } => { - // Variadic parameters don't have any restrictions in this context, so - // we'll just continue to the next parameter set. + } + + EitherOrBoth::Right(target_parameter) => { + if let Some(source_parameter_count) = as_target_typevartuple(target_parameter) { + if source_parameter_count > 0 { + return self.never(); + } + if !check_types( + &mut result, + target_parameter.annotated_type(), + Type::empty_tuple(db), + target_parameter.name(), + target_index, + ) { + return result; + } + target_index += 1; + continue; } - }, - EitherOrBoth::Right(_) => { // If there are more parameters in `target` than in `source`, then `source` is // not a subtype of `target`. return self.never(); @@ -3060,6 +3208,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -3093,6 +3242,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -3108,6 +3258,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | ParameterKind::PositionalOrKeyword { .. }, ) => { if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -3134,6 +3285,9 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // checked against the variadic parameter in `source`. This loop does // that by only moving the `other` iterator forward. while let Some(target_parameter) = parameters.peek_target() { + if as_target_typevartuple(target_parameter).is_some() { + break; + } match target_parameter.kind() { ParameterKind::PositionalOrKeyword { .. } => { target_keywords.push(target_parameter); @@ -3148,6 +3302,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } target_index += 1; if !check_types( + &mut result, target_parameter.annotated_type(), source_param.annotated_type(), target_parameter.name(), @@ -3159,8 +3314,79 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } - (ParameterKind::Variadic { .. }, ParameterKind::Variadic { .. }) => { + (_, ParameterKind::Variadic { .. }) => { + if let Some(source_parameter_count) = + as_target_typevartuple(target_param) + { + let source_tail = parameters.source_iter.as_slice(); + let captured_source_parameters = || { + std::iter::once(source_param) + .chain(source_tail) + .take(source_parameter_count) + }; + let inferred_tuple = + if let Some((source_variadic_index, source_variadic)) = + captured_source_parameters() + .find_position(|parameter| parameter.is_variadic()) + { + let variable = match source_variadic.annotated_type() { + Type::TypeVar(typevartuple) + if typevartuple.is_typevartuple(db) => + { + VariableSegment::TypeVarTuple(typevartuple) + } + element => VariableSegment::Homogeneous(element), + }; + Type::tuple(TupleType::mixed_with_segment( + db, + captured_source_parameters() + .take(source_variadic_index) + .map(Parameter::annotated_type), + variable, + captured_source_parameters() + .skip(source_variadic_index + 1) + .map(Parameter::annotated_type), + )) + } else { + Type::heterogeneous_tuple( + db, + captured_source_parameters() + .map(Parameter::annotated_type), + ) + }; + + reuse_current_source = source_parameter_count == 0; + for _ in 1..source_parameter_count { + parameters.next_source(); + } + + if !check_types( + &mut result, + target_param.annotated_type(), + inferred_tuple, + target_param.name(), + target_index, + ) { + return result; + } + target_index += 1; + + if source.parameters.is_gradual() { + return match self.relation { + TypeRelation::Assignability => result, + TypeRelation::Subtyping + | TypeRelation::SubtypingAssuming + | TypeRelation::Redundancy { .. } => self.never(), + }; + } + continue; + } + + if !source_param.is_variadic() { + return self.never(); + } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -3277,6 +3503,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); } if !check_types( + &mut result, target_param.annotated_type(), source_param.annotated_type(), target_param.name(), @@ -3291,6 +3518,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } else if let Some(source_keyword_variadic) = source_keyword_variadic { if !check_types( + &mut result, target_param.annotated_type(), source_keyword_variadic, target_param.name(), @@ -3309,6 +3537,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return self.never(); }; if !check_types( + &mut result, target_param.annotated_type(), source_keyword_variadic, target_param.name(), @@ -4034,7 +4263,7 @@ impl<'db> Parameters<'db> { .map(|param| param.apply_type_mapping_impl(db, &type_mapping, tcx, visitor)) .collect(); - Self::new(value, self.data.kind) + Self::new(value, self.data.kind).expand_starred_variadic_annotations(db) } pub(crate) fn len(&self) -> usize { self.data.value.len() @@ -4103,6 +4332,70 @@ impl<'db> Parameters<'db> { .rfind(|(_, parameter)| parameter.is_keyword_variadic()) } + /// Expands an unpacked `*args` annotation into its logical callable parameters. + fn expand_starred_variadic_annotations(&self, db: &'db dyn Db) -> Self { + if !self + .data + .value + .iter() + .any(|parameter| parameter.is_variadic() && parameter.has_starred_annotation()) + { + return self.clone(); + } + + let mut expanded = false; + let mut parameters = Vec::with_capacity(self.data.value.len()); + for parameter in &self.data.value { + if parameter.is_variadic() + && parameter.has_starred_annotation() + && let Some(tuple) = parameter.annotated_type().exact_tuple_instance_spec(db) + { + expanded = true; + match tuple.as_ref() { + Tuple::Fixed(tuple) => { + parameters.extend( + tuple + .iter_all_elements() + .map(|ty| Parameter::positional_only(None).with_annotated_type(ty)), + ); + } + Tuple::Variable(variable) => { + parameters.extend( + variable + .iter_prefix_elements() + .map(|ty| Parameter::positional_only(None).with_annotated_type(ty)), + ); + let name = parameter + .name() + .cloned() + .unwrap_or_else(|| Name::new_static("args")); + parameters.push(Parameter::variadic(name).with_annotated_type( + match variable.variable() { + VariableSegment::Homogeneous(element) => element, + VariableSegment::TypeVarTuple(typevartuple) => { + Type::TypeVar(typevartuple) + } + }, + )); + parameters.extend( + variable + .iter_suffix_elements() + .map(|ty| Parameter::positional_only(None).with_annotated_type(ty)), + ); + } + } + } else { + parameters.push(parameter.clone()); + } + } + + if expanded { + Self::from_annotation(db, parameters) + } else { + self.clone() + } + } + /// Expands adjacent `P.args`/`P.kwargs` placeholders into their mapped parameters. pub(crate) fn expand_paramspec_variadics(&self, db: &'db dyn Db) -> Self { let mut variadic_index = None; @@ -4334,6 +4627,11 @@ impl<'db> Parameter<'db> { self } + pub(crate) fn with_starred_annotation(mut self) -> Self { + self.annotation_kind = ParameterAnnotationKind::Starred; + self + } + pub(crate) fn with_default_type(mut self, default: Type<'db>) -> Self { match &mut self.kind { ParameterKind::PositionalOnly { default_type, .. } @@ -4483,28 +4781,29 @@ impl<'db> Parameter<'db> { let index = semantic_index(db, function_definition.file(db)); let definition = Some(index.expect_single_definition(parameter)); - let (annotated_type, inferred_annotation, has_starred_annotation) = + let (annotated_type, inferred_annotation, annotation_flags, has_starred_annotation) = if let Some(annotation) = parameter.annotation() { ( function_signature_expression_type(db, function_definition, annotation), false, + function_signature_type_expression_flags(db, function_definition, annotation), annotation.is_starred_expr(), ) } else { - (Type::unknown(), true, false) + (Type::unknown(), true, TypeExpressionFlags::empty(), false) }; + let has_unpacked_variadic_annotation = matches!(&kind, ParameterKind::Variadic { .. }) + && annotation_flags.contains(TypeExpressionFlags::UNPACK); let is_unpacked_typed_dict_kwargs = matches!(&kind, ParameterKind::KeywordVariadic { .. }) - && parameter.annotation().is_some_and(|annotation| { - extract_unpacked_typed_dict_keys_from_kwargs_annotation( - db, - annotated_type, - function_signature_type_expression_flags(db, function_definition, annotation), - ) - .is_some() - }); + && extract_unpacked_typed_dict_keys_from_kwargs_annotation( + db, + annotated_type, + annotation_flags, + ) + .is_some(); let annotation_kind = if is_unpacked_typed_dict_kwargs { ParameterAnnotationKind::UnpackedTypedDictKwargs - } else if has_starred_annotation { + } else if has_starred_annotation || has_unpacked_variadic_annotation { ParameterAnnotationKind::Starred } else { ParameterAnnotationKind::Normal @@ -4588,8 +4887,9 @@ impl<'db> Parameter<'db> { self.definition } - /// Return `true` if this parameter has a starred annotation, - /// e.g. `*args: *Ts` or `*args: *tuple[int, *tuple[str, ...], bytes]` + /// Return `true` if this parameter has an unpacked variadic annotation, + /// e.g. `*args: *Ts`, `*args: Unpack[Ts]`, or + /// `*args: *tuple[int, *tuple[str, ...], bytes]`. pub(crate) fn has_starred_annotation(&self) -> bool { matches!(self.annotation_kind, ParameterAnnotationKind::Starred) } diff --git a/crates/ty_python_semantic/src/types/subscript.rs b/crates/ty_python_semantic/src/types/subscript.rs index 57a99afb1b..251f9bd6f6 100644 --- a/crates/ty_python_semantic/src/types/subscript.rs +++ b/crates/ty_python_semantic/src/types/subscript.rs @@ -23,8 +23,8 @@ use super::infer::TypeContext; use super::instance::SliceLiteral; use super::special_form::SpecialFormType; use super::{ - DynamicType, IntersectionBuilder, IntersectionType, KnownInstanceType, Type, TypeAliasType, - TypedDictType, UnionBuilder, UnionType, todo_type, + IntersectionBuilder, IntersectionType, KnownInstanceType, Type, TypeAliasType, TypedDictType, + UnionBuilder, UnionType, todo_type, }; /// The kind of subscriptable type that had an out-of-bounds index. @@ -144,6 +144,8 @@ pub(crate) enum SubscriptErrorKind<'db> { }, /// A `TypeVarTuple` was provided to `Generic` or `Protocol` without being unpacked. TypeVarTupleNotUnpacked { origin: LegacyGenericOrigin }, + /// More than one `TypeVarTuple` was provided to `Generic` or `Protocol`. + MultipleTypeVarTuples { origin: LegacyGenericOrigin }, } impl<'db> SubscriptError<'db> { @@ -351,6 +353,13 @@ impl<'db> SubscriptErrorKind<'db> { )); } } + Self::MultipleTypeVarTuples { origin } => { + if let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, subscript) { + builder.into_diagnostic(format_args!( + "Only one `TypeVarTuple` parameter is allowed in a `{origin}` subscription", + )); + } + } } } @@ -781,7 +790,8 @@ impl<'db> Type<'db> { } (Type::SpecialForm(SpecialFormType::Unpack), _) => { - Some(Ok(Type::Dynamic(DynamicType::TodoUnpack))) + // TODO: Emit an invalid-type-form diagnostic for runtime subscripting of `Unpack`. + Some(Ok(Type::unknown())) } (Type::SpecialForm(SpecialFormType::TypeQualifier(TypeQualifier::InitVar)), _) => { diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index 31c4893224..cdfe8d19c1 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -21,14 +21,14 @@ use std::hash::Hash; use std::num::{NonZeroI32, NonZeroUsize}; use itertools::{Either, EitherOrBoth, Itertools}; -use smallvec::{SmallVec, smallvec_inline}; +use smallvec::SmallVec; use crate::subscript::{ Nth, OutOfBoundsError, PyIndex, PySlice, StepSizeZeroError, py_slice_with_step, }; use crate::types::class::{ClassType, KnownClass}; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; -use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; +use crate::types::relation::{DisjointnessChecker, TypeRelationChecker, TypeVarEvaluation}; use crate::types::set_theoretic::RecursivelyDefined; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, ErrorContext, FindLegacyTypeVarsVisitor, @@ -141,8 +141,26 @@ pub(super) fn walk_tuple_type<'db, V: super::visitor::TypeVisitor<'db> + ?Sized> tuple: TupleType<'db>, visitor: &V, ) { - for element in tuple.tuple(db).iter_all_elements() { - visitor.visit_type(db, element); + match tuple.tuple(db) { + Tuple::Fixed(tuple) => { + for element in tuple.iter_all_elements() { + visitor.visit_type(db, element); + } + } + Tuple::Variable(tuple) => { + for element in tuple.iter_prefix_elements() { + visitor.visit_type(db, element); + } + match tuple.variable() { + VariableSegment::Homogeneous(element) => visitor.visit_type(db, element), + VariableSegment::TypeVarTuple(typevartuple) => { + visitor.visit_type(db, Type::TypeVar(typevartuple)); + } + } + for element in tuple.iter_suffix_elements() { + visitor.visit_type(db, element); + } + } } } @@ -160,15 +178,15 @@ impl<'db> TupleType<'db> { // If the variable-length portion is Never, it can only be instantiated with zero elements. // That means this isn't a variable-length tuple after all! - if let TupleSpec::Variable(tuple) = spec { - if tuple.variable().is_never() { - let tuple = TupleSpec::Fixed(FixedLengthTuple::from_elements( - tuple - .iter_prefix_elements() - .chain(tuple.iter_suffix_elements()), - )); - return Some(TupleType::new_internal::<_, TupleSpec<'db>>(db, tuple)); - } + if let TupleSpec::Variable(tuple) = spec + && matches!(tuple.variable(), VariableSegment::Homogeneous(Type::Never)) + { + let tuple = TupleSpec::Fixed(FixedLengthTuple::from_elements( + tuple + .iter_prefix_elements() + .chain(tuple.iter_suffix_elements()), + )); + return Some(TupleType::new_internal::<_, TupleSpec<'db>>(db, tuple)); } Some(TupleType::new_internal(db, spec)) @@ -190,6 +208,15 @@ impl<'db> TupleType<'db> { prefix: impl IntoIterator>, variable: Type<'db>, suffix: impl IntoIterator>, + ) -> Option { + Self::mixed_with_segment(db, prefix, VariableSegment::Homogeneous(variable), suffix) + } + + pub(crate) fn mixed_with_segment( + db: &'db dyn Db, + prefix: impl IntoIterator>, + variable: VariableSegment<'db>, + suffix: impl IntoIterator>, ) -> Option { TupleType::new(db, &VariableLengthTuple::mixed(prefix, variable, suffix)) } @@ -201,6 +228,18 @@ impl<'db> TupleType<'db> { } } + /// Packs a `TypeVarTuple` into the tuple value used for generic specialization relations. + pub(crate) fn unpacked_typevartuple( + db: &'db dyn Db, + typevar: BoundTypeVarInstance<'db>, + ) -> Self { + debug_assert!(typevar.is_typevartuple(db)); + TupleType::new_internal( + db, + VariableLengthTuple::mixed([], VariableSegment::TypeVarTuple(typevar), []), + ) + } + // N.B. If this method is not Salsa-tracked, we take 10 minutes to check // `static-frame` as part of the ecosystem analysis. This is because it's called // from `NominalInstanceType::class()`, which is a very hot method. @@ -212,7 +251,7 @@ impl<'db> TupleType<'db> { tuple_class.apply_specialization(db, |generic_context| { if generic_context.variables(db).len() == 1 { - let element_type = self.tuple(db).homogeneous_element_type(db); + let element_type = self.tuple(db).tuple_class_type(db); generic_context.specialize_tuple(db, element_type, self) } else { generic_context.default_specialization(db, Some(KnownClass::Tuple)) @@ -366,13 +405,23 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } - // In addition, any remaining elements in this tuple must satisfy the - // variable-length portion of the other tuple. - result.and(db, self.constraints, || { - source_iter.when_all(db, self.constraints, |&source_ty| { - self.check_type_pair(db, source_ty, target.variable()) - }) - }) + match target.variable() { + VariableSegment::TypeVarTuple(typevartuple) => { + let packed = Type::heterogeneous_tuple(db, source_iter.copied()); + result.and(db, self.constraints, || { + self.check_type_pair(db, packed, Type::TypeVar(typevartuple)) + }) + } + VariableSegment::Homogeneous(target_ty) => { + // In addition, any remaining elements in this tuple must satisfy the + // variable-length portion of the other tuple. + result.and(db, self.constraints, || { + source_iter.when_all(db, self.constraints, |&source_ty| { + self.check_type_pair(db, source_ty, target_ty) + }) + }) + } + } } } } @@ -380,7 +429,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { fn check_variable_length_vs_tuple_spec( &self, db: &'db dyn Db, - source: &VariableLengthTuple>, + source: &VariableLengthTuple, VariableSegment<'db>>, target: &TupleSpec<'db>, ) -> ConstraintSet<'db, 'c> { match target { @@ -395,7 +444,12 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // (or any other dynamic type), then the `...` is the _gradual choice_ of all // possible lengths. This means that `tuple[Any, ...]` can match any tuple of any // length. - if !self.is_eager_assignability() || !source.variable().is_dynamic() { + let VariableSegment::Homogeneous(source_variable) = source.variable() else { + // Unlike a dynamic homogeneous segment, a symbolic type variable tuple ranges + // over all specializations rather than making a gradual choice of length. + return self.never(); + }; + if !self.is_eager_assignability() || !source_variable.is_dynamic() { return self.never(); } @@ -434,14 +488,86 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } Tuple::Variable(target) => { + if let ( + VariableSegment::TypeVarTuple(source_typevartuple), + VariableSegment::TypeVarTuple(target_typevartuple), + ) = (source.variable(), target.variable()) + && source_typevartuple.is_same_typevar_as(db, target_typevartuple) + { + if source.prefix_len() != target.prefix_len() + || source.suffix_len() != target.suffix_len() + { + return self.never(); + } + + return source + .prefix_elements() + .iter() + .zip(target.prefix_elements()) + .chain( + source + .suffix_elements() + .iter() + .zip(target.suffix_elements()), + ) + .when_all(db, self.constraints, |(&source_ty, &target_ty)| { + self.check_type_pair(db, source_ty, target_ty) + }); + } + + if self.typevar_evaluation == TypeVarEvaluation::Lazy + && let VariableSegment::TypeVarTuple(typevartuple) = target.variable() + { + let source_prefix = source.prefix_elements(); + let source_suffix = source.suffix_elements(); + let target_prefix = target.prefix_elements(); + let target_suffix = target.suffix_elements(); + if source_prefix.len() < target_prefix.len() + || source_suffix.len() < target_suffix.len() + { + return self.never(); + } + + let source_suffix_start = source_suffix.len() - target_suffix.len(); + let boundary_constraints = source_prefix + .iter() + .zip(target_prefix) + .chain( + source_suffix[source_suffix_start..] + .iter() + .zip(target_suffix), + ) + .when_all(db, self.constraints, |(&source_ty, &target_ty)| { + self.check_type_pair(db, source_ty, target_ty) + }); + + let packed = Type::tuple(TupleType::new( + db, + &VariableLengthTuple::mixed( + source_prefix[target_prefix.len()..].iter().copied(), + source.variable(), + source_suffix[..source_suffix_start].iter().copied(), + ), + )); + return boundary_constraints.and(db, self.constraints, || { + self.check_type_pair(db, packed, Type::TypeVar(typevartuple)) + }); + } + + if matches!(target.variable(), VariableSegment::TypeVarTuple(_)) { + return self.never(); + } + // When prenormalizing below, we assume that a dynamic variable-length portion of // one tuple materializes to the variable-length portion of the other tuple. + let source_variable = source.variable().element_type(db); + let target_variable = target.variable().element_type(db); let source_prenormalize_variable = match source.variable() { - Type::Dynamic(_) => Some(target.variable()), + VariableSegment::Homogeneous(Type::Dynamic(_)) => Some(target_variable), _ => None, }; let target_prenormalize_variable = match target.variable() { - Type::Dynamic(_) => Some(source.variable()), + VariableSegment::Homogeneous(Type::Dynamic(_)) => Some(source_variable), _ => None, }; @@ -460,17 +586,17 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.check_type_pair(db, self_ty, other_ty) } EitherOrBoth::Left(self_ty) => { - self.check_type_pair(db, self_ty, target.variable()) + self.check_type_pair(db, self_ty, target_variable) } EitherOrBoth::Right(other_ty) => { // The rhs has a required element that the lhs is not guaranteed to // provide, unless the lhs has a dynamic variable-length portion // that can materialize to provide it (for assignability only), // as in `tuple[Any, ...]` matching `tuple[int, int]`. - if !self.is_eager_assignability() || !source.variable().is_dynamic() { + if !self.is_eager_assignability() || !source_variable.is_dynamic() { return self.never(); } - self.check_type_pair(db, source.variable(), other_ty) + self.check_type_pair(db, source_variable, other_ty) } }; if result @@ -497,17 +623,17 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { self.check_type_pair(db, source_ty, target_ty) } EitherOrBoth::Left(&source_ty) => { - self.check_type_pair(db, source_ty, target.variable()) + self.check_type_pair(db, source_ty, target_variable) } EitherOrBoth::Right(&target_ty) => { // The rhs has a required element that the lhs is not guaranteed to // provide, unless the lhs has a dynamic variable-length portion // that can materialize to provide it (for assignability only), // as in `tuple[Any, ...]` matching `tuple[int, int]`. - if !self.is_eager_assignability() || !source.variable().is_dynamic() { + if !self.is_eager_assignability() || !source_variable.is_dynamic() { return self.never(); } - self.check_type_pair(db, source.variable(), target_ty) + self.check_type_pair(db, source_variable, target_ty) } }; if result @@ -520,7 +646,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // And lastly, the variable-length portions must satisfy the relation. result.and(db, self.constraints, || { - self.check_type_pair(db, source.variable(), target.variable()) + self.check_type_pair(db, source_variable, target_variable) }) } } @@ -619,7 +745,54 @@ fn to_class_type_cycle_initial<'db>( /// Tuple specs are used for more than just `tuple` instances, so they allow `Never` to appear as a /// fixed-length element type. [`TupleType`] adds that additional invariant (since a tuple that /// must contain an element that can't be instantiated, can't be instantiated itself). -pub(crate) type TupleSpec<'db> = Tuple>; +pub(crate) type TupleSpec<'db> = Tuple, VariableSegment<'db>>; + +/// The variable-length portion of a [`TupleSpec`]. +/// +/// For example, `tuple[str, *tuple[int, ...], bytes]` has a homogeneous `int` segment, while +/// `tuple[str, *Ts, bytes]` has a `TypeVarTuple` segment for `Ts`. The fixed `str` prefix and +/// `bytes` suffix are stored separately by [`VariableLengthTuple`]. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub enum VariableSegment<'db> { + /// A segment whose elements all have the same type, such as `int` in `tuple[int, ...]`. + Homogeneous(Type<'db>), + /// An unpacked type variable tuple, such as `Ts` in `tuple[*Ts]`. + TypeVarTuple(BoundTypeVarInstance<'db>), +} + +impl<'db> VariableSegment<'db> { + pub(crate) const fn homogeneous_type(self) -> Option> { + match self { + Self::Homogeneous(element) => Some(element), + Self::TypeVarTuple(_) => None, + } + } + + pub(crate) const fn typevartuple(self) -> Option> { + match self { + Self::Homogeneous(_) => None, + Self::TypeVarTuple(typevartuple) => Some(typevartuple), + } + } + + pub(crate) fn element_type(self, _db: &'db dyn Db) -> Type<'db> { + match self { + Self::Homogeneous(element) => element, + Self::TypeVarTuple(_) => Type::object(), + } + } + + /// Returns the type used for the builtin tuple class's single generic parameter. + /// + /// Preserve the `TypeVarTuple` here so that variance inference and generic-context traversal + /// can still observe it. Runtime element operations must use [`Self::element_type`] instead. + fn tuple_class_type(self) -> Type<'db> { + match self { + Self::Homogeneous(element) => element, + Self::TypeVarTuple(typevartuple) => Type::TypeVar(typevartuple), + } + } +} /// A fixed-length tuple. /// @@ -656,10 +829,6 @@ impl FixedLengthTuple { self.0.iter().copied() } - pub(crate) fn into_all_elements_with_kind(self) -> impl Iterator> { - self.0.into_iter().map(TupleElement::Fixed) - } - /// Returns the length of this tuple. pub(crate) fn len(&self) -> usize { self.0.len() @@ -671,7 +840,7 @@ impl<'db> FixedLengthTuple> { &self, db: &'db dyn Db, new_length: TupleLength, - ) -> Result>, ResizeTupleError> { + ) -> Result, ResizeTupleError> { match new_length { TupleLength::Fixed(new_length) => match self.len().cmp(&new_length) { Ordering::Less => Err(ResizeTupleError::TooFewValues), @@ -692,7 +861,11 @@ impl<'db> FixedLengthTuple> { let variable = UnionType::from_elements_leave_aliases(db, elements.by_ref().take(variable)); let suffix = elements.by_ref().take(suffix); - Ok(VariableLengthTuple::mixed(prefix, variable, suffix)) + Ok(VariableLengthTuple::mixed( + prefix, + VariableSegment::Homogeneous(variable), + suffix, + )) } } } @@ -745,7 +918,7 @@ impl<'db> FixedLengthTuple> { None => Either::Right(std::iter::repeat(TypeContext::default())), Some(tuple) => Either::Left( tuple - .iter_all_elements() + .iter_element_types(db) .map(|tcx| TypeContext::new(Some(tcx))), ), }; @@ -799,37 +972,41 @@ impl<'db> PySlice<'db> for FixedLengthTuple> { /// A variable-length tuple. /// -/// The tuple can contain a fixed-length heterogeneous prefix and/or suffix. All of the elements of -/// the variable-length portion must be the same. +/// The tuple can contain a fixed-length heterogeneous prefix and/or suffix. The variable-length +/// portion is described by `V`; for [`TupleSpec`], it is either homogeneous or an unpacked +/// `TypeVarTuple`. /// /// Our tuple representation can hold instances of any Rust type. For tuples containing Python /// types, use [`TupleSpec`], which defines some additional type-specific methods. #[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -pub struct VariableLengthTuple { - pub(crate) elements: smallvec::SmallVec<[T; 1]>, - variable_index: usize, +pub struct VariableLengthTuple { + /// Fixed prefix elements followed by fixed suffix elements. + fixed_elements: smallvec::SmallVec<[T; 0]>, + /// The number of elements in `fixed_elements` that belong to the prefix. + prefix_len: usize, + /// The variable-length portion between the fixed prefix and suffix. + variable_segment: V, } -impl VariableLengthTuple { - /// Creates a new tuple spec containing zero or more elements of a given type, with no prefix - /// or suffix. - const fn homogeneous(ty: T) -> Self { - let elements = smallvec_inline![ty]; +impl VariableLengthTuple { + /// Creates a new tuple spec consisting only of the given variable-length segment. + const fn homogeneous(variable: V) -> Self { Self { - elements, - variable_index: 0, + fixed_elements: SmallVec::new_const(), + prefix_len: 0, + variable_segment: variable, } } fn mixed( prefix: impl IntoIterator, - variable: T, + variable: V, suffix: impl IntoIterator, - ) -> Tuple { + ) -> Tuple { Tuple::Variable(Self::new(prefix, variable, suffix)) } - fn try_new(prefix: P, variable: T, suffix: S) -> Option + fn try_new(prefix: P, variable: V, suffix: S) -> Option where P: IntoIterator>, P::IntoIter: ExactSizeIterator, @@ -839,78 +1016,73 @@ impl VariableLengthTuple { let prefix = prefix.into_iter(); let suffix = suffix.into_iter(); - let mut elements = - SmallVec::with_capacity(prefix.len().saturating_add(suffix.len()).saturating_add(1)); + let mut fixed_elements = SmallVec::with_capacity(prefix.len().saturating_add(suffix.len())); for element in prefix { - elements.push(element?); + fixed_elements.push(element?); } - let variable_index = elements.len(); - elements.push(variable); + let prefix_len = fixed_elements.len(); for element in suffix { - elements.push(element?); + fixed_elements.push(element?); } - elements.shrink_to_fit(); + fixed_elements.shrink_to_fit(); Some(Self { - elements, - variable_index, + fixed_elements, + prefix_len, + variable_segment: variable, }) } fn new( prefix: impl IntoIterator, - variable: T, + variable: V, suffix: impl IntoIterator, ) -> Self { - let mut elements = SmallVec::new_const(); - elements.extend(prefix); + let mut fixed_elements = SmallVec::new_const(); + fixed_elements.extend(prefix); - let variable_index = elements.len(); - elements.push(variable); - elements.extend(suffix); - elements.shrink_to_fit(); + let prefix_len = fixed_elements.len(); + fixed_elements.extend(suffix); + fixed_elements.shrink_to_fit(); Self { - elements, - variable_index, + fixed_elements, + prefix_len, + variable_segment: variable, } } - fn new_from_vec(prefix: Vec, variable: T, suffix: Vec) -> Self { - let mut elements = SmallVec::from_vec(prefix); + fn new_from_vec(prefix: Vec, variable: V, suffix: Vec) -> Self { + let mut fixed_elements = SmallVec::from_vec(prefix); - let variable_index = elements.len(); - elements.push(variable); - elements.extend(suffix); - elements.shrink_to_fit(); + let prefix_len = fixed_elements.len(); + fixed_elements.extend(suffix); + fixed_elements.shrink_to_fit(); Self { - elements, - variable_index, + fixed_elements, + prefix_len, + variable_segment: variable, } } - pub(crate) fn variable(&self) -> T + pub(crate) fn variable(&self) -> V where - T: Copy, + V: Copy, { - self.elements[self.variable_index] + self.variable_segment } - pub(crate) fn variable_element(&self) -> &T { - &self.elements[self.variable_index] - } - - pub(crate) fn variable_element_mut(&mut self) -> &mut T { - &mut self.elements[self.variable_index] + pub(crate) fn variable_element_mut(&mut self) -> &mut V { + &mut self.variable_segment } pub(crate) fn prefix_elements(&self) -> &[T] { - &self.elements[..self.variable_index] + &self.fixed_elements[..self.prefix_len] } pub(crate) fn iter_prefix_elements(&self) -> impl DoubleEndedIterator @@ -921,11 +1093,11 @@ impl VariableLengthTuple { } pub(crate) fn prefix_elements_mut(&mut self) -> &mut [T] { - &mut self.elements[..self.variable_index] + &mut self.fixed_elements[..self.prefix_len] } pub(crate) fn suffix_elements(&self) -> &[T] { - &self.elements[self.suffix_offset()..] + &self.fixed_elements[self.prefix_len..] } pub(crate) fn iter_suffix_elements(&self) -> impl DoubleEndedIterator @@ -936,39 +1108,36 @@ impl VariableLengthTuple { } pub(crate) fn suffix_elements_mut(&mut self) -> &mut [T] { - let suffix_offset = self.suffix_offset(); - &mut self.elements[suffix_offset..] - } - - fn suffix_offset(&self) -> usize { - self.variable_index + 1 + &mut self.fixed_elements[self.prefix_len..] } fn fixed_elements(&self) -> impl Iterator + '_ { - self.prefix_elements().iter().chain(self.suffix_elements()) + self.fixed_elements.iter() } - fn all_elements(&self) -> &[T] { - &self.elements - } + fn into_all_elements_with_kind(self) -> impl Iterator> { + let mut fixed_elements = self.fixed_elements.into_iter(); + let mut remaining_prefix = self.prefix_len; + let mut variable = Some(self.variable_segment); - fn into_all_elements_with_kind(self) -> impl Iterator> { - self.elements - .into_iter() - .enumerate() - .map(move |(i, element)| match i.cmp(&self.variable_index) { - Ordering::Less => TupleElement::Prefix(element), - Ordering::Equal => TupleElement::Variable(element), - Ordering::Greater => TupleElement::Suffix(element), - }) + std::iter::from_fn(move || { + if remaining_prefix > 0 { + remaining_prefix -= 1; + return fixed_elements.next().map(TupleElement::Prefix); + } + if let Some(variable) = variable.take() { + return Some(TupleElement::Variable(variable)); + } + fixed_elements.next().map(TupleElement::Suffix) + }) } fn prefix_len(&self) -> usize { - self.variable_index + self.prefix_len } fn suffix_len(&self) -> usize { - self.elements.len() - self.suffix_offset() + self.fixed_elements.len() - self.prefix_len } fn len(&self) -> TupleLength { @@ -1010,10 +1179,21 @@ enum FixedPositionOrigin { Back, } +/// How the source tuple's variable segment contributes to the slice. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VariableSliceKind { + /// The variable segment does not contribute to the slice. + Excluded, + /// The variable segment contributes its runtime element type to a homogeneous approximation. + ElementType, + /// The complete variable segment is retained in its original order. + Preserved, +} + /// The elements folded into the variable part of a sliced variable-length tuple. #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct VariableSlice { - include_variable: bool, + kind: VariableSliceKind, suffix_start: usize, suffix_stop: usize, } @@ -1096,7 +1276,7 @@ impl FixedPositionSlice { } impl ForwardSliceStop { - fn suffix_stop(self, tuple: &VariableLengthTuple>) -> usize { + fn suffix_stop(self, tuple: &VariableLengthTuple, VariableSegment<'_>>) -> usize { match self { ForwardSliceStop::End => tuple.suffix_len(), ForwardSliceStop::Absolute(stop) => stop @@ -1114,7 +1294,7 @@ impl ForwardSliceStop { impl VariableSlice { fn variable_only() -> Self { Self { - include_variable: true, + kind: VariableSliceKind::Preserved, suffix_start: 0, suffix_stop: 0, } @@ -1122,30 +1302,41 @@ impl VariableSlice { fn suffix(start: usize, stop: usize) -> Option { (start < stop).then_some(Self { - include_variable: false, + kind: VariableSliceKind::Excluded, suffix_start: start, suffix_stop: stop, }) } - fn ty<'db>(self, db: &'db dyn Db, tuple: &VariableLengthTuple>) -> Type<'db> { + fn ty<'db>( + self, + db: &'db dyn Db, + tuple: &VariableLengthTuple, VariableSegment<'db>>, + ) -> Type<'db> { UnionType::from_elements_leave_aliases( db, - self.include_variable - .then_some(tuple.variable()) - .into_iter() - .chain( - tuple - .iter_suffix_elements() - .skip(self.suffix_start) - .take(self.suffix_stop.saturating_sub(self.suffix_start)), - ), + matches!( + self.kind, + VariableSliceKind::ElementType | VariableSliceKind::Preserved + ) + .then_some(tuple.variable().element_type(db)) + .into_iter() + .chain( + tuple + .iter_suffix_elements() + .skip(self.suffix_start) + .take(self.suffix_stop.saturating_sub(self.suffix_start)), + ), ) } } impl VariableTupleSlicePlan { - fn into_type<'db>(self, db: &'db dyn Db, tuple: &VariableLengthTuple>) -> Type<'db> { + fn into_type<'db>( + self, + db: &'db dyn Db, + tuple: &VariableLengthTuple, VariableSegment<'db>>, + ) -> Type<'db> { match self { VariableTupleSlicePlan::Empty => { Type::heterogeneous_tuple(db, std::iter::empty::>()) @@ -1159,12 +1350,28 @@ impl VariableTupleSlicePlan { fixed_prefix, variable, fixed_suffix, - } => Type::tuple(TupleType::mixed( - db, - VariableLengthTuple::optional_fixed_slice(tuple.prefix_elements(), fixed_prefix), - variable.ty(db, tuple), - VariableLengthTuple::optional_fixed_slice(tuple.suffix_elements(), fixed_suffix), - )), + } => { + let variable_segment = match variable.kind { + VariableSliceKind::Preserved => tuple.variable(), + VariableSliceKind::Excluded | VariableSliceKind::ElementType => { + VariableSegment::Homogeneous(variable.ty(db, tuple)) + } + }; + Type::tuple(TupleType::new( + db, + &VariableLengthTuple::mixed( + VariableLengthTuple::optional_fixed_slice( + tuple.prefix_elements(), + fixed_prefix, + ), + variable_segment, + VariableLengthTuple::optional_fixed_slice( + tuple.suffix_elements(), + fixed_suffix, + ), + ), + )) + } VariableTupleSlicePlan::Homogeneous => tuple.homogeneous_type(db), } @@ -1208,7 +1415,7 @@ impl TupleSliceDirection { } } -impl<'db> VariableLengthTuple> { +impl<'db> VariableLengthTuple, VariableSegment<'db>> { fn optional_fixed_slice<'a>( elements: &'a [Type<'db>], slice: Option, @@ -1323,10 +1530,12 @@ impl<'db> VariableLengthTuple> { }) } - fn reversed(&self) -> Self { + fn reversed(&self, db: &'db dyn Db) -> Self { + // Reversing a `TypeVarTuple` changes its element order, so the result can no longer use + // the original symbolic segment. Self::new( self.iter_suffix_elements().rev(), - self.variable(), + VariableSegment::Homogeneous(self.variable().element_type(db)), self.iter_prefix_elements().rev(), ) } @@ -1357,7 +1566,7 @@ impl<'db> VariableLengthTuple> { .forward_slice_plan(start, stop, step) .into_type(db, self), TupleSliceDirection::Backward => { - let reversed = self.reversed(); + let reversed = self.reversed(db); reversed .forward_slice_plan( TupleSliceDirection::reverse_bound(start), @@ -1584,7 +1793,7 @@ impl<'db> VariableLengthTuple> { fixed_prefix: prefix_start .and_then(|prefix_start| self.fixed_prefix_slice(Some(prefix_start), None, step)), variable: VariableSlice { - include_variable: true, + kind: VariableSliceKind::ElementType, suffix_start: 0, suffix_stop: suffix_stop.unwrap_or_else(|| self.suffix_len()), }, @@ -1604,7 +1813,7 @@ impl<'db> VariableLengthTuple> { return VariableTupleSlicePlan::Mixed { fixed_prefix: self.fixed_prefix_slice(Some(start), None, step), variable: VariableSlice { - include_variable: true, + kind: VariableSliceKind::ElementType, suffix_start: 0, suffix_stop, }, @@ -1622,7 +1831,7 @@ impl<'db> VariableLengthTuple> { VariableTupleSlicePlan::Mixed { fixed_prefix: None, variable: VariableSlice { - include_variable: true, + kind: VariableSliceKind::ElementType, suffix_start: 0, suffix_stop: variable_suffix_stop, }, @@ -1702,19 +1911,28 @@ impl<'db> VariableLengthTuple> { db, self.iter_prefix_elements() .skip(self.prefix_len() - prefix_and_variable_len) - .chain(std::iter::once(self.variable())), + .chain(std::iter::once(self.variable().element_type(db))), )) } + fn iter_all_elements( + &self, + db: &'db dyn Db, + ) -> impl DoubleEndedIterator> + '_ { + self.iter_prefix_elements() + .chain(std::iter::once(self.variable().element_type(db))) + .chain(self.iter_suffix_elements()) + } + fn homogeneous_type(&self, db: &'db dyn Db) -> Type<'db> { - let element = UnionType::from_elements_leave_aliases(db, self.all_elements()); + let element = UnionType::from_elements_leave_aliases(db, self.iter_all_elements(db)); Type::homogeneous_tuple(db, element) } fn variable_and_suffix_type(&self, db: &'db dyn Db, suffix_stop: Option) -> Type<'db> { UnionType::from_elements_leave_aliases( db, - std::iter::once(self.variable()).chain( + std::iter::once(self.variable().element_type(db)).chain( self.iter_suffix_elements() .take(suffix_stop.unwrap_or_else(|| self.suffix_len())), ), @@ -1745,7 +1963,7 @@ impl<'db> VariableLengthTuple> { db: &'db dyn Db, variable: Option>, ) -> impl Iterator> + 'a { - let variable = variable.unwrap_or(self.variable()); + let variable = variable.unwrap_or_else(|| self.variable().element_type(db)); self.iter_prefix_elements().chain( self.iter_suffix_elements() .take_while(move |element| element.is_equivalent_to(db, variable)), @@ -1776,7 +1994,7 @@ impl<'db> VariableLengthTuple> { db: &'db dyn Db, variable: Option>, ) -> impl Iterator> + 'a { - let variable = variable.unwrap_or(self.variable()); + let variable = variable.unwrap_or_else(|| self.variable().element_type(db)); self.iter_suffix_elements() .skip_while(move |element| element.is_equivalent_to(db, variable)) } @@ -1785,7 +2003,7 @@ impl<'db> VariableLengthTuple> { &self, db: &'db dyn Db, new_length: TupleLength, - ) -> Result>, ResizeTupleError> { + ) -> Result, ResizeTupleError> { match new_length { TupleLength::Fixed(new_length) => { // The number of elements that will get their value from our variable-length @@ -1795,7 +2013,10 @@ impl<'db> VariableLengthTuple> { }; Ok(Tuple::Fixed(FixedLengthTuple::from_elements( (self.iter_prefix_elements()) - .chain(std::iter::repeat_n(self.variable(), variable_count)) + .chain(std::iter::repeat_n( + self.variable().element_type(db), + variable_count, + )) .chain(self.iter_suffix_elements()), ))) } @@ -1818,14 +2039,18 @@ impl<'db> VariableLengthTuple> { db, self.iter_prefix_elements() .skip(prefix_length) - .chain(std::iter::once(self.variable())) + .chain(std::iter::once(self.variable().element_type(db))) .chain(self.iter_suffix_elements().take(suffix_overflow)), ); let prefix = (self.iter_prefix_elements().take(prefix_length)) .chain(std::iter::repeat_n(variable, prefix_underflow)); let suffix = std::iter::repeat_n(variable, suffix_underflow) .chain(self.iter_suffix_elements().skip(suffix_overflow)); - Ok(VariableLengthTuple::mixed(prefix, variable, suffix)) + Ok(VariableLengthTuple::mixed( + prefix, + VariableSegment::Homogeneous(variable), + suffix, + )) } } } @@ -1842,33 +2067,44 @@ impl<'db> VariableLengthTuple> { .iter() .map(|ty| ty.recursive_type_normalized_impl(db, div, true)); - let variable = self - .variable() - .recursive_type_normalized_impl(db, div, true)?; + let variable_segment = match self.variable() { + VariableSegment::Homogeneous(variable) => VariableSegment::Homogeneous( + variable.recursive_type_normalized_impl(db, div, true)?, + ), + VariableSegment::TypeVarTuple(typevartuple) => { + VariableSegment::TypeVarTuple(typevartuple) + } + }; let suffix = self .suffix_elements() .iter() .map(|ty| ty.recursive_type_normalized_impl(db, div, true)); - Self::try_new(prefix, variable, suffix) + Self::try_new(prefix, variable_segment, suffix) } else { let prefix = self.prefix_elements().iter().map(|ty| { ty.recursive_type_normalized_impl(db, div, true) .unwrap_or(div) }); - let variable = self - .variable() - .recursive_type_normalized_impl(db, div, true) - .unwrap_or(div); + let variable_segment = match self.variable() { + VariableSegment::Homogeneous(variable) => VariableSegment::Homogeneous( + variable + .recursive_type_normalized_impl(db, div, true) + .unwrap_or(div), + ), + VariableSegment::TypeVarTuple(typevartuple) => { + VariableSegment::TypeVarTuple(typevartuple) + } + }; let suffix = self.suffix_elements().iter().map(|ty| { ty.recursive_type_normalized_impl(db, div, true) .unwrap_or(div) }); - Some(Self::new(prefix, variable, suffix)) + Some(Self::new(prefix, variable_segment, suffix)) } } @@ -1879,16 +2115,64 @@ impl<'db> VariableLengthTuple> { tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'db>, ) -> TupleSpec<'db> { - Self::mixed( - self.prefix_elements() - .iter() - .map(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), - self.variable() - .apply_type_mapping_impl(db, type_mapping, tcx, visitor), - self.suffix_elements() - .iter() - .map(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), - ) + let prefix = self + .prefix_elements() + .iter() + .map(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); + let suffix = self + .suffix_elements() + .iter() + .map(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); + + match self.variable() { + VariableSegment::Homogeneous(variable) => Self::mixed( + prefix, + VariableSegment::Homogeneous(variable.apply_type_mapping_impl( + db, + type_mapping, + tcx, + visitor, + )), + suffix, + ), + VariableSegment::TypeVarTuple(typevartuple) => { + let mapped = Type::TypeVar(typevartuple).apply_type_mapping_impl( + db, + type_mapping, + tcx, + visitor, + ); + if mapped == Type::TypeVar(typevartuple) { + return Self::mixed( + prefix, + VariableSegment::TypeVarTuple(typevartuple), + suffix, + ); + } + if let Type::TypeVar(mapped_typevartuple) = mapped + && mapped_typevartuple.is_typevartuple(db) + { + return Self::mixed( + prefix, + VariableSegment::TypeVarTuple(mapped_typevartuple), + suffix, + ); + } + if let Some(mapped_tuple) = mapped.exact_tuple_instance_spec(db) { + let mut builder = TupleSpecBuilder::with_capacity(self.fixed_elements.len()); + for element in prefix { + builder.push(element); + } + builder = builder.concat(db, &mapped_tuple); + for element in suffix { + builder.push(element); + } + return builder.build(); + } + + Self::mixed(prefix, VariableSegment::Homogeneous(mapped), suffix) + } + } } fn find_legacy_typevars_impl( @@ -1901,15 +2185,26 @@ impl<'db> VariableLengthTuple> { for ty in self.prefix_elements() { ty.find_legacy_typevars_impl(db, binding_context, typevars, visitor); } - self.variable() - .find_legacy_typevars_impl(db, binding_context, typevars, visitor); + match self.variable() { + VariableSegment::Homogeneous(variable) => { + variable.find_legacy_typevars_impl(db, binding_context, typevars, visitor); + } + VariableSegment::TypeVarTuple(typevartuple) => { + Type::TypeVar(typevartuple).find_legacy_typevars_impl( + db, + binding_context, + typevars, + visitor, + ); + } + } for ty in self.suffix_elements() { ty.find_legacy_typevars_impl(db, binding_context, typevars, visitor); } } } -impl<'db> PyIndex<'db> for &VariableLengthTuple> { +impl<'db> PyIndex<'db> for &VariableLengthTuple, VariableSegment<'db>> { type Item = Type<'db>; fn py_index(self, db: &'db dyn Db, index: i32) -> Result { @@ -1933,7 +2228,7 @@ impl<'db> PyIndex<'db> for &VariableLengthTuple> { (self.prefix_elements().iter().rev().copied()) .take(index_past_suffix) .rev() - .chain(std::iter::once(self.variable())), + .chain(std::iter::once(self.variable().element_type(db))), )) } } @@ -1945,12 +2240,12 @@ impl<'db> PyIndex<'db> for &VariableLengthTuple> { /// Our tuple representation can hold instances of any Rust type. For tuples containing Python /// types, use [`TupleSpec`], which defines some additional type-specific methods. #[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -pub enum Tuple { +pub enum Tuple { Fixed(FixedLengthTuple), - Variable(VariableLengthTuple), + Variable(VariableLengthTuple), } -impl Tuple { +impl Tuple { /// Returns the inner fixed-length tuple if this is a `Tuple::Fixed` variant. pub(crate) fn as_fixed_length(&self) -> Option<&FixedLengthTuple> { match self { @@ -1963,23 +2258,8 @@ impl Tuple { matches!(self, Tuple::Variable(_)) } - pub(crate) const fn homogeneous(element: T) -> Self { - Self::Variable(VariableLengthTuple::homogeneous(element)) - } - pub(crate) fn heterogeneous(elements: impl IntoIterator) -> Self { - FixedLengthTuple::from_elements(elements).into() - } - - /// Returns the variable-length element of this tuple, if it has one. - pub(crate) fn variable_element(&self) -> Option<&T> - where - T: Copy, - { - match self { - Tuple::Fixed(_) => None, - Tuple::Variable(tuple) => Some(tuple.variable_element()), - } + Self::Fixed(FixedLengthTuple::from_elements(elements)) } /// Returns an iterator of all of the fixed-length element types of this tuple. @@ -1990,25 +2270,11 @@ impl Tuple { } } - /// Returns an iterator of all of the element types of this tuple. Does not deduplicate the - /// elements, and does not distinguish between fixed- and variable-length elements. - pub(crate) fn all_elements(&self) -> &[T] { + pub(crate) fn into_all_elements_with_kind(self) -> impl Iterator> { match self { - Tuple::Fixed(tuple) => tuple.all_elements(), - Tuple::Variable(tuple) => tuple.all_elements(), - } - } - - pub(crate) fn iter_all_elements(&self) -> impl DoubleEndedIterator + '_ - where - T: Copy, - { - self.all_elements().iter().copied() - } - - pub(crate) fn into_all_elements_with_kind(self) -> impl Iterator> { - match self { - Tuple::Fixed(tuple) => Either::Left(tuple.into_all_elements_with_kind()), + Tuple::Fixed(tuple) => { + Either::Left(tuple.owned_elements().into_iter().map(TupleElement::Fixed)) + } Tuple::Variable(tuple) => Either::Right(tuple.into_all_elements_with_kind()), } } @@ -2033,9 +2299,54 @@ impl Tuple { } } -impl<'db> Tuple> { +impl<'db> Tuple, VariableSegment<'db>> { + pub(crate) const fn homogeneous(element: Type<'db>) -> Self { + Self::Variable(VariableLengthTuple::homogeneous( + VariableSegment::Homogeneous(element), + )) + } + pub(crate) fn homogeneous_element_type(&self, db: &'db dyn Db) -> Type<'db> { - UnionType::from_elements_leave_aliases(db, self.all_elements()) + match self { + Tuple::Fixed(tuple) => { + UnionType::from_elements_leave_aliases(db, tuple.iter_all_elements()) + } + Tuple::Variable(tuple) => { + UnionType::from_elements_leave_aliases(db, tuple.iter_all_elements(db)) + } + } + } + + fn tuple_class_type(&self, db: &'db dyn Db) -> Type<'db> { + match self { + Tuple::Fixed(tuple) => { + UnionType::from_elements_leave_aliases(db, tuple.iter_all_elements()) + } + Tuple::Variable(tuple) => UnionType::from_elements_leave_aliases( + db, + tuple + .iter_prefix_elements() + .chain(std::iter::once(tuple.variable().tuple_class_type())) + .chain(tuple.iter_suffix_elements()), + ), + } + } + + pub(crate) fn variable_element_type(&self, db: &'db dyn Db) -> Option> { + match self { + Tuple::Fixed(_) => None, + Tuple::Variable(tuple) => Some(tuple.variable().element_type(db)), + } + } + + pub(crate) fn iter_element_types( + &self, + db: &'db dyn Db, + ) -> impl DoubleEndedIterator> + '_ { + match self { + Tuple::Fixed(tuple) => Either::Left(tuple.iter_all_elements()), + Tuple::Variable(tuple) => Either::Right(tuple.iter_all_elements(db)), + } } /// Returns the type of a static slice into this tuple. @@ -2134,7 +2445,12 @@ impl<'db> Tuple> { /// For variable-length tuples, this yields all pairs of elements that could overlap at runtime, /// including prefix/suffix elements matched by position, and variable elements that could /// align with any position in the other tuple. - pub(crate) fn try_for_each_element_pair(&self, other: &Self, mut f: F) -> Result<(), E> + pub(crate) fn try_for_each_element_pair( + &self, + db: &'db dyn Db, + other: &Self, + mut f: F, + ) -> Result<(), E> where F: FnMut(Type<'db>, Type<'db>) -> Result<(), E>, { @@ -2159,7 +2475,7 @@ impl<'db> Tuple> { .iter() .skip(right.prefix_elements().len()) { - f(*l, right.variable())?; + f(*l, right.variable().element_type(db))?; } // 3. Right's extra prefix elements with left's variable. @@ -2168,11 +2484,14 @@ impl<'db> Tuple> { .iter() .skip(left.prefix_elements().len()) { - f(left.variable(), *r)?; + f(left.variable().element_type(db), *r)?; } // 4. Variable elements with each other. - f(left.variable(), right.variable())?; + f( + left.variable().element_type(db), + right.variable().element_type(db), + )?; // 5. Left's extra suffix elements with right's variable. for l in left @@ -2181,7 +2500,7 @@ impl<'db> Tuple> { .rev() .skip(right.suffix_elements().len()) { - f(*l, right.variable())?; + f(*l, right.variable().element_type(db))?; } // 6. Right's extra suffix elements with left's variable. @@ -2191,7 +2510,7 @@ impl<'db> Tuple> { .rev() .skip(left.suffix_elements().len()) { - f(left.variable(), *r)?; + f(left.variable().element_type(db), *r)?; } // 7. Suffix elements at matching positions (from the end). @@ -2231,7 +2550,7 @@ impl<'db> Tuple> { .skip(middle_start) .take(middle_end.saturating_sub(middle_start)) { - f(left.variable(), *r)?; + f(left.variable().element_type(db), *r)?; } } @@ -2261,7 +2580,7 @@ impl<'db> Tuple> { .skip(middle_start) .take(middle_end.saturating_sub(middle_start)) { - f(*l, right.variable())?; + f(*l, right.variable().element_type(db))?; } } } @@ -2298,19 +2617,19 @@ impl<'db> Tuple> { } } -impl From> for Tuple { +impl From> for Tuple { fn from(tuple: FixedLengthTuple) -> Self { Tuple::Fixed(tuple) } } -impl From> for Tuple { - fn from(tuple: VariableLengthTuple) -> Self { +impl From> for Tuple { + fn from(tuple: VariableLengthTuple) -> Self { Tuple::Variable(tuple) } } -impl<'db> PyIndex<'db> for &Tuple> { +impl<'db> PyIndex<'db> for &TupleSpec<'db> { type Item = Type<'db>; fn py_index(self, db: &'db dyn Db, index: i32) -> Result { @@ -2321,10 +2640,10 @@ impl<'db> PyIndex<'db> for &Tuple> { } } -pub(crate) enum TupleElement { +pub(crate) enum TupleElement { Fixed(T), Prefix(T), - Variable(T), + Variable(V), Suffix(T), } @@ -2363,10 +2682,7 @@ impl<'db> TupleUnpacker<'db> { /// identical. The lengths only have to be identical if both sides are fixed-length; if either /// side is variable-length, we will pull multiple values out of the rhs variable-length /// portion, and assign multiple values to the starred target, as needed. - pub(crate) fn unpack_tuple( - &mut self, - values: &Tuple>, - ) -> Result<(), ResizeTupleError> { + pub(crate) fn unpack_tuple(&mut self, values: &TupleSpec<'db>) -> Result<(), ResizeTupleError> { let values = values.resize(self.db, self.targets.len())?; match (&mut self.targets, &values) { (Tuple::Fixed(targets), Tuple::Fixed(values)) => { @@ -2410,15 +2726,20 @@ impl<'db> FixedLengthTuple> { } impl<'db> VariableLengthTuple> { - fn unpack_tuple(&mut self, db: &'db dyn Db, values: &VariableLengthTuple>) { + fn unpack_tuple( + &mut self, + db: &'db dyn Db, + values: &VariableLengthTuple, VariableSegment<'db>>, + ) { // We have already verified above that the two tuples have the same length. for (target, value) in (self.prefix_elements_mut().iter_mut()).zip(values.iter_prefix_elements()) { target.add_in_place(value); } - self.variable_element_mut() - .add_in_place(KnownClass::List.to_specialized_instance(db, &[values.variable()])); + self.variable_element_mut().add_in_place( + KnownClass::List.to_specialized_instance(db, &[values.variable().element_type(db)]), + ); for (target, value) in (self.suffix_elements_mut().iter_mut()).zip(values.iter_suffix_elements()) { @@ -2439,7 +2760,7 @@ pub(crate) enum TupleSpecBuilder<'db> { Fixed(Vec>), Variable { prefix: Vec>, - variable: Type<'db>, + segment: VariableSegment<'db>, suffix: Vec>, }, } @@ -2456,6 +2777,17 @@ impl<'db> TupleSpecBuilder<'db> { } } + /// Concatenates an unpacked `TypeVarTuple` as the variable-length portion of this tuple. + pub(crate) fn concat_variadic_typevar( + self, + db: &'db dyn Db, + typevar: BoundTypeVarInstance<'db>, + ) -> Self { + debug_assert!(typevar.is_typevartuple(db)); + let other = VariableLengthTuple::mixed([], VariableSegment::TypeVarTuple(typevar), []); + self.concat(db, &other) + } + /// Concatenates another tuple to the end of this tuple, returning a new tuple. pub(crate) fn concat(mut self, db: &'db dyn Db, other: &TupleSpec<'db>) -> Self { match (&mut self, other) { @@ -2468,7 +2800,7 @@ impl<'db> TupleSpecBuilder<'db> { left_tuple.extend_from_slice(variable_tuple.prefix_elements()); TupleSpecBuilder::Variable { prefix: std::mem::take(left_tuple), - variable: variable_tuple.variable(), + segment: variable_tuple.variable(), suffix: variable_tuple.suffix_elements().to_vec(), } } @@ -2476,7 +2808,7 @@ impl<'db> TupleSpecBuilder<'db> { ( TupleSpecBuilder::Variable { prefix: _, - variable: _, + segment: _, suffix, }, TupleSpec::Fixed(right), @@ -2488,7 +2820,7 @@ impl<'db> TupleSpecBuilder<'db> { ( TupleSpecBuilder::Variable { prefix: left_prefix, - variable: left_variable, + segment: left_segment, suffix: left_suffix, }, TupleSpec::Variable(right), @@ -2497,26 +2829,34 @@ impl<'db> TupleSpecBuilder<'db> { db, left_suffix .iter() - .chain([left_variable, &right.variable()]) - .chain(right.prefix_elements()), + .copied() + .chain(std::iter::once(left_segment.element_type(db))) + .chain(std::iter::once(right.variable().element_type(db))) + .chain(right.iter_prefix_elements()), ); TupleSpecBuilder::Variable { prefix: std::mem::take(left_prefix), - variable, + segment: VariableSegment::Homogeneous(variable), suffix: right.suffix_elements().to_vec(), } } } } - fn all_elements(&self) -> impl Iterator> { + fn iter_element_types(&self, db: &'db dyn Db) -> impl Iterator> + '_ { match self { - TupleSpecBuilder::Fixed(elements) => Either::Left(elements.iter()), + TupleSpecBuilder::Fixed(elements) => Either::Left(elements.iter().copied()), TupleSpecBuilder::Variable { prefix, - variable, + segment, suffix, - } => Either::Right(prefix.iter().chain(std::iter::once(variable)).chain(suffix)), + } => Either::Right( + prefix + .iter() + .copied() + .chain(std::iter::once(segment.element_type(db))) + .chain(suffix.iter().copied()), + ), } } @@ -2553,11 +2893,12 @@ impl<'db> TupleSpecBuilder<'db> { _ => { let unioned = UnionType::from_elements_leave_aliases( db, - self.all_elements().chain(other.all_elements()), + self.iter_element_types(db) + .chain(other.iter_element_types(db)), ); TupleSpecBuilder::Variable { prefix: vec![], - variable: unioned, + segment: VariableSegment::Homogeneous(unioned), suffix: vec![], } } @@ -2601,7 +2942,7 @@ impl<'db> TupleSpecBuilder<'db> { ( TupleSpecBuilder::Variable { prefix, - variable, + segment, suffix, }, TupleSpec::Variable(var), @@ -2612,7 +2953,19 @@ impl<'db> TupleSpecBuilder<'db> { for (existing, new) in prefix.iter_mut().zip(var.prefix_elements()) { *existing = IntersectionType::from_two_elements(db, *existing, *new); } - *variable = IntersectionType::from_two_elements(db, *variable, var.variable()); + *segment = match (*segment, var.variable()) { + ( + VariableSegment::TypeVarTuple(left), + VariableSegment::TypeVarTuple(right), + ) if left == right => VariableSegment::TypeVarTuple(left), + (left, right) => { + VariableSegment::Homogeneous(IntersectionType::from_two_elements( + db, + left.element_type(db), + right.element_type(db), + )) + } + }; for (existing, new) in suffix.iter_mut().zip(var.suffix_elements()) { *existing = IntersectionType::from_two_elements(db, *existing, *new); } @@ -2640,9 +2993,9 @@ impl<'db> TupleSpecBuilder<'db> { } TupleSpecBuilder::Variable { prefix, - variable, + segment, suffix, - } => TupleSpec::Variable(VariableLengthTuple::new_from_vec(prefix, variable, suffix)), + } => TupleSpec::Variable(VariableLengthTuple::new_from_vec(prefix, segment, suffix)), } } } @@ -2653,7 +3006,7 @@ impl<'db> From<&TupleSpec<'db>> for TupleSpecBuilder<'db> { TupleSpec::Fixed(fixed) => TupleSpecBuilder::Fixed(fixed.0.to_vec()), TupleSpec::Variable(variable) => TupleSpecBuilder::Variable { prefix: variable.prefix_elements().to_vec(), - variable: variable.variable(), + segment: variable.variable(), suffix: variable.suffix_elements().to_vec(), }, } diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index f74303932b..afc19eb919 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -225,6 +225,10 @@ impl<'db> TypeVarInstance<'db> { self.kind(db).is_paramspec() } + pub(crate) fn is_typevartuple(self, db: &'db dyn Db) -> bool { + self.kind(db).is_typevartuple() + } + pub(crate) fn upper_bound(self, db: &'db dyn Db) -> Option> { if let Some(TypeVarBoundOrConstraints::UpperBound(ty)) = self.bound_or_constraints(db) { Some(ty) @@ -577,17 +581,19 @@ impl<'db> TypeVarInstance<'db> { Type::NominalInstance(nominal_instance) => nominal_instance .own_tuple_spec(db) .map_or_else(Parameters::unknown, |tuple_spec| { - Parameters::standard( - tuple_spec - .iter_all_elements() - .map(|ty| Parameter::positional_only(None).with_annotated_type(ty)), - ) + match tuple_spec.as_ref() { + Tuple::Fixed(tuple) => { + Parameters::standard(tuple.iter_all_elements().map(|ty| { + Parameter::positional_only(None).with_annotated_type(ty) + })) + } + // A `ParamSpec` default cannot contain a variable-length tuple, so this + // branch only recovers from an invalid type expression. + Tuple::Variable(_) => Parameters::unknown(), + } }), Type::Dynamic(dynamic) => match dynamic { - DynamicType::Todo(_) - | DynamicType::TodoUnpack - | DynamicType::TodoStarredExpression - | DynamicType::TodoTypeVarTuple => Parameters::todo(), + DynamicType::Todo(_) => Parameters::todo(), DynamicType::Any | DynamicType::Unknown | DynamicType::UnknownGeneric(_) @@ -640,6 +646,11 @@ impl<'db> TypeVarInstance<'db> { definition_expression_type(db, definition, paramspec_node.default.as_ref()?); convert_type_to_paramspec_value(db, default_ty) } + // PEP 695 TypeVarTuple + DefinitionKind::TypeVarTuple(typevartuple) => { + let typevartuple_node = typevartuple.node(&module); + definition_expression_type(db, definition, typevartuple_node.default.as_ref()?) + } _ => return None, }; @@ -927,6 +938,10 @@ impl<'db> BoundTypeVarInstance<'db> { self.kind(db).is_paramspec() } + pub(crate) fn is_typevartuple(self, db: &'db dyn Db) -> bool { + self.kind(db).is_typevartuple() + } + /// Returns a new bound typevar instance with the given `ParamSpec` attribute set. /// /// This method will also set an appropriate upper bound on the typevar, based on the @@ -1309,6 +1324,10 @@ pub enum TypeVarKind { LegacyParamSpec, /// `def foo[**P]() -> None: ...` Pep695ParamSpec, + /// `Ts = TypeVarTuple("Ts")` + LegacyTypeVarTuple, + /// `def foo[*Ts]() -> None: ...` + Pep695TypeVarTuple, /// `Alias: typing.TypeAlias = T` Pep613Alias, } @@ -1317,6 +1336,10 @@ impl TypeVarKind { pub(super) const fn is_paramspec(self) -> bool { matches!(self, Self::LegacyParamSpec | Self::Pep695ParamSpec) } + + pub(super) const fn is_typevartuple(self) -> bool { + matches!(self, Self::LegacyTypeVarTuple | Self::Pep695TypeVarTuple) + } } /// The identity of a type variable. diff --git a/crates/ty_python_semantic/src/types/unpacker.rs b/crates/ty_python_semantic/src/types/unpacker.rs index b594eb0ac6..45afdab987 100644 --- a/crates/ty_python_semantic/src/types/unpacker.rs +++ b/crates/ty_python_semantic/src/types/unpacker.rs @@ -8,7 +8,7 @@ use ruff_python_ast::{self as ast, AnyNodeRef}; use crate::Db; use crate::types::infer::{ExpressionInference, FrozenMap}; -use crate::types::tuple::{ResizeTupleError, Tuple, TupleLength, TupleSpec, TupleUnpacker}; +use crate::types::tuple::{ResizeTupleError, TupleLength, TupleSpec, TupleUnpacker}; use crate::types::{Type, TypeCheckDiagnostics, TypeContext, infer_expression_types}; use ty_python_core::ExpressionNodeKey; use ty_python_core::scope::ScopeId; @@ -222,7 +222,7 @@ impl<'db, 'ast> Unpacker<'db, 'ast> { if let Err(err) = unpacker.unpack_tuple(tuple.as_ref()) { unpacker - .unpack_tuple(&Tuple::homogeneous(Type::unknown())) + .unpack_tuple(&TupleSpec::homogeneous(Type::unknown())) .expect("adding a homogeneous tuple should always succeed"); if let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) { diff --git a/crates/ty_python_semantic/src/types/visitor.rs b/crates/ty_python_semantic/src/types/visitor.rs index 0e55d2fb90..72a3754fd7 100644 --- a/crates/ty_python_semantic/src/types/visitor.rs +++ b/crates/ty_python_semantic/src/types/visitor.rs @@ -576,7 +576,7 @@ where #[cfg(test)] mod tests { - use crate::types::{DynamicType, Type}; + use crate::types::{DynamicType, SpecialFormType, Type}; use super::CollectedTypes; @@ -592,7 +592,7 @@ mod tests { Type::Dynamic(DynamicType::UnspecializedTypeVar), Type::Dynamic(DynamicType::InvalidConcatenateUnknown), Type::Dynamic(DynamicType::AmbiguousOverload), - Type::Dynamic(DynamicType::TodoUnpack), + Type::SpecialForm(SpecialFormType::Any), ]; for ty in types { @@ -601,7 +601,7 @@ mod tests { assert!(collected.is_spilled()); assert!(!collected.insert(Type::Never)); - assert!(!collected.insert(Type::Dynamic(DynamicType::TodoUnpack))); - assert!(collected.insert(Type::Dynamic(DynamicType::TodoStarredExpression))); + assert!(!collected.insert(Type::SpecialForm(SpecialFormType::Any))); + assert!(collected.insert(Type::SpecialForm(SpecialFormType::Unknown))); } } From f23975849a0db64a2851029c68fc054799d5e7da Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:59:52 -0400 Subject: [PATCH 113/222] [`flake8-pyi`] Fix false positive in `__all__` (`PYI053`) (#26872) Summary -- This PR fixes a false positive on long strings in `__all__`. These strings correspond to existing exported symbols and are out of the stub author's control. I initially hoped that we could use the existing `Semantic::in_dunder_all_definition`, but this is only set in `Checker::visit_exports` and used for adding global references, so the flag is no longer set when the rule runs in `Checker::visit_expr`. I found another similar piece of existing code in `Checker::handle_node_store` and factored that out into a `Checker` helper method. Test Plan -- New mdtest --- .../flake8-pyi/string-or-bytes-too-long.md | 16 +++++ crates/ruff_linter/src/checkers/ast/mod.rs | 64 +++++++++++-------- .../rules/string_or_bytes_too_long.rs | 11 +++- 3 files changed, 62 insertions(+), 29 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/flake8-pyi/string-or-bytes-too-long.md diff --git a/crates/ruff_linter/resources/mdtest/flake8-pyi/string-or-bytes-too-long.md b/crates/ruff_linter/resources/mdtest/flake8-pyi/string-or-bytes-too-long.md new file mode 100644 index 0000000000..c01174839c --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/flake8-pyi/string-or-bytes-too-long.md @@ -0,0 +1,16 @@ +# `string-or-bytes-too-long` (`PYI053`) + +```toml +[lint] +select = ["PYI053"] +``` + +## Long name in `__all__` + +Strings in `__all__` correspond to exported names and should be exempt from the rule. + +```pyi +__all__ = [ + "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeef", +] +``` diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index afdf134f1e..23d6f8f5d6 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -2792,34 +2792,7 @@ impl<'a> Checker<'a> { _ => {} } - let scope = self.semantic.current_scope(); - - if scope.kind.is_module() - && match parent { - Stmt::Assign(ast::StmtAssign { targets, .. }) => { - if let Some(Expr::Name(ast::ExprName { id, .. })) = targets.first() { - id == "__all__" - } else { - false - } - } - Stmt::AugAssign(ast::StmtAugAssign { target, .. }) => { - if let Expr::Name(ast::ExprName { id, .. }) = target.as_ref() { - id == "__all__" - } else { - false - } - } - Stmt::AnnAssign(ast::StmtAnnAssign { target, .. }) => { - if let Expr::Name(ast::ExprName { id, .. }) = target.as_ref() { - id == "__all__" - } else { - false - } - } - _ => false, - } - { + if self.in_dunder_all_assignment(parent) { let (all_names, all_flags) = self.semantic.extract_dunder_all_names(parent); if all_flags.intersects(DunderAllFlags::INVALID_OBJECT) { @@ -3285,6 +3258,41 @@ impl<'a> Checker<'a> { self.semantic.restore(snapshot); } + + /// Report whether a module-level `__all__` assignment is being visited. + /// + /// This differs from [`SemanticModel::in_dunder_all_definition`], which is set only while + /// adding bindings for the entries in `__all__`. + pub(crate) fn in_dunder_all_assignment(&self, parent: &Stmt) -> bool { + if !self.semantic.current_scope().kind.is_module() { + return false; + } + + match parent { + Stmt::Assign(ast::StmtAssign { targets, .. }) => { + if let Some(Expr::Name(ast::ExprName { id, .. })) = targets.first() { + id == "__all__" + } else { + false + } + } + Stmt::AugAssign(ast::StmtAugAssign { target, .. }) => { + if let Expr::Name(ast::ExprName { id, .. }) = target.as_ref() { + id == "__all__" + } else { + false + } + } + Stmt::AnnAssign(ast::StmtAnnAssign { target, .. }) => { + if let Expr::Name(ast::ExprName { id, .. }) = target.as_ref() { + id == "__all__" + } else { + false + } + } + _ => false, + } + } } struct ParsedAnnotationsCache<'a> { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs index 0b0580663b..d4fd623c4d 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/string_or_bytes_too_long.rs @@ -21,6 +21,9 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// checkers, the primary consumers of stub files. Replace very long constants /// with ellipses (`...`) to simplify the stub. /// +/// The rule does not apply to long entries in `__all__`, which are assumed to +/// be outside the stub author's control. +/// /// ## Example /// /// ```pyi @@ -51,8 +54,10 @@ impl AlwaysFixableViolation for StringOrBytesTooLong { pub(crate) fn string_or_bytes_too_long(checker: &Checker, string: StringLike) { let semantic = checker.semantic(); + let parent = semantic.current_statement(); + // Ignore docstrings. - if is_docstring_stmt(semantic.current_statement()) { + if is_docstring_stmt(parent) { return; } @@ -64,6 +69,10 @@ pub(crate) fn string_or_bytes_too_long(checker: &Checker, string: StringLike) { return; } + if checker.in_dunder_all_assignment(parent) { + return; + } + let length = match string { StringLike::String(ast::ExprStringLiteral { value, .. }) => value.chars().count(), StringLike::Bytes(ast::ExprBytesLiteral { value, .. }) => value.len(), From d244fd75e9036e61be4896b823fe760d7aa632a3 Mon Sep 17 00:00:00 2001 From: Joosboy Date: Thu, 16 Jul 2026 19:31:32 +0530 Subject: [PATCH 114/222] Document argfile support (#26803) Closes #21894 by adding a section about argfile support (using `@`) to the configuration documentation. --------- Co-authored-by: Mahadev Annabhimoju <219508079+Joosboy@users.noreply.github.com> --- docs/configuration.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 3b424f66aa..64b5af9e02 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -516,6 +516,24 @@ which will similarly override the `line-length` setting from all configuration files detected by Ruff, regardless of where a specific configuration file is located. +### Argfile support + +Ruff supports reading command-line arguments from a file, which is especially useful when passing a large number of file paths that might exceed your shell's command-line length limit. To use an argfile, prefix the file path with an `@` symbol: + +```console +$ ruff check @path/to/args.txt +``` + +The arguments in the file must all be written on their own line. For example, `args.txt` might contain: + +```text +--select +F401 +--quiet +path/to/code1/ +path/to/code2/ +``` + ### Full command-line interface See `ruff help` for the full list of Ruff's top-level commands: From f3cf7c81fa35f8b819a719303bf1789714f1c780 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:05:29 -0400 Subject: [PATCH 115/222] [`ruff`] Add `rule-codes-in-selectors` (`RUF201`) (#26772) Summary -- This PR adds a new lint rule for TOML config files that checks for the use of rule codes as selectors and offers a fix to replace them with human-readable names. At a high-level, the implementation just deserializes a TOML config into a stripped down version of our normal configuration types and then checks each of its `Spanned` selector entries. Unless I oversimplified this too much, it didn't end up being as tricky as I thought. The main tricky part was adding basic support for fixes in TOML files, but even this was pretty straightforward. Codex was concerned about calling this a safe fix because applying the fix with `--preview`, or another temporary way of enabling preview, would break the config if preview isn't always enabled. That seemed to go beyond our usual fix safety policy to me, but I'm also happy to mark the fix as unsafe if that's preferable. Test Plan -- New tests covering all of the expected selectors and new CLI tests to make sure that `ruff.toml` files get linted and respect various ways of suppressing fixes. Previously we only linted `pyproject.toml` files for `RUF200` and bypassed some of the checks in `LintContext`. I left the change to use `LintContext` as a separate commit since it also affects `RUF200` and I briefly considered splitting it off into a separate PR. --- crates/ruff/src/commands/check.rs | 3 +- crates/ruff/src/commands/show_files.rs | 3 +- crates/ruff/src/diagnostics.rs | 103 ++++++-- crates/ruff/tests/cli/lint.rs | 40 +++- ...ires_python_extend_from_shared_config.snap | 2 + .../cli__lint__requires_python_no_tool.snap | 2 + ...quires_python_no_tool_preview_enabled.snap | 2 + ...ython_no_tool_target_version_override.snap | 2 + ..._requires_python_pyproject_toml_above.snap | 2 + ...python_pyproject_toml_above_with_tool.snap | 2 + ...nt__requires_python_ruff_toml_above-2.snap | 2 + ...lint__requires_python_ruff_toml_above.snap | 2 + ...s_python_ruff_toml_no_target_fallback.snap | 2 + ...ow_settings__display_default_settings.snap | 2 + ...isplay_settings_from_nested_directory.snap | 2 + crates/ruff_dev/src/format_dev.rs | 5 +- .../mdtest/ruff/rule-codes-in-selectors.md | 213 +++++++++++++++++ .../src/checkers/physical_lines.rs | 2 +- crates/ruff_linter/src/codes.rs | 2 + crates/ruff_linter/src/lib.rs | 2 +- crates/ruff_linter/src/linter.rs | 8 +- crates/ruff_linter/src/pyproject_toml.rs | 25 -- crates/ruff_linter/src/registry.rs | 5 +- crates/ruff_linter/src/rule_selector.rs | 4 + crates/ruff_linter/src/rules/ruff/mod.rs | 22 +- .../ruff/rules/invalid_pyproject_toml.rs | 22 +- .../ruff_linter/src/rules/ruff/rules/mod.rs | 2 + .../ruff/rules/rule_codes_in_selectors.rs | 223 ++++++++++++++++++ crates/ruff_linter/src/source_kind.rs | 9 + crates/ruff_linter/src/test.rs | 18 ++ crates/ruff_linter/src/toml.rs | 89 +++++++ crates/ruff_mdtest/src/lib.rs | 8 +- crates/ruff_python_ast/src/lib.rs | 5 + crates/ruff_workspace/src/options.rs | 9 +- crates/ruff_workspace/src/settings.rs | 4 + ruff.schema.json | 4 +- 36 files changed, 777 insertions(+), 75 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-selectors.md delete mode 100644 crates/ruff_linter/src/pyproject_toml.rs create mode 100644 crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs create mode 100644 crates/ruff_linter/src/toml.rs diff --git a/crates/ruff/src/commands/check.rs b/crates/ruff/src/commands/check.rs index 5a80e4f5ac..d363801ccd 100644 --- a/crates/ruff/src/commands/check.rs +++ b/crates/ruff/src/commands/check.rs @@ -50,7 +50,8 @@ pub(crate) fn check( if let Ok(ResolvedFile::Root(path) | ResolvedFile::Nested(path)) = path { matches!( SourceType::from(path), - SourceType::Python(_) | SourceType::Toml(TomlSourceType::Pyproject) + SourceType::Python(_) + | SourceType::Toml(TomlSourceType::Pyproject | TomlSourceType::Ruff) ) } else { true diff --git a/crates/ruff/src/commands/show_files.rs b/crates/ruff/src/commands/show_files.rs index 22826dbd0d..9ac22700d2 100644 --- a/crates/ruff/src/commands/show_files.rs +++ b/crates/ruff/src/commands/show_files.rs @@ -25,7 +25,8 @@ pub(crate) fn show_files( if let Ok(ResolvedFile::Root(path) | ResolvedFile::Nested(path)) = path { matches!( SourceType::from(path), - SourceType::Python(_) | SourceType::Toml(TomlSourceType::Pyproject) + SourceType::Python(_) + | SourceType::Toml(TomlSourceType::Pyproject | TomlSourceType::Ruff) ) } else { true diff --git a/crates/ruff/src/diagnostics.rs b/crates/ruff/src/diagnostics.rs index 61311515bb..634ad80b1c 100644 --- a/crates/ruff/src/diagnostics.rs +++ b/crates/ruff/src/diagnostics.rs @@ -14,10 +14,10 @@ use ruff_db::diagnostic::Diagnostic; use ruff_linter::codes::Rule; use ruff_linter::linter::{FixTable, FixerResult, LinterResult, ParseSource, lint_fix, lint_only}; use ruff_linter::package::PackageRoot; -use ruff_linter::pyproject_toml::lint_pyproject_toml; use ruff_linter::settings::types::UnsafeFixes; use ruff_linter::settings::{LinterSettings, flags}; -use ruff_linter::source_kind::{SourceError, SourceKind}; +use ruff_linter::source_kind::{SourceError, SourceKind, SourceKindDiff}; +use ruff_linter::toml::{TomlFixerResult, lint_fix_toml, lint_toml}; use ruff_linter::{IOError, Violation, fs}; use ruff_notebook::{NotebookError, NotebookIndex}; use ruff_python_ast::{SourceType, TomlSourceType}; @@ -213,11 +213,11 @@ pub(crate) fn lint_path( debug!("Checking: {}", path.display()); let source_type = match settings.extension.get_source_type(path) { - SourceType::Toml(TomlSourceType::Pyproject) => { - let diagnostics = if settings + SourceType::Toml(source_type @ (TomlSourceType::Pyproject | TomlSourceType::Ruff)) => { + let (diagnostics, fixed) = if settings .rules .iter_enabled() - .any(|rule_code| rule_code.lint_source().is_pyproject_toml()) + .any(|rule_code| rule_code.lint_source().is_toml()) { let contents = match std::fs::read_to_string(path).map_err(SourceError::from) { Ok(contents) => contents, @@ -225,13 +225,47 @@ pub(crate) fn lint_path( return Ok(Diagnostics::from_source_error(&err, Some(path), settings)); } }; - lint_pyproject_toml(path, &contents, settings) + if matches!(fix_mode, flags::FixMode::Apply | flags::FixMode::Diff) { + let TomlFixerResult { + diagnostics, + transformed, + fixed, + } = lint_fix_toml(path, &contents, settings, source_type, unsafe_fixes); + + if !fixed.is_empty() { + match fix_mode { + flags::FixMode::Apply => { + File::create(path)?.write_all(transformed.as_bytes())?; + } + flags::FixMode::Diff => { + write!( + &mut io::stdout().lock(), + "{}", + SourceKindDiff::from_text( + &contents, + transformed.as_ref(), + Some(path), + ) + )?; + } + flags::FixMode::Generate => {} + } + } + + (diagnostics, fixed) + } else { + ( + lint_toml(path, &contents, settings, source_type), + FixTable::default(), + ) + } } else { - vec![] + (vec![], FixTable::default()) }; return Ok(Diagnostics { inner: diagnostics, - ..Diagnostics::default() + fixed: FixMap::from_iter([(fs::relativize_path(path), fixed)]), + notebook_indexes: FxHashMap::default(), }); } SourceType::Toml(_) | SourceType::Markdown => return Ok(Diagnostics::default()), @@ -358,26 +392,63 @@ pub(crate) fn lint_stdin( .map(|path| settings.linter.extension.get_source_type(path)) .unwrap_or_default() { - SourceType::Toml(source_type) if source_type.is_pyproject() => { + SourceType::Toml(source_type @ (TomlSourceType::Pyproject | TomlSourceType::Ruff)) => { if !settings .linter .rules .iter_enabled() - .any(|rule_code| rule_code.lint_source().is_pyproject_toml()) + .any(|rule_code| rule_code.lint_source().is_toml()) { return Ok(Diagnostics::default()); } let path = path.unwrap(); - match fix_mode { - flags::FixMode::Diff | flags::FixMode::Generate => {} - flags::FixMode::Apply => write!(&mut io::stdout().lock(), "{contents}")?, - } + let (diagnostics, fixed) = + if matches!(fix_mode, flags::FixMode::Apply | flags::FixMode::Diff) { + let TomlFixerResult { + diagnostics, + transformed, + fixed, + } = lint_fix_toml( + path, + &contents, + &settings.linter, + source_type, + settings.unsafe_fixes, + ); + + match fix_mode { + flags::FixMode::Apply => { + write!(&mut io::stdout().lock(), "{transformed}")?; + } + flags::FixMode::Diff => { + if !fixed.is_empty() { + write!( + &mut io::stdout().lock(), + "{}", + SourceKindDiff::from_text( + &contents, + transformed.as_ref(), + Some(path), + ) + )?; + } + } + flags::FixMode::Generate => {} + } + + (diagnostics, fixed) + } else { + ( + lint_toml(path, &contents, &settings.linter, source_type), + FixTable::default(), + ) + }; return Ok(Diagnostics { - inner: lint_pyproject_toml(path, &contents, &settings.linter), - fixed: FixMap::from_iter([(fs::relativize_path(path), FixTable::default())]), + inner: diagnostics, + fixed: FixMap::from_iter([(fs::relativize_path(path), fixed)]), notebook_indexes: FxHashMap::default(), }); } diff --git a/crates/ruff/tests/cli/lint.rs b/crates/ruff/tests/cli/lint.rs index bacd40530c..a1edec6a7a 100644 --- a/crates/ruff/tests/cli/lint.rs +++ b/crates/ruff/tests/cli/lint.rs @@ -468,7 +468,6 @@ ignore = ["D203", "D212"] All checks passed! ----- stderr ----- - warning: No Python files found under the given path(s) "); Ok(()) @@ -5192,3 +5191,42 @@ fn preview_default_rules() -> Result<()> { ); Ok(()) } + +#[test] +fn ruff_toml_is_linted() -> Result<()> { + let test = CliTest::with_file("ruff.toml", r#"lint.select = ["F401"]"#)?; + + assert_cmd_snapshot!( + test.command().args([ + "check", + "--no-cache", + "--isolated", + "--preview", + "--select", + "RUF201", + ]), + @r#" + success: false + exit_code: 1 + ----- stdout ----- + rule-codes-in-selectors: [*] Rule code used instead of name in `lint.select` + --> ruff.toml:1:17 + | + 1 | lint.select = ["F401"] + | ^^^^ + | + help: Replace rule code with `unused-import` + | + - lint.select = ["F401"] + 1 + lint.select = ["unused-import"] + | + + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "#, + ); + + Ok(()) +} diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap index f5a44bbbd8..b4a34f4c7b 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap @@ -59,6 +59,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap index 322b41688e..fafd0ded8c 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap @@ -61,6 +61,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap index 3e71de36fc..0a24b5e1d8 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap @@ -63,6 +63,8 @@ file_resolver.include = [ "*.pyw", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", "*.md", ] file_resolver.extend_include = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap index 0dadf95bea..48c3d81fed 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap @@ -63,6 +63,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap index 140513dc93..99f233b509 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap @@ -60,6 +60,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap index 8cc415c496..a9727552e0 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap @@ -61,6 +61,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap index 73ceea46bb..72982b9649 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap @@ -59,6 +59,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap index f8136b2d97..7a50948dfc 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap @@ -59,6 +59,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap index d281c8f869..60137f66c8 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap @@ -59,6 +59,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__show_settings__display_default_settings.snap b/crates/ruff/tests/cli/snapshots/cli__show_settings__display_default_settings.snap index 792399f068..882ccb119a 100644 --- a/crates/ruff/tests/cli/snapshots/cli__show_settings__display_default_settings.snap +++ b/crates/ruff/tests/cli/snapshots/cli__show_settings__display_default_settings.snap @@ -56,6 +56,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff/tests/cli/snapshots/cli__show_settings__display_settings_from_nested_directory.snap b/crates/ruff/tests/cli/snapshots/cli__show_settings__display_settings_from_nested_directory.snap index ff29f68590..f155e549bc 100644 --- a/crates/ruff/tests/cli/snapshots/cli__show_settings__display_settings_from_nested_directory.snap +++ b/crates/ruff/tests/cli/snapshots/cli__show_settings__display_settings_from_nested_directory.snap @@ -56,6 +56,8 @@ file_resolver.include = [ "*.pyi", "*.ipynb", "**/pyproject.toml", + "**/ruff.toml", + "**/.ruff.toml", ] file_resolver.extend_include = [] file_resolver.respect_gitignore = true diff --git a/crates/ruff_dev/src/format_dev.rs b/crates/ruff_dev/src/format_dev.rs index 25f81fbb80..ac081bff1b 100644 --- a/crates/ruff_dev/src/format_dev.rs +++ b/crates/ruff_dev/src/format_dev.rs @@ -554,7 +554,10 @@ fn format_dir_entry( ) -> anyhow::Result<(Result, PathBuf), Error> { let resolved_file = resolved_file.context("Iterating the files in the repository failed")?; // For some reason it does not filter in the beginning - if resolved_file.file_name() == "pyproject.toml" { + if ["pyproject.toml", "ruff.toml", ".ruff.toml"] + .iter() + .any(|&path| resolved_file.file_name() == path) + { return Ok((Ok(Statistics::default()), resolved_file.into_path())); } diff --git a/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-selectors.md b/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-selectors.md new file mode 100644 index 0000000000..cd9bada104 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/ruff/rule-codes-in-selectors.md @@ -0,0 +1,213 @@ +# `rule-codes-in-selectors` (`RUF201`) + +```toml +[lint] +preview = true +select = ["rule-codes-in-selectors"] +``` + +## Various quotes + +`ruff.toml`: + +```toml +[lint] +select = [ + "F401", # snapshot: rule-codes-in-selectors + 'F402', # snapshot: rule-codes-in-selectors + """F403""", # snapshot: rule-codes-in-selectors + '''F404''', # snapshot: rule-codes-in-selectors +] +``` + +```snapshot +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:3:6 + | +3 | "F401", # snapshot: rule-codes-in-selectors + | ^^^^ + | +help: Replace rule code with `unused-import` + | +2 | select = [ + - "F401", # snapshot: rule-codes-in-selectors +3 + "unused-import", # snapshot: rule-codes-in-selectors +4 | 'F402', # snapshot: rule-codes-in-selectors + | + + +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:4:6 + | +4 | 'F402', # snapshot: rule-codes-in-selectors + | ^^^^ + | +help: Replace rule code with `import-shadowed-by-loop-var` + | +3 | "F401", # snapshot: rule-codes-in-selectors + - 'F402', # snapshot: rule-codes-in-selectors +4 + 'import-shadowed-by-loop-var', # snapshot: rule-codes-in-selectors +5 | """F403""", # snapshot: rule-codes-in-selectors + | + + +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:5:8 + | +5 | """F403""", # snapshot: rule-codes-in-selectors + | ^^^^ + | +help: Replace rule code with `undefined-local-with-import-star` + | +4 | 'F402', # snapshot: rule-codes-in-selectors + - """F403""", # snapshot: rule-codes-in-selectors +5 + """undefined-local-with-import-star""", # snapshot: rule-codes-in-selectors +6 | '''F404''', # snapshot: rule-codes-in-selectors + | + + +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:6:8 + | +6 | '''F404''', # snapshot: rule-codes-in-selectors + | ^^^^ + | +help: Replace rule code with `late-future-import` + | +5 | """F403""", # snapshot: rule-codes-in-selectors + - '''F404''', # snapshot: rule-codes-in-selectors +6 + '''late-future-import''', # snapshot: rule-codes-in-selectors +7 | ] + | +``` + +## Invalid rule codes + +Invalid rule codes are not flagged, including nested quoting issues like `"'F401'"`, but valid codes +in the same selector are still analyzed: + +`ruff.toml`: + +```toml +[lint] +# snapshot: rule-codes-in-selectors +select = ["'F401'", "F402"] +``` + +```snapshot +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:3:22 + | +3 | select = ["'F401'", "F402"] + | ^^^^ + | +help: Replace rule code with `import-shadowed-by-loop-var` + | +2 | # snapshot: rule-codes-in-selectors + - select = ["'F401'", "F402"] +3 + select = ["'F401'", "import-shadowed-by-loop-var"] + | +``` + +## Invalid selector shapes + +Just in case these ever make it past our actual config deserialization, the rule skips over +malformed selectors (e.g. table for `select`, non-table for `per-file-ignores`): + +`ruff.toml`: + +```toml +[lint] +select = { nested = ["F401"] } +per-file-ignores = ["F401"] +``` + +## Prefixes and names + +Prefixes and rule names are also left alone: + +`ruff.toml`: + +```toml +[lint] +select = ["F", "unused-import"] +``` + +## All selectors + +Test that we flag all selectors both in the `lint` table and in the deprecated top-level settings: + +`ruff.toml`: + +```toml +select = ["F401"] # error: [rule-codes-in-selectors] +extend-select = ["F841"] # error: [rule-codes-in-selectors] +fixable = ["E501"] # error: [rule-codes-in-selectors] +extend-fixable = ["UP035"] # error: [rule-codes-in-selectors] +ignore = ["F401"] # error: [rule-codes-in-selectors] +extend-ignore = ["F841"] # error: [rule-codes-in-selectors] +per-file-ignores = { "foo.py" = ["E501"] } # error: [rule-codes-in-selectors] +extend-per-file-ignores = { "bar.py" = ["UP035"] } # error: [rule-codes-in-selectors] +unfixable = ["F401"] # error: [rule-codes-in-selectors] +extend-unfixable = ["F841"] # error: [rule-codes-in-selectors] +extend-safe-fixes = ["E501"] # error: [rule-codes-in-selectors] +extend-unsafe-fixes = ["UP035"] # error: [rule-codes-in-selectors] + +[lint] +select = ["F401"] # error: [rule-codes-in-selectors] +extend-select = ["F841"] # error: [rule-codes-in-selectors] +fixable = ["E501"] # error: [rule-codes-in-selectors] +extend-fixable = ["UP035"] # error: [rule-codes-in-selectors] +ignore = ["F401"] # error: [rule-codes-in-selectors] +extend-ignore = ["F841"] # error: [rule-codes-in-selectors] +per-file-ignores = { "foo.py" = ["E501"] } # error: [rule-codes-in-selectors] +extend-per-file-ignores = { "bar.py" = ["UP035"] } # error: [rule-codes-in-selectors] +unfixable = ["F401"] # error: [rule-codes-in-selectors] +extend-unfixable = ["F841"] # error: [rule-codes-in-selectors] +extend-safe-fixes = ["E501"] # error: [rule-codes-in-selectors] +extend-unsafe-fixes = ["UP035"] # error: [rule-codes-in-selectors] +``` + +## `pyproject.toml` + +`pyproject.toml`: + +```toml +[tool.ruff] +ignore = ["F401"] # error: [rule-codes-in-selectors] + +[tool.ruff.lint] +select = ["F402"] # error: [rule-codes-in-selectors] +``` + +## `unfixable` + +Test that `rule-codes-in-selectors` and other TOML-specific lints respect the user's `unfixable` +settings: + +```toml +[lint] +preview = true +select = ["rule-codes-in-selectors"] +unfixable = ["rule-codes-in-selectors"] +``` + +`ruff.toml`: + +```toml +# snapshot: rule-codes-in-selectors +lint.select = ["F401"] +``` + +```snapshot +error[RUF201]: Rule code used instead of name in `lint.select` + --> src/ruff.toml:2:17 + | +2 | lint.select = ["F401"] + | ^^^^ + | +help: Replace rule code with `unused-import` +``` + +This should also cover settings like `extend-unsafe-fixes` and `per-file-ignores`, all of which are +handled through the `LintContext`. diff --git a/crates/ruff_linter/src/checkers/physical_lines.rs b/crates/ruff_linter/src/checkers/physical_lines.rs index 5cc8f7a908..dbfe48a308 100644 --- a/crates/ruff_linter/src/checkers/physical_lines.rs +++ b/crates/ruff_linter/src/checkers/physical_lines.rs @@ -115,7 +115,7 @@ mod tests { }; let diagnostics = LintContext::new(Path::new(""), line, &settings); check_physical_lines(&locator, &stylist, &indexer, &[], &settings, &diagnostics); - diagnostics.into_parts().0 + diagnostics.into_diagnostics() }; let line_length = LineLength::try_from(8).unwrap(); assert_eq!(check_with_max_line_length(line_length), vec![]); diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index a554f9fee2..645b9266a2 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1093,6 +1093,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "106") => rules::ruff::rules::RuleCodesInSuppressionComments, (Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml, + (Ruff, "201") => rules::ruff::rules::RuleCodesInSelectors, + #[cfg(any(feature = "test-rules", test))] (Ruff, "900") => rules::ruff::rules::StableTestRule, #[cfg(any(feature = "test-rules", test))] diff --git a/crates/ruff_linter/src/lib.rs b/crates/ruff_linter/src/lib.rs index 4a59b41a92..f971c43b4d 100644 --- a/crates/ruff_linter/src/lib.rs +++ b/crates/ruff_linter/src/lib.rs @@ -38,7 +38,6 @@ mod noqa; pub mod package; pub mod packaging; pub mod preview; -pub mod pyproject_toml; pub mod registry; mod renamer; mod rule_redirects; @@ -48,6 +47,7 @@ pub mod settings; pub mod source_kind; pub mod suppression; mod text_helpers; +pub mod toml; pub mod upstream_categories; mod violation; diff --git a/crates/ruff_linter/src/linter.rs b/crates/ruff_linter/src/linter.rs index b8039417a9..e83a57416d 100644 --- a/crates/ruff_linter/src/linter.rs +++ b/crates/ruff_linter/src/linter.rs @@ -369,7 +369,7 @@ pub fn check_path( ) } -const MAX_ITERATIONS: usize = 100; +pub(crate) const MAX_ITERATIONS: usize = 100; /// Add any missing suppression comments to the source code at the given `Path`. pub fn add_suppressions_to_path( @@ -676,7 +676,11 @@ where } #[expect(clippy::print_stderr)] -fn report_failed_to_converge_error(path: &Path, transformed: &str, diagnostics: &[Diagnostic]) { +pub(crate) fn report_failed_to_converge_error( + path: &Path, + transformed: &str, + diagnostics: &[Diagnostic], +) { let codes = collect_rule_codes(diagnostics.iter().filter_map(Diagnostic::secondary_code)); if cfg!(debug_assertions) { eprintln!( diff --git a/crates/ruff_linter/src/pyproject_toml.rs b/crates/ruff_linter/src/pyproject_toml.rs deleted file mode 100644 index 583f099981..0000000000 --- a/crates/ruff_linter/src/pyproject_toml.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::path::Path; - -use pyproject_toml::PyProjectToml; -use ruff_db::diagnostic::Diagnostic; - -use crate::checkers::ast::LintContext; -use crate::codes::Rule; -use crate::rules::ruff::rules::invalid_pyproject_toml; -use crate::settings::LinterSettings; - -pub fn lint_pyproject_toml( - path: &Path, - contents: &str, - settings: &LinterSettings, -) -> Vec { - let context = LintContext::new(path, contents, settings); - - if let Err(err) = toml::from_str::(contents) { - if context.is_rule_enabled(Rule::InvalidPyprojectToml) { - invalid_pyproject_toml(&context, &err); - } - } - - context.into_diagnostics() -} diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 990352abfb..21892be74d 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -241,7 +241,8 @@ pub enum LintSource { Imports, Noqa, Filesystem, - PyprojectToml, + /// A TOML config file, either `pyproject.toml`, `ruff.toml`, or `.ruff.toml`. + Toml, } impl Rule { @@ -249,7 +250,7 @@ impl Rule { /// physical lines). pub const fn lint_source(&self) -> LintSource { match self { - Rule::InvalidPyprojectToml => LintSource::PyprojectToml, + Rule::InvalidPyprojectToml | Rule::RuleCodesInSelectors => LintSource::Toml, Rule::BlanketNOQA | Rule::NoqaComments | Rule::RedirectedNOQA diff --git a/crates/ruff_linter/src/rule_selector.rs b/crates/ruff_linter/src/rule_selector.rs index 7a477aeae9..70b6303c8d 100644 --- a/crates/ruff_linter/src/rule_selector.rs +++ b/crates/ruff_linter/src/rule_selector.rs @@ -15,6 +15,10 @@ use crate::rule_redirects::get_redirect; use crate::settings::types::PreviewMode; use crate::warn_user_once_by_message; +/// A potential rule selector that has not yet been validated and tracks its source. +/// +/// If you add a new field that uses this type, be sure to update `rule-codes-in-selectors` +/// (`RUF201`) to validate the additional selector field. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(transparent)] pub struct UnresolvedRuleSelector(RangedValue); diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index a91f858996..4252591fd1 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -7,22 +7,20 @@ pub(crate) mod typing; #[cfg(test)] mod tests { - use std::fs; use std::path::Path; use anyhow::Result; use regex::Regex; - use ruff_python_ast::PythonVersion; + use ruff_python_ast::{PythonVersion, TomlSourceType}; use rustc_hash::FxHashSet; use test_case::test_case; - use crate::pyproject_toml::lint_pyproject_toml; use crate::registry::Rule; use crate::rules::pydocstyle::settings::Settings as PydocstyleSettings; use crate::settings::LinterSettings; use crate::settings::types::{CompiledPerFileIgnoreList, PerFileIgnore, PreviewMode}; use crate::source_kind::SourceKind; - use crate::test::{test_contents, test_path, test_resource_path, test_snippet}; + use crate::test::{test_contents, test_path, test_resource_path, test_snippet, test_toml_path}; use crate::{UnresolvedRuleSelector, assert_diagnostics, assert_diagnostics_diff, settings}; #[test_case(Rule::CollectionLiteralConcatenation, Path::new("RUF005.py"))] @@ -788,17 +786,13 @@ mod tests { #[test_case(Rule::InvalidPyprojectToml, Path::new("pep639"))] fn invalid_pyproject_toml(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); - let path = test_resource_path("fixtures") - .join("ruff") - .join("pyproject_toml") - .join(path) - .join("pyproject.toml"); - let contents = fs::read_to_string(path)?; - let messages = lint_pyproject_toml( - Path::new("pyproject.toml"), - &contents, + let messages = test_toml_path( + Path::new("ruff/pyproject_toml") + .join(path) + .join("pyproject.toml"), &settings::LinterSettings::for_rule(Rule::InvalidPyprojectToml), - ); + TomlSourceType::Pyproject, + )?; assert_diagnostics!(snapshot, messages); Ok(()) } diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs index be78db98cf..9e9675041c 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs @@ -1,3 +1,8 @@ +use pyproject_toml::PyProjectToml; +use serde::Deserialize; +use toml::Spanned; +use toml::de::DeTable; + use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::{TextRange, TextSize}; @@ -48,7 +53,22 @@ impl Violation for InvalidPyprojectToml { } /// RUF200 -pub(crate) fn invalid_pyproject_toml(context: &LintContext, err: &toml::de::Error) { +pub(crate) fn invalid_pyproject_toml( + context: &LintContext, + document: Result>, toml::de::Error>, +) { + let err = match document { + Ok(document) => { + let deserializer = toml::de::Deserializer::from(document); + let Err(mut err) = PyProjectToml::deserialize(deserializer) else { + return; + }; + err.set_input(Some(context.source_file().source_text())); + err + } + Err(err) => err, + }; + let range = match err.span() { // This is bad but sometimes toml and/or serde just don't give us spans // TODO(konstin,micha): https://github.com/astral-sh/ruff/issues/4571 diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 71cd885d0f..1b2aeca17b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -50,6 +50,7 @@ pub(crate) use pytest_raises_ambiguous_pattern::*; pub(crate) use quadratic_list_summation::*; pub(crate) use redirected_noqa::*; pub(crate) use redundant_bool_literal::*; +pub(crate) use rule_codes_in_selectors::*; pub(crate) use rule_codes_in_suppression_comments::*; pub(crate) use sort_dunder_all::*; pub(crate) use sort_dunder_slots::*; @@ -130,6 +131,7 @@ mod pytest_raises_ambiguous_pattern; mod quadratic_list_summation; mod redirected_noqa; mod redundant_bool_literal; +mod rule_codes_in_selectors; mod rule_codes_in_suppression_comments; mod sequence_sorting; mod sort_dunder_all; diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs new file mode 100644 index 0000000000..9b99508579 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs @@ -0,0 +1,223 @@ +use toml::Spanned; +use toml::de::{DeArray, DeTable, DeValue}; + +use ruff_db::diagnostic::LintName; +use ruff_diagnostics::{Edit, Fix}; +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::TomlSourceType; +use ruff_text_size::{TextLen, TextRange, TextSize}; + +use crate::{ + AlwaysFixableViolation, checkers::ast::LintContext, codes::Rule, + preview::is_human_readable_names_enabled, rule_redirects::get_redirect_target, +}; + +/// ## What it does +/// +/// Checks for any configuration files that use rule codes as selectors. +/// +/// ## Why is this bad? +/// +/// Human-readable rule names are easier to understand than rule codes. Using names also avoids +/// requiring readers to look up the meaning of each code. +/// +/// ## Example +/// +/// ```toml +/// [tool.ruff.lint] +/// select = ["F401"] +/// ``` +/// +/// Use instead: +/// +/// ```toml +/// [tool.ruff.lint] +/// select = ["unused-import"] +/// ``` +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +pub(crate) struct RuleCodesInSelectors { + selector: &'static str, + name: &'static str, + in_lint_table: bool, +} + +impl AlwaysFixableViolation for RuleCodesInSelectors { + #[derive_message_formats] + fn message(&self) -> String { + let Self { + selector, + in_lint_table, + name: _, + } = self; + if *in_lint_table { + format!("Rule code used instead of name in `lint.{selector}`") + } else { + format!("Rule code used instead of name in `{selector}`") + } + } + + fn fix_title(&self) -> String { + format!("Replace rule code with `{name}`", name = self.name) + } +} + +/// RUF201 +pub(crate) fn rule_codes_in_selectors( + context: &LintContext, + document: &DeTable<'_>, + source_type: TomlSourceType, +) { + if !is_human_readable_names_enabled(context.settings().preview) { + return; + } + + let ruff = match source_type { + TomlSourceType::Pyproject => document + .get("tool") + .and_then(|tool| tool.get_ref().get("ruff")) + .and_then(|ruff| ruff.get_ref().as_table()), + TomlSourceType::Ruff => Some(document), + _ => None, + }; + + let Some(ruff) = ruff else { + return; + }; + + check_selectors(context, ruff, false); + + if let Some(lint) = ruff.get("lint").and_then(|lint| lint.get_ref().as_table()) { + check_selectors(context, lint, true); + } +} + +/// Selectors that are themselves arrays. +/// +/// For example: +/// +/// ```toml +/// select = ["F401"] +/// ``` +const ARRAY_SELECTORS: &[&str] = &[ + "select", + "extend-select", + "fixable", + "extend-fixable", + "ignore", + "extend-ignore", + "unfixable", + "extend-unfixable", + "extend-safe-fixes", + "extend-unsafe-fixes", +]; + +/// Selectors that are tables containing arrays. +/// +/// For example: +/// +/// ```toml +/// per-file-ignores = { "*.py" = ["F401"] } +/// ``` +const TABLE_SELECTORS: &[&str] = &["per-file-ignores", "extend-per-file-ignores"]; + +fn check_selectors(context: &LintContext, table: &DeTable<'_>, in_lint_table: bool) { + for &selector in ARRAY_SELECTORS { + let Some(value) = table.get(selector) else { + continue; + }; + + if let DeValue::Array(values) = value.get_ref() { + check_selector_array(context, values, selector, in_lint_table); + } + } + + for &selector in TABLE_SELECTORS { + let Some(value) = table.get(selector) else { + continue; + }; + + if let DeValue::Table(per_file) = value.get_ref() { + for value in per_file.values() { + let Some(values) = value.get_ref().as_array() else { + continue; + }; + check_selector_array(context, values, selector, in_lint_table); + } + } + } +} + +fn check_selector_array( + context: &LintContext, + values: &DeArray<'_>, + selector: &'static str, + in_lint_table: bool, +) { + let source = context.source_file().source_text(); + + for value in values { + let Some(RuleCode { name, range }) = RuleCode::from_spanned(value, source) else { + continue; + }; + + context + .report_diagnostic( + RuleCodesInSelectors { + selector, + in_lint_table, + name: name.as_str(), + }, + range, + ) + .set_fix(Fix::safe_edit(Edit::range_replacement( + name.to_string(), + range, + ))); + } +} + +struct RuleCode { + name: LintName, + range: TextRange, +} + +impl RuleCode { + /// Extract a rule code and its range from a spanned TOML string. + /// + /// The range corresponds to the code itself rather than the surrounding string: + /// + /// ```toml + /// [lint] + /// select = ["F401"] + /// ^^^^ + /// ``` + fn from_spanned(spanned: &Spanned>, source: &str) -> Option { + let code = spanned.get_ref().as_str()?; + let code = get_redirect_target(code).unwrap_or(code); + let rule = Rule::from_code(code).ok()?; + + let span = spanned.span(); + let range = TextRange::new( + TextSize::try_from(span.start).unwrap(), + TextSize::try_from(span.end).unwrap(), + ); + + // Note that this should be infallible because the parsed TOML string is surrounded by valid + // quotes, and `Rule::from_code` above guarantees that its content is a valid rule code. This + // means that we don't have to worry about stripping nested quotes like `"'F401'"` or similar. + let range = { + let string = &source[range]; + let content = string.trim_start_matches(['"', '\'']); + let quote_len = string.text_len() - content.text_len(); + let start = range.start() + quote_len; + let end = range.end() - quote_len; + TextRange::new(start, end) + }; + + Some(Self { + name: rule.name(), + range, + }) + } +} diff --git a/crates/ruff_linter/src/source_kind.rs b/crates/ruff_linter/src/source_kind.rs index 35e660190e..a132ce83b4 100644 --- a/crates/ruff_linter/src/source_kind.rs +++ b/crates/ruff_linter/src/source_kind.rs @@ -205,6 +205,15 @@ pub struct SourceKindDiff<'a> { path: Option<&'a Path>, } +impl<'a> SourceKindDiff<'a> { + pub fn from_text(original: &'a str, modified: &'a str, path: Option<&'a Path>) -> Self { + Self { + kind: DiffKind::Text(original, modified), + path, + } + } +} + impl std::fmt::Display for SourceKindDiff<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self.kind { diff --git a/crates/ruff_linter/src/test.rs b/crates/ruff_linter/src/test.rs index cba6b200fe..d48155c728 100644 --- a/crates/ruff_linter/src/test.rs +++ b/crates/ruff_linter/src/test.rs @@ -134,6 +134,24 @@ pub(crate) fn test_path( Ok(test_contents(&source_kind, &path, settings).0) } +/// Run the configuration TOML linter on a file in the `resources/test/fixtures` directory. +#[cfg(test)] +pub(crate) fn test_toml_path( + path: impl AsRef, + settings: &LinterSettings, + source_type: ruff_python_ast::TomlSourceType, +) -> Result> { + let path = test_resource_path("fixtures").join(path); + let filename = path.file_name().unwrap_or_else(|| path.as_os_str()); + let contents = std::fs::read_to_string(&path)?; + Ok(crate::toml::lint_toml( + Path::new(filename), + &contents, + settings, + source_type, + )) +} + /// Test a file with two different settings and return the differences #[cfg(test)] pub(crate) fn test_path_with_settings_diff( diff --git a/crates/ruff_linter/src/toml.rs b/crates/ruff_linter/src/toml.rs new file mode 100644 index 0000000000..4a0fa817ee --- /dev/null +++ b/crates/ruff_linter/src/toml.rs @@ -0,0 +1,89 @@ +use std::borrow::Cow; +use std::path::Path; + +use toml::de::DeTable; + +use ruff_db::diagnostic::Diagnostic; +use ruff_python_ast::TomlSourceType; + +use crate::Locator; +use crate::checkers::ast::LintContext; +use crate::fix::{FixResult, fix_file}; +use crate::linter::{FixTable, MAX_ITERATIONS, report_failed_to_converge_error}; +use crate::registry::Rule; +use crate::rules::ruff::rules::{invalid_pyproject_toml, rule_codes_in_selectors}; +use crate::settings::LinterSettings; +use crate::settings::types::UnsafeFixes; + +pub struct TomlFixerResult<'a> { + pub diagnostics: Vec, + pub transformed: Cow<'a, str>, + pub fixed: FixTable, +} + +pub fn lint_toml( + path: &Path, + contents: &str, + settings: &LinterSettings, + source_type: TomlSourceType, +) -> Vec { + let context = LintContext::new(path, contents, settings); + + let document = DeTable::parse(contents); + + if context.is_rule_enabled(Rule::RuleCodesInSelectors) + && let Ok(document) = &document + { + rule_codes_in_selectors(&context, document.get_ref(), source_type); + } + + if source_type.is_pyproject() && context.is_rule_enabled(Rule::InvalidPyprojectToml) { + invalid_pyproject_toml(&context, document); + } + + context.into_diagnostics() +} + +/// Generate [`Diagnostic`]s for a TOML configuration file, iteratively fixing until stable. +pub fn lint_fix_toml<'a>( + path: &Path, + source: &'a str, + settings: &LinterSettings, + source_type: TomlSourceType, + unsafe_fixes: UnsafeFixes, +) -> TomlFixerResult<'a> { + let mut diagnostics = lint_toml(path, source, settings, source_type); + let mut transformed = Cow::Borrowed(source); + let mut fixed = FixTable::default(); + let mut iterations = 0; + + loop { + let locator = Locator::new(transformed.as_ref()); + let Some(FixResult { code, fixes, .. }) = fix_file(&diagnostics, &locator, unsafe_fixes) + else { + return TomlFixerResult { + diagnostics, + transformed, + fixed, + }; + }; + + if iterations >= MAX_ITERATIONS { + report_failed_to_converge_error(path, transformed.as_ref(), &diagnostics); + return TomlFixerResult { + diagnostics, + transformed, + fixed, + }; + } + + for (rule, name, count) in fixes.iter() { + *fixed.entry(rule).or_default(name) += count; + } + + transformed = Cow::Owned(code); + iterations += 1; + + diagnostics = lint_toml(path, transformed.as_ref(), settings, source_type); + } +} diff --git a/crates/ruff_mdtest/src/lib.rs b/crates/ruff_mdtest/src/lib.rs index c35ced5469..c8c9fa67a4 100644 --- a/crates/ruff_mdtest/src/lib.rs +++ b/crates/ruff_mdtest/src/lib.rs @@ -10,9 +10,9 @@ use ruff_db::diagnostic::{Annotation, Diagnostic, Span}; use ruff_db::files::{File, system_path_to_file}; use ruff_db::source::source_text; use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf}; -use ruff_linter::pyproject_toml::lint_pyproject_toml; use ruff_linter::source_kind::SourceKind; use ruff_linter::test::test_contents; +use ruff_linter::toml::lint_toml; use ruff_python_ast::SourceType; use ruff_ranged_value::{ValueSource, ValueSourceGuard}; use ruff_workspace::configuration::Configuration; @@ -126,10 +126,10 @@ fn run_test( }; test_contents(&source_kind, path, &settings.linter).0 } - SourceType::Toml(source_type) if source_type.is_pyproject() => { - lint_pyproject_toml(path, source.as_str(), &settings.linter) + SourceType::Toml(source_type) => { + lint_toml(path, source.as_str(), &settings.linter, source_type) } - SourceType::Toml(_) | SourceType::Markdown => Vec::new(), + SourceType::Markdown => Vec::new(), } }, test_file, diff --git a/crates/ruff_python_ast/src/lib.rs b/crates/ruff_python_ast/src/lib.rs index bd49f93b5e..7f2eae3fd6 100644 --- a/crates/ruff_python_ast/src/lib.rs +++ b/crates/ruff_python_ast/src/lib.rs @@ -69,6 +69,9 @@ impl> From

for SourceType { Some(filename) if filename == "pyproject.toml" => Self::Toml(TomlSourceType::Pyproject), Some(filename) if filename == "Pipfile" => Self::Toml(TomlSourceType::Pipfile), Some(filename) if filename == "poetry.lock" => Self::Toml(TomlSourceType::Poetry), + Some(filename) if filename == "ruff.toml" || filename == ".ruff.toml" => { + Self::Toml(TomlSourceType::Ruff) + } _ => Self::from_extension( path.as_ref() .extension() @@ -83,6 +86,8 @@ impl> From

for SourceType { pub enum TomlSourceType { /// The source is a `pyproject.toml`. Pyproject, + /// The source is a `ruff.toml` or `.ruff.toml`. + Ruff, /// The source is a `Pipfile`. Pipfile, /// The source is a `poetry.lock`. diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index f6ec9bd372..cd19803f52 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -257,15 +257,16 @@ pub struct Options { /// A list of file patterns to include when linting. /// /// Inclusion are based on globs, and should be single-path patterns, like - /// `*.pyw`, to include any file with the `.pyw` extension. `pyproject.toml` is - /// included here not for configuration but because we lint whether e.g. the - /// `[project]` matches the schema. + /// `*.pyw`, to include any file with the `.pyw` extension. + /// `pyproject.toml`, `ruff.toml`, and `.ruff.toml` are included here not for + /// configuration but because we lint whether e.g. the `[project]` matches + /// the schema in `pyproject.toml` or that rule names are used as selectors. /// /// Notebook files (`.ipynb` extension) are included by default on Ruff 0.6.0+. /// /// For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax). #[option( - default = r#"["*.py", "*.pyi", "*.pyw", "*.ipynb", "*.md", "**/pyproject.toml"]"#, + default = r#"["*.py", "*.pyi", "*.pyw", "*.ipynb", "*.md", "**/pyproject.toml", "**/ruff.toml", "**/.ruff.toml"]"#, value_type = "list[str]", example = r#" include = ["*.py"] diff --git a/crates/ruff_workspace/src/settings.rs b/crates/ruff_workspace/src/settings.rs index 9490d716fe..a1c3dd4af2 100644 --- a/crates/ruff_workspace/src/settings.rs +++ b/crates/ruff_workspace/src/settings.rs @@ -143,6 +143,8 @@ pub(crate) static INCLUDE: &[FilePattern] = &[ FilePattern::Builtin("*.pyi"), FilePattern::Builtin("*.ipynb"), FilePattern::Builtin("**/pyproject.toml"), + FilePattern::Builtin("**/ruff.toml"), + FilePattern::Builtin("**/.ruff.toml"), ]; pub(crate) static INCLUDE_PREVIEW: &[FilePattern] = &[ FilePattern::Builtin("*.py"), @@ -150,6 +152,8 @@ pub(crate) static INCLUDE_PREVIEW: &[FilePattern] = &[ FilePattern::Builtin("*.pyw"), FilePattern::Builtin("*.ipynb"), FilePattern::Builtin("**/pyproject.toml"), + FilePattern::Builtin("**/ruff.toml"), + FilePattern::Builtin("**/.ruff.toml"), FilePattern::Builtin("*.md"), ]; diff --git a/ruff.schema.json b/ruff.schema.json index f846497844..212dbdf6aa 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -465,7 +465,7 @@ "deprecated": true }, "include": { - "description": "A list of file patterns to include when linting.\n\nInclusion are based on globs, and should be single-path patterns, like\n`*.pyw`, to include any file with the `.pyw` extension. `pyproject.toml` is\nincluded here not for configuration but because we lint whether e.g. the\n`[project]` matches the schema.\n\nNotebook files (`.ipynb` extension) are included by default on Ruff 0.6.0+.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "description": "A list of file patterns to include when linting.\n\nInclusion are based on globs, and should be single-path patterns, like\n`*.pyw`, to include any file with the `.pyw` extension.\n`pyproject.toml`, `ruff.toml`, and `.ruff.toml` are included here not for\nconfiguration but because we lint whether e.g. the `[project]` matches\nthe schema in `pyproject.toml` or that rule names are used as selectors.\n\nNotebook files (`.ipynb` extension) are included by default on Ruff 0.6.0+.\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ "array", "null" @@ -4270,6 +4270,7 @@ "RUF2", "RUF20", "RUF200", + "RUF201", "S", "S1", "S10", @@ -5209,6 +5210,7 @@ "return-outside-function", "reuse-of-groupby-generator", "root-logger-call", + "rule-codes-in-selectors", "rule-codes-in-suppression-comments", "run-process-in-async-function", "runtime-cast-value", From fe8ba85a55b76d838e6058d813b64468b5f81eca Mon Sep 17 00:00:00 2001 From: Diego Araujo Date: Thu, 16 Jul 2026 11:13:25 -0300 Subject: [PATCH 116/222] [`pycodestyle`] Document `E731` fix safety (#26847) ## Summary Adds a `Fix safety` section to the documentation for `lambda-assignment` (`E731`). The new section explains that converting a lambda assignment into a function definition changes observable callable metadata. In particular, the function's `__name__` changes from `""` to the assigned variable name, which can affect code that relies on logging, registration, or introspection. The E731 fix was changed from display-only to unsafe in #19700. Related discussion: #19650. Part of #15584. ## Test Plan - `cargo fmt --all` - `git diff --check` - Manually verified the E731 unsafe fix and the resulting `__name__` change. --- .../src/rules/pycodestyle/rules/lambda_assignment.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs index 59cd84abfa..28113e9589 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/lambda_assignment.rs @@ -34,6 +34,15 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; /// return 2 * x /// ``` /// +/// ## Fix safety +/// This fix is marked as unsafe because converting a lambda assignment into a +/// function definition changes observable properties of the callable. +/// +/// In particular, a lambda function has the name `""`, while the +/// generated function uses the name of the assigned variable. Code that relies +/// on function metadata, such as logging, registration, or introspection, may +/// therefore behave differently after the fix. +/// /// [PEP 8]: https://peps.python.org/pep-0008/#programming-recommendations #[derive(ViolationMetadata)] #[violation_metadata(stable_since = "v0.0.28")] From 0177a7e0d2c4a3805afa1960f106d72878766cbc Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:54:43 -0400 Subject: [PATCH 117/222] Bump 0.15.22 (#26884) --- CHANGELOG.md | 44 +++++++++++ Cargo.lock | 74 +++++++++--------- Cargo.toml | 72 ++++++++--------- README.md | 6 +- crates/ruff/Cargo.toml | 2 +- crates/ruff/README.md | 2 +- crates/ruff_annotate_snippets/Cargo.toml | 2 +- crates/ruff_cache/Cargo.toml | 2 +- crates/ruff_cache/README.md | 4 +- crates/ruff_db/Cargo.toml | 2 +- crates/ruff_db/README.md | 4 +- crates/ruff_diagnostics/Cargo.toml | 2 +- crates/ruff_diagnostics/README.md | 4 +- crates/ruff_formatter/Cargo.toml | 2 +- crates/ruff_formatter/README.md | 4 +- crates/ruff_graph/Cargo.toml | 2 +- crates/ruff_graph/README.md | 4 +- crates/ruff_index/Cargo.toml | 2 +- crates/ruff_index/README.md | 4 +- crates/ruff_linter/Cargo.toml | 2 +- crates/ruff_linter/README.md | 4 +- .../src/rules/ruff/rules/noqa_comments.rs | 2 +- .../ruff/rules/rule_codes_in_selectors.rs | 2 +- .../rule_codes_in_suppression_comments.rs | 2 +- crates/ruff_macros/Cargo.toml | 2 +- crates/ruff_macros/README.md | 4 +- crates/ruff_markdown/Cargo.toml | 2 +- crates/ruff_markdown/README.md | 4 +- crates/ruff_memory_usage/Cargo.toml | 2 +- crates/ruff_memory_usage/README.md | 4 +- crates/ruff_notebook/Cargo.toml | 2 +- crates/ruff_notebook/README.md | 4 +- crates/ruff_options_metadata/Cargo.toml | 2 +- crates/ruff_options_metadata/README.md | 4 +- crates/ruff_python_ast/Cargo.toml | 2 +- crates/ruff_python_ast/README.md | 4 +- crates/ruff_python_codegen/Cargo.toml | 2 +- crates/ruff_python_codegen/README.md | 4 +- crates/ruff_python_formatter/Cargo.toml | 2 +- crates/ruff_python_formatter/README.md | 4 +- crates/ruff_python_importer/Cargo.toml | 2 +- crates/ruff_python_importer/README.md | 4 +- crates/ruff_python_index/Cargo.toml | 2 +- crates/ruff_python_index/README.md | 4 +- crates/ruff_python_literal/Cargo.toml | 2 +- crates/ruff_python_literal/README.md | 4 +- crates/ruff_python_parser/Cargo.toml | 2 +- crates/ruff_python_parser/README.md | 4 +- crates/ruff_python_semantic/Cargo.toml | 2 +- crates/ruff_python_semantic/README.md | 4 +- crates/ruff_python_stdlib/Cargo.toml | 2 +- crates/ruff_python_stdlib/README.md | 4 +- crates/ruff_python_trivia/Cargo.toml | 2 +- crates/ruff_python_trivia/README.md | 4 +- crates/ruff_ranged_value/Cargo.toml | 2 +- crates/ruff_ranged_value/README.md | 4 +- crates/ruff_server/Cargo.toml | 2 +- crates/ruff_server/README.md | 4 +- crates/ruff_source_file/Cargo.toml | 2 +- crates/ruff_source_file/README.md | 4 +- crates/ruff_text_size/Cargo.toml | 2 +- crates/ruff_text_size/README.md | 4 +- crates/ruff_wasm/Cargo.toml | 2 +- crates/ruff_wasm/README.md | 4 +- crates/ruff_workspace/Cargo.toml | 2 +- crates/ruff_workspace/README.md | 4 +- crates/ty_combine/Cargo.toml | 2 +- crates/ty_combine/README.md | 4 +- crates/ty_module_resolver/Cargo.toml | 2 +- crates/ty_module_resolver/README.md | 4 +- crates/ty_python_core/Cargo.toml | 2 +- crates/ty_python_core/README.md | 4 +- crates/ty_python_semantic/Cargo.toml | 2 +- crates/ty_python_semantic/README.md | 4 +- crates/ty_site_packages/Cargo.toml | 2 +- crates/ty_site_packages/README.md | 4 +- crates/ty_static/Cargo.toml | 2 +- crates/ty_static/README.md | 4 +- crates/ty_vendored/Cargo.toml | 2 +- docs/formatter.md | 2 +- docs/integrations.md | 8 +- docs/tutorial.md | 2 +- pyproject.toml | 2 +- scripts/benchmarks/pyproject.toml | 2 +- uv.lock | 78 +++++++++---------- 85 files changed, 276 insertions(+), 232 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8eff74ef4..e92d24f1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.15.22 + +Released on 2026-07-16. + +### Preview features + +- \[`pycodestyle`\] Add an autofix for `E402` ([#22212](https://github.com/astral-sh/ruff/pull/22212)) +- \[`refurb`\] Allow subclassing builtins in stub files (`FURB189`) ([#26812](https://github.com/astral-sh/ruff/pull/26812)) +- \[`ruff`\] Add rule to replace `noqa` comments with `ruff:ignore` (`RUF105`) ([#26423](https://github.com/astral-sh/ruff/pull/26423)) +- \[`ruff`\] Add rule to use human-readable names in `ruff:ignore` comments (`RUF106`) ([#26682](https://github.com/astral-sh/ruff/pull/26682)) +- \[`ruff`\] Add rule to use human-readable names in configuration selectors (`RUF201`) ([#26772](https://github.com/astral-sh/ruff/pull/26772)) + +### Bug fixes + +- \[`flake8-pyi`\] Fix false positive in `__all__` (`PYI053`) ([#26872](https://github.com/astral-sh/ruff/pull/26872)) + +### Rule changes + +- \[`pylint`\] Ignore mutable type updates in `redefined-loop-name` (`PLW2901`) ([#25733](https://github.com/astral-sh/ruff/pull/25733)) + +### Performance + +- Avoid redundant lexer token bookkeeping ([#26765](https://github.com/astral-sh/ruff/pull/26765)) +- Avoid redundant pending-indentation writes ([#26774](https://github.com/astral-sh/ruff/pull/26774)) +- Avoid unnecessary identifier lookahead ([#26525](https://github.com/astral-sh/ruff/pull/26525)) +- Reuse parser scratch buffers ([#26798](https://github.com/astral-sh/ruff/pull/26798)) + +### Documentation + +- Document argfile support ([#26803](https://github.com/astral-sh/ruff/pull/26803)) +- \[`flake8-datetimez`\] Clarify naming guidance for `datetime.today` (`DTZ002`) ([#26658](https://github.com/astral-sh/ruff/pull/26658)) +- \[`pycodestyle`\] Document `E731` fix safety ([#26847](https://github.com/astral-sh/ruff/pull/26847)) +- \[`ruff`\] Clarify intentional async contexts for `unused-async` (`RUF029`) ([#26641](https://github.com/astral-sh/ruff/pull/26641)) + +### Contributors + +- [@dwego](https://github.com/dwego) +- [@MichaReiser](https://github.com/MichaReiser) +- [@Joosboy](https://github.com/Joosboy) +- [@KaufmanDmitriy](https://github.com/KaufmanDmitriy) +- [@PeterJCLaw](https://github.com/PeterJCLaw) +- [@ntBre](https://github.com/ntBre) +- [@charliermarsh](https://github.com/charliermarsh) + ## 0.15.21 Released on 2026-07-09. diff --git a/Cargo.lock b/Cargo.lock index 52e8a3ac14..61d491c2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3067,7 +3067,7 @@ dependencies = [ [[package]] name = "ruff" -version = "0.15.21" +version = "0.15.22" dependencies = [ "anyhow", "argfile", @@ -3131,7 +3131,7 @@ dependencies = [ [[package]] name = "ruff_annotate_snippets" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anstream 1.0.0", "anstyle", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "ruff_cache" -version = "0.0.4" +version = "0.0.5" dependencies = [ "filetime", "glob", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "ruff_db" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anstyle", "arc-swap", @@ -3276,7 +3276,7 @@ dependencies = [ [[package]] name = "ruff_diagnostics" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", "is-macro", @@ -3286,7 +3286,7 @@ dependencies = [ [[package]] name = "ruff_formatter" -version = "0.0.4" +version = "0.0.5" dependencies = [ "drop_bomb", "ruff_cache", @@ -3302,7 +3302,7 @@ dependencies = [ [[package]] name = "ruff_graph" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "clap", @@ -3323,7 +3323,7 @@ dependencies = [ [[package]] name = "ruff_index" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", "ruff_macros", @@ -3333,7 +3333,7 @@ dependencies = [ [[package]] name = "ruff_linter" -version = "0.15.21" +version = "0.15.22" dependencies = [ "aho-corasick", "anyhow", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "ruff_macros" -version = "0.0.4" +version = "0.0.5" dependencies = [ "heck", "itertools 0.15.0", @@ -3409,7 +3409,7 @@ dependencies = [ [[package]] name = "ruff_markdown" -version = "0.0.4" +version = "0.0.5" dependencies = [ "insta", "regex", @@ -3440,14 +3440,14 @@ dependencies = [ [[package]] name = "ruff_memory_usage" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", ] [[package]] name = "ruff_notebook" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "rand 0.10.2", @@ -3463,14 +3463,14 @@ dependencies = [ [[package]] name = "ruff_options_metadata" -version = "0.0.4" +version = "0.0.5" dependencies = [ "serde", ] [[package]] name = "ruff_python_ast" -version = "0.0.4" +version = "0.0.5" dependencies = [ "aho-corasick", "arrayvec", @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "ruff_python_codegen" -version = "0.0.4" +version = "0.0.5" dependencies = [ "ruff_python_ast", "ruff_python_literal", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "ruff_python_formatter" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "clap", @@ -3551,7 +3551,7 @@ dependencies = [ [[package]] name = "ruff_python_importer" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "insta", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "ruff_python_index" -version = "0.0.4" +version = "0.0.5" dependencies = [ "ruff_python_ast", "ruff_python_parser", @@ -3577,7 +3577,7 @@ dependencies = [ [[package]] name = "ruff_python_literal" -version = "0.0.4" +version = "0.0.5" dependencies = [ "bitflags 2.13.0", "icu_properties", @@ -3587,7 +3587,7 @@ dependencies = [ [[package]] name = "ruff_python_parser" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "ruff_python_semantic" -version = "0.0.4" +version = "0.0.5" dependencies = [ "bitflags 2.13.0", "insta", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "ruff_python_stdlib" -version = "0.0.4" +version = "0.0.5" dependencies = [ "bitflags 2.13.0", "unicode-ident", @@ -3646,7 +3646,7 @@ dependencies = [ [[package]] name = "ruff_python_trivia" -version = "0.0.4" +version = "0.0.5" dependencies = [ "itertools 0.15.0", "ruff_source_file", @@ -3667,7 +3667,7 @@ dependencies = [ [[package]] name = "ruff_ranged_value" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", "ruff_db", @@ -3679,7 +3679,7 @@ dependencies = [ [[package]] name = "ruff_server" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "crossbeam", @@ -3722,7 +3722,7 @@ dependencies = [ [[package]] name = "ruff_source_file" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", "memchr", @@ -3732,7 +3732,7 @@ dependencies = [ [[package]] name = "ruff_text_size" -version = "0.0.4" +version = "0.0.5" dependencies = [ "get-size2", "schemars", @@ -3743,7 +3743,7 @@ dependencies = [ [[package]] name = "ruff_wasm" -version = "0.15.21" +version = "0.15.22" dependencies = [ "console_error_panic_hook", "console_log", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "ruff_workspace" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "colored", @@ -4655,7 +4655,7 @@ dependencies = [ [[package]] name = "ty_combine" -version = "0.0.4" +version = "0.0.5" dependencies = [ "ordermap", "ruff_db", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "ty_module_resolver" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "camino", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "ty_python_core" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -4843,7 +4843,7 @@ dependencies = [ [[package]] name = "ty_python_semantic" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -4935,7 +4935,7 @@ dependencies = [ [[package]] name = "ty_site_packages" -version = "0.0.4" +version = "0.0.5" dependencies = [ "camino", "colored", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "ty_static" -version = "0.0.4" +version = "0.0.5" dependencies = [ "ruff_macros", ] @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "ty_vendored" -version = "0.0.4" +version = "0.0.5" dependencies = [ "path-slash", "ruff_db", diff --git a/Cargo.toml b/Cargo.toml index 17d0e852ee..e76e79ad67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,51 +13,51 @@ authors = ["Charlie Marsh "] license = "MIT" [workspace.dependencies] -ruff = { version = "0.15.21", path = "crates/ruff" } -ruff_annotate_snippets = { version = "0.0.4", path = "crates/ruff_annotate_snippets" } -ruff_cache = { version = "0.0.4", path = "crates/ruff_cache" } -ruff_db = { version = "0.0.4", path = "crates/ruff_db", default-features = false } -ruff_diagnostics = { version = "0.0.4", path = "crates/ruff_diagnostics" } -ruff_formatter = { version = "0.0.4", path = "crates/ruff_formatter" } -ruff_graph = { version = "0.0.4", path = "crates/ruff_graph" } -ruff_index = { version = "0.0.4", path = "crates/ruff_index" } -ruff_linter = { version = "0.15.21", path = "crates/ruff_linter" } -ruff_macros = { version = "0.0.4", path = "crates/ruff_macros" } -ruff_markdown = { version = "0.0.4", path = "crates/ruff_markdown" } -ruff_memory_usage = { version = "0.0.4", path = "crates/ruff_memory_usage" } -ruff_notebook = { version = "0.0.4", path = "crates/ruff_notebook" } -ruff_options_metadata = { version = "0.0.4", path = "crates/ruff_options_metadata" } -ruff_python_ast = { version = "0.0.4", path = "crates/ruff_python_ast" } -ruff_python_codegen = { version = "0.0.4", path = "crates/ruff_python_codegen" } -ruff_python_formatter = { version = "0.0.4", path = "crates/ruff_python_formatter" } -ruff_python_importer = { version = "0.0.4", path = "crates/ruff_python_importer" } -ruff_python_index = { version = "0.0.4", path = "crates/ruff_python_index" } -ruff_python_literal = { version = "0.0.4", path = "crates/ruff_python_literal" } -ruff_python_parser = { version = "0.0.4", path = "crates/ruff_python_parser" } -ruff_python_semantic = { version = "0.0.4", path = "crates/ruff_python_semantic" } -ruff_python_stdlib = { version = "0.0.4", path = "crates/ruff_python_stdlib" } -ruff_python_trivia = { version = "0.0.4", path = "crates/ruff_python_trivia" } -ruff_server = { version = "0.0.4", path = "crates/ruff_server" } -ruff_source_file = { version = "0.0.4", path = "crates/ruff_source_file" } +ruff = { version = "0.15.22", path = "crates/ruff" } +ruff_annotate_snippets = { version = "0.0.5", path = "crates/ruff_annotate_snippets" } +ruff_cache = { version = "0.0.5", path = "crates/ruff_cache" } +ruff_db = { version = "0.0.5", path = "crates/ruff_db", default-features = false } +ruff_diagnostics = { version = "0.0.5", path = "crates/ruff_diagnostics" } +ruff_formatter = { version = "0.0.5", path = "crates/ruff_formatter" } +ruff_graph = { version = "0.0.5", path = "crates/ruff_graph" } +ruff_index = { version = "0.0.5", path = "crates/ruff_index" } +ruff_linter = { version = "0.15.22", path = "crates/ruff_linter" } +ruff_macros = { version = "0.0.5", path = "crates/ruff_macros" } +ruff_markdown = { version = "0.0.5", path = "crates/ruff_markdown" } +ruff_memory_usage = { version = "0.0.5", path = "crates/ruff_memory_usage" } +ruff_notebook = { version = "0.0.5", path = "crates/ruff_notebook" } +ruff_options_metadata = { version = "0.0.5", path = "crates/ruff_options_metadata" } +ruff_python_ast = { version = "0.0.5", path = "crates/ruff_python_ast" } +ruff_python_codegen = { version = "0.0.5", path = "crates/ruff_python_codegen" } +ruff_python_formatter = { version = "0.0.5", path = "crates/ruff_python_formatter" } +ruff_python_importer = { version = "0.0.5", path = "crates/ruff_python_importer" } +ruff_python_index = { version = "0.0.5", path = "crates/ruff_python_index" } +ruff_python_literal = { version = "0.0.5", path = "crates/ruff_python_literal" } +ruff_python_parser = { version = "0.0.5", path = "crates/ruff_python_parser" } +ruff_python_semantic = { version = "0.0.5", path = "crates/ruff_python_semantic" } +ruff_python_stdlib = { version = "0.0.5", path = "crates/ruff_python_stdlib" } +ruff_python_trivia = { version = "0.0.5", path = "crates/ruff_python_trivia" } +ruff_server = { version = "0.0.5", path = "crates/ruff_server" } +ruff_source_file = { version = "0.0.5", path = "crates/ruff_source_file" } ruff_mdtest = { path = "crates/ruff_mdtest" } -ruff_ranged_value = { version = "0.0.4", path = "crates/ruff_ranged_value" } -ruff_text_size = { version = "0.0.4", path = "crates/ruff_text_size" } -ruff_workspace = { version = "0.0.4", path = "crates/ruff_workspace" } +ruff_ranged_value = { version = "0.0.5", path = "crates/ruff_ranged_value" } +ruff_text_size = { version = "0.0.5", path = "crates/ruff_text_size" } +ruff_workspace = { version = "0.0.5", path = "crates/ruff_workspace" } ty = { path = "crates/ty" } -ty_combine = { version = "0.0.4", path = "crates/ty_combine" } +ty_combine = { version = "0.0.5", path = "crates/ty_combine" } ty_completion_bench = { path = "crates/ty_completion_bench" } ty_completion_eval = { path = "crates/ty_completion_eval" } ty_ide = { path = "crates/ty_ide" } -ty_module_resolver = { version = "0.0.4", path = "crates/ty_module_resolver" } +ty_module_resolver = { version = "0.0.5", path = "crates/ty_module_resolver" } ty_project = { path = "crates/ty_project", default-features = false } -ty_python_semantic = { version = "0.0.4", path = "crates/ty_python_semantic" } -ty_python_core = { version = "0.0.4", path = "crates/ty_python_core" } +ty_python_semantic = { version = "0.0.5", path = "crates/ty_python_semantic" } +ty_python_core = { version = "0.0.5", path = "crates/ty_python_core" } ty_server = { path = "crates/ty_server" } -ty_site_packages = { version = "0.0.4", path = "crates/ty_site_packages" } -ty_static = { version = "0.0.4", path = "crates/ty_static" } +ty_site_packages = { version = "0.0.5", path = "crates/ty_site_packages" } +ty_static = { version = "0.0.5", path = "crates/ty_static" } ty_test = { path = "crates/ty_test" } -ty_vendored = { version = "0.0.4", path = "crates/ty_vendored" } +ty_vendored = { version = "0.0.5", path = "crates/ty_vendored" } mdtest = { path = "crates/mdtest" } diff --git a/README.md b/README.md index 1302a04aea..e77738e943 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh powershell -c "irm https://astral.sh/ruff/install.ps1 | iex" # For a specific version. -curl -LsSf https://astral.sh/ruff/0.15.21/install.sh | sh -powershell -c "irm https://astral.sh/ruff/0.15.21/install.ps1 | iex" +curl -LsSf https://astral.sh/ruff/0.15.22/install.sh | sh +powershell -c "irm https://astral.sh/ruff/0.15.22/install.ps1 | iex" ``` You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff), @@ -186,7 +186,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.21 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index 9a7ed66590..0a1533cda3 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff" -version = "0.15.21" +version = "0.15.22" description = "An extremely fast Python linter and code formatter" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff/README.md b/crates/ruff/README.md index e6e92a36f7..f9aa90fe1b 100644 --- a/crates/ruff/README.md +++ b/crates/ruff/README.md @@ -10,7 +10,7 @@ See the [documentation](https://docs.astral.sh/ruff/) or This crate is the entry point to the Ruff command-line interface. The Rust API exposed here is not considered public interface. -This is version 0.15.21. The source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff). +This is version 0.15.22. The source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff). The following Ruff workspace members are also available: diff --git a/crates/ruff_annotate_snippets/Cargo.toml b/crates/ruff_annotate_snippets/Cargo.toml index d039a84724..d40beddeec 100644 --- a/crates/ruff_annotate_snippets/Cargo.toml +++ b/crates/ruff_annotate_snippets/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_annotate_snippets" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_cache/Cargo.toml b/crates/ruff_cache/Cargo.toml index cad5e6a3c9..697f7e65b6 100644 --- a/crates/ruff_cache/Cargo.toml +++ b/crates/ruff_cache/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_cache" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_cache/README.md b/crates/ruff_cache/README.md index db062d35fe..2a8e9a65a2 100644 --- a/crates/ruff_cache/README.md +++ b/crates/ruff_cache/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_cache). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_cache). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_db/Cargo.toml b/crates/ruff_db/Cargo.toml index 35cc319e1c..757321d855 100644 --- a/crates/ruff_db/Cargo.toml +++ b/crates/ruff_db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_db" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_db/README.md b/crates/ruff_db/README.md index d22b4fe108..d755d0a8bd 100644 --- a/crates/ruff_db/README.md +++ b/crates/ruff_db/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_db). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_db). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_diagnostics/Cargo.toml b/crates/ruff_diagnostics/Cargo.toml index e887f1a94d..233189ed8e 100644 --- a/crates/ruff_diagnostics/Cargo.toml +++ b/crates/ruff_diagnostics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_diagnostics" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_diagnostics/README.md b/crates/ruff_diagnostics/README.md index 481a3ecff2..c606f2fa91 100644 --- a/crates/ruff_diagnostics/README.md +++ b/crates/ruff_diagnostics/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_diagnostics). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_diagnostics). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_formatter/Cargo.toml b/crates/ruff_formatter/Cargo.toml index 0292a467ba..fd5568e375 100644 --- a/crates/ruff_formatter/Cargo.toml +++ b/crates/ruff_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_formatter" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_formatter/README.md b/crates/ruff_formatter/README.md index 4ba4ae9fb3..4701c296b6 100644 --- a/crates/ruff_formatter/README.md +++ b/crates/ruff_formatter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_formatter). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_graph/Cargo.toml b/crates/ruff_graph/Cargo.toml index 7ad3961578..c239f328e8 100644 --- a/crates/ruff_graph/Cargo.toml +++ b/crates/ruff_graph/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_graph" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ruff_graph/README.md b/crates/ruff_graph/README.md index a529146b33..6100ed4cc0 100644 --- a/crates/ruff_graph/README.md +++ b/crates/ruff_graph/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_graph). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_graph). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_index/Cargo.toml b/crates/ruff_index/Cargo.toml index 639ba89c2d..b9817e75b5 100644 --- a/crates/ruff_index/Cargo.toml +++ b/crates/ruff_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_index" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_index/README.md b/crates/ruff_index/README.md index ca40eb9e69..d99bf84ffb 100644 --- a/crates/ruff_index/README.md +++ b/crates/ruff_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_index). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index ff47dc150d..77c7b8a4cb 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.15.21" +version = "0.15.22" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_linter/README.md b/crates/ruff_linter/README.md index f71ed24dda..ba776d0d57 100644 --- a/crates/ruff_linter/README.md +++ b/crates/ruff_linter/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.15.21) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_linter). +This version (0.15.22) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_linter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs index c255d5ea53..d01abd0e6d 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/noqa_comments.rs @@ -58,7 +58,7 @@ use crate::{ /// `unused-noqa` for a rule that will remove these and allow the remaining codes to be moved into a /// `ruff:ignore` comment. #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +#[violation_metadata(preview_since = "0.15.22")] pub(crate) struct NoqaComments { file_level: bool, } diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs index 9b99508579..9179f982c8 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_selectors.rs @@ -35,7 +35,7 @@ use crate::{ /// select = ["unused-import"] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +#[violation_metadata(preview_since = "0.15.22")] pub(crate) struct RuleCodesInSelectors { selector: &'static str, name: &'static str, diff --git a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs index 0ee0ff0742..53d42e3cc5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/rule_codes_in_suppression_comments.rs @@ -25,7 +25,7 @@ use crate::AlwaysFixableViolation; /// import os # ruff:ignore[unused-import] /// ``` #[derive(ViolationMetadata)] -#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")] +#[violation_metadata(preview_since = "0.15.22")] pub(crate) struct RuleCodesInSuppressionComments; impl AlwaysFixableViolation for RuleCodesInSuppressionComments { diff --git a/crates/ruff_macros/Cargo.toml b/crates/ruff_macros/Cargo.toml index 3a609b4a26..3b0320b491 100644 --- a/crates/ruff_macros/Cargo.toml +++ b/crates/ruff_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_macros" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_macros/README.md b/crates/ruff_macros/README.md index af4e570a9e..127dbd027c 100644 --- a/crates/ruff_macros/README.md +++ b/crates/ruff_macros/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_macros). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_macros). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_markdown/Cargo.toml b/crates/ruff_markdown/Cargo.toml index f6587b2368..e7106dcf34 100644 --- a/crates/ruff_markdown/Cargo.toml +++ b/crates/ruff_markdown/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_markdown" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ruff_markdown/README.md b/crates/ruff_markdown/README.md index 343703f403..f6007c1178 100644 --- a/crates/ruff_markdown/README.md +++ b/crates/ruff_markdown/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_markdown). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_markdown). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_memory_usage/Cargo.toml b/crates/ruff_memory_usage/Cargo.toml index 442cdaeb73..5758dc8979 100644 --- a/crates/ruff_memory_usage/Cargo.toml +++ b/crates/ruff_memory_usage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_memory_usage" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_memory_usage/README.md b/crates/ruff_memory_usage/README.md index f3e37905d6..75700ae5d3 100644 --- a/crates/ruff_memory_usage/README.md +++ b/crates/ruff_memory_usage/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_memory_usage). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_memory_usage). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_notebook/Cargo.toml b/crates/ruff_notebook/Cargo.toml index 8802dd74cd..c5216cb7e2 100644 --- a/crates/ruff_notebook/Cargo.toml +++ b/crates/ruff_notebook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_notebook" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_notebook/README.md b/crates/ruff_notebook/README.md index 6d4cd2d05c..1f01efbdc5 100644 --- a/crates/ruff_notebook/README.md +++ b/crates/ruff_notebook/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_notebook). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_notebook). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_options_metadata/Cargo.toml b/crates/ruff_options_metadata/Cargo.toml index d35695f9ac..4e6b438efe 100644 --- a/crates/ruff_options_metadata/Cargo.toml +++ b/crates/ruff_options_metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_options_metadata" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_options_metadata/README.md b/crates/ruff_options_metadata/README.md index f5b989fd51..4a425586f4 100644 --- a/crates/ruff_options_metadata/README.md +++ b/crates/ruff_options_metadata/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_options_metadata). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_options_metadata). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index 338af11d14..48a4d38e05 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_ast" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_ast/README.md b/crates/ruff_python_ast/README.md index 738181eb10..246cbdc200 100644 --- a/crates/ruff_python_ast/README.md +++ b/crates/ruff_python_ast/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_ast). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_ast). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_codegen/Cargo.toml b/crates/ruff_python_codegen/Cargo.toml index 739a1ed45b..14d83eaf54 100644 --- a/crates/ruff_python_codegen/Cargo.toml +++ b/crates/ruff_python_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_codegen" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_codegen/README.md b/crates/ruff_python_codegen/README.md index 4be1cdc562..c998c19eaf 100644 --- a/crates/ruff_python_codegen/README.md +++ b/crates/ruff_python_codegen/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_codegen). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_codegen). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index a215af3cbe..c7fe7d8f8d 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_formatter" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_formatter/README.md b/crates/ruff_python_formatter/README.md index ea88a40106..af1d9cb289 100644 --- a/crates/ruff_python_formatter/README.md +++ b/crates/ruff_python_formatter/README.md @@ -32,8 +32,8 @@ Head to [The Ruff Formatter](https://docs.astral.sh/ruff/formatter/) for usage i This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_formatter). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_formatter). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_importer/Cargo.toml b/crates/ruff_python_importer/Cargo.toml index d1d675d0fd..2f65a28a62 100644 --- a/crates/ruff_python_importer/Cargo.toml +++ b/crates/ruff_python_importer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_importer" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_importer/README.md b/crates/ruff_python_importer/README.md index eba92d21dc..594ef8b98b 100644 --- a/crates/ruff_python_importer/README.md +++ b/crates/ruff_python_importer/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_importer). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_importer). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_index/Cargo.toml b/crates/ruff_python_index/Cargo.toml index f47cbe0a58..8278131bbd 100644 --- a/crates/ruff_python_index/Cargo.toml +++ b/crates/ruff_python_index/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_index" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_index/README.md b/crates/ruff_python_index/README.md index 1ea8ebedde..0a11540f61 100644 --- a/crates/ruff_python_index/README.md +++ b/crates/ruff_python_index/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_index). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_index). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_literal/Cargo.toml b/crates/ruff_python_literal/Cargo.toml index 47096437d6..1fd8bebb7f 100644 --- a/crates/ruff_python_literal/Cargo.toml +++ b/crates/ruff_python_literal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_literal" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } diff --git a/crates/ruff_python_literal/README.md b/crates/ruff_python_literal/README.md index 1d271bf256..afdcd0368b 100644 --- a/crates/ruff_python_literal/README.md +++ b/crates/ruff_python_literal/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_literal). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_literal). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_parser/Cargo.toml b/crates/ruff_python_parser/Cargo.toml index c88c116faf..9d92940929 100644 --- a/crates/ruff_python_parser/Cargo.toml +++ b/crates/ruff_python_parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_parser" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = ["Charlie Marsh ", "RustPython Team"] edition = { workspace = true } diff --git a/crates/ruff_python_parser/README.md b/crates/ruff_python_parser/README.md index 2b24f98eba..e8a07ae1e2 100644 --- a/crates/ruff_python_parser/README.md +++ b/crates/ruff_python_parser/README.md @@ -19,8 +19,8 @@ Refer to the [contributing guidelines](./CONTRIBUTING.md) to get started and Git This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_parser). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_parser). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_semantic/Cargo.toml b/crates/ruff_python_semantic/Cargo.toml index c5fd2f388e..ed753c2ed0 100644 --- a/crates/ruff_python_semantic/Cargo.toml +++ b/crates/ruff_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_semantic" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_semantic/README.md b/crates/ruff_python_semantic/README.md index 10abff75e1..bd19e639c7 100644 --- a/crates/ruff_python_semantic/README.md +++ b/crates/ruff_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_semantic). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_stdlib/Cargo.toml b/crates/ruff_python_stdlib/Cargo.toml index 7714197cc5..61e9b6309f 100644 --- a/crates/ruff_python_stdlib/Cargo.toml +++ b/crates/ruff_python_stdlib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_stdlib" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_stdlib/README.md b/crates/ruff_python_stdlib/README.md index 5903367dfe..720253982a 100644 --- a/crates/ruff_python_stdlib/README.md +++ b/crates/ruff_python_stdlib/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_stdlib). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_stdlib). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_python_trivia/Cargo.toml b/crates/ruff_python_trivia/Cargo.toml index 1849b6de16..e00ccd86b4 100644 --- a/crates/ruff_python_trivia/Cargo.toml +++ b/crates/ruff_python_trivia/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_python_trivia" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_python_trivia/README.md b/crates/ruff_python_trivia/README.md index b9bcb1c7e1..ea990d2d33 100644 --- a/crates/ruff_python_trivia/README.md +++ b/crates/ruff_python_trivia/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_python_trivia). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_python_trivia). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_ranged_value/Cargo.toml b/crates/ruff_ranged_value/Cargo.toml index d43902559d..df3e113867 100644 --- a/crates/ruff_ranged_value/Cargo.toml +++ b/crates/ruff_ranged_value/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_ranged_value" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_ranged_value/README.md b/crates/ruff_ranged_value/README.md index 023af142c3..eb36cea302 100644 --- a/crates/ruff_ranged_value/README.md +++ b/crates/ruff_ranged_value/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_ranged_value). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_ranged_value). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index abffd61924..808f812876 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_server" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_server/README.md b/crates/ruff_server/README.md index e9bec02362..9f1ed8c62f 100644 --- a/crates/ruff_server/README.md +++ b/crates/ruff_server/README.md @@ -24,8 +24,8 @@ You can also join us on [**Discord**](https://discord.com/invite/astral-sh). This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_server). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_server). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_source_file/Cargo.toml b/crates/ruff_source_file/Cargo.toml index 3c1ae15c81..b713313d5a 100644 --- a/crates/ruff_source_file/Cargo.toml +++ b/crates/ruff_source_file/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_source_file" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_source_file/README.md b/crates/ruff_source_file/README.md index 1e7a464240..693ee2da7a 100644 --- a/crates/ruff_source_file/README.md +++ b/crates/ruff_source_file/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_source_file). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_source_file). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_text_size/Cargo.toml b/crates/ruff_text_size/Cargo.toml index 5e69e00997..fcd8305cde 100644 --- a/crates/ruff_text_size/Cargo.toml +++ b/crates/ruff_text_size/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_text_size" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_text_size/README.md b/crates/ruff_text_size/README.md index cbb2505bcc..21887ec343 100644 --- a/crates/ruff_text_size/README.md +++ b/crates/ruff_text_size/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_text_size). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_text_size). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index 5986913722..f7c7ac1871 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.15.21" +version = "0.15.22" description = "WebAssembly bindings for Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_wasm/README.md b/crates/ruff_wasm/README.md index 7881507b87..b58015c3f6 100644 --- a/crates/ruff_wasm/README.md +++ b/crates/ruff_wasm/README.md @@ -55,8 +55,8 @@ const formatted = workspace.format(exampleDocument); This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.15.21) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_wasm). +This version (0.15.22) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_wasm). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index b35ce34304..7aaae335ca 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_workspace" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_workspace/README.md b/crates/ruff_workspace/README.md index 40a1db01bd..6a9c1eb472 100644 --- a/crates/ruff_workspace/README.md +++ b/crates/ruff_workspace/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ruff_workspace). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ruff_workspace). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_combine/Cargo.toml b/crates/ty_combine/Cargo.toml index b27ab8d12e..acafd2457a 100644 --- a/crates/ty_combine/Cargo.toml +++ b/crates/ty_combine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_combine" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" edition.workspace = true rust-version.workspace = true diff --git a/crates/ty_combine/README.md b/crates/ty_combine/README.md index e929516dc9..6542e860cc 100644 --- a/crates/ty_combine/README.md +++ b/crates/ty_combine/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_combine). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_combine). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_module_resolver/Cargo.toml b/crates/ty_module_resolver/Cargo.toml index 3ae75426f0..d75f8c4548 100644 --- a/crates/ty_module_resolver/Cargo.toml +++ b/crates/ty_module_resolver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_module_resolver" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_module_resolver/README.md b/crates/ty_module_resolver/README.md index 6d0a6be112..3c34b69455 100644 --- a/crates/ty_module_resolver/README.md +++ b/crates/ty_module_resolver/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_module_resolver). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_module_resolver). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_core/Cargo.toml b/crates/ty_python_core/Cargo.toml index 08bef060c2..0fda750f44 100644 --- a/crates/ty_python_core/Cargo.toml +++ b/crates/ty_python_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_core" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_python_core/README.md b/crates/ty_python_core/README.md index 9510919965..7b9226b17f 100644 --- a/crates/ty_python_core/README.md +++ b/crates/ty_python_core/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_python_core). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_python_core). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index d70a876753..5885ee9ba9 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_python_semantic" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_python_semantic/README.md b/crates/ty_python_semantic/README.md index cf3010067a..0e52fd63d6 100644 --- a/crates/ty_python_semantic/README.md +++ b/crates/ty_python_semantic/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_python_semantic). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_python_semantic). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_site_packages/Cargo.toml b/crates/ty_site_packages/Cargo.toml index 76560f7afa..65dae5d5c4 100644 --- a/crates/ty_site_packages/Cargo.toml +++ b/crates/ty_site_packages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_site_packages" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ty_site_packages/README.md b/crates/ty_site_packages/README.md index 3952b325da..935980dcbf 100644 --- a/crates/ty_site_packages/README.md +++ b/crates/ty_site_packages/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_site_packages). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_site_packages). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_static/Cargo.toml b/crates/ty_static/Cargo.toml index 9de7f22d66..dd649170e8 100644 --- a/crates/ty_static/Cargo.toml +++ b/crates/ty_static/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_static" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/ty_static/README.md b/crates/ty_static/README.md index 55834c7bd6..34c43bf209 100644 --- a/crates/ty_static/README.md +++ b/crates/ty_static/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [Ruff](https://crates.io/crates/ruff). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.4) is a component of [Ruff 0.15.21](https://crates.io/crates/ruff/0.15.21). The -source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.21/crates/ty_static). +This version (0.0.5) is a component of [Ruff 0.15.22](https://crates.io/crates/ruff/0.15.22). The +source can be found [here](https://github.com/astral-sh/ruff/blob/0.15.22/crates/ty_static). See Ruff's [crate versioning policy](https://docs.astral.sh/ruff/versioning/#crate-versioning) for details on versioning. diff --git a/crates/ty_vendored/Cargo.toml b/crates/ty_vendored/Cargo.toml index dc0b928a66..2ba738ffc7 100644 --- a/crates/ty_vendored/Cargo.toml +++ b/crates/ty_vendored/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ty_vendored" -version = "0.0.4" +version = "0.0.5" description = "This is an internal component crate of Ruff" authors = { workspace = true } edition = { workspace = true } diff --git a/docs/formatter.md b/docs/formatter.md index 87e7296bc0..aee65ee133 100644 --- a/docs/formatter.md +++ b/docs/formatter.md @@ -306,7 +306,7 @@ support needs to be explicitly included by adding it to `types_or`: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.21 + rev: v0.15.22 hooks: - id: ruff-format types_or: [python, pyi, jupyter, markdown] diff --git a/docs/integrations.md b/docs/integrations.md index b2ddc6ad07..2645d5ab7a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -80,7 +80,7 @@ You can add the following configuration to `.gitlab-ci.yml` to run a `ruff forma stage: build interruptible: true image: - name: ghcr.io/astral-sh/ruff:0.15.21-alpine + name: ghcr.io/astral-sh/ruff:0.15.22-alpine before_script: - cd $CI_PROJECT_DIR - ruff --version @@ -106,7 +106,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.21 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check @@ -119,7 +119,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.21 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check @@ -133,7 +133,7 @@ To avoid running on Jupyter Notebooks, remove `jupyter` from the list of allowed ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.21 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check diff --git a/docs/tutorial.md b/docs/tutorial.md index ce731e35d3..450dbc81df 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -372,7 +372,7 @@ This tutorial has focused on Ruff's command-line interface, but Ruff can also be ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.21 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check diff --git a/pyproject.toml b/pyproject.toml index b8aab55786..897f46a027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ruff" -version = "0.15.21" +version = "0.15.22" description = "An extremely fast Python linter and code formatter, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] readme = "README.md" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index 0ae5a78981..72d3f15a68 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scripts" -version = "0.15.21" +version = "0.15.22" description = "" authors = ["Charles Marsh "] diff --git a/uv.lock b/uv.lock index ba760a617d..a5e12af15b 100644 --- a/uv.lock +++ b/uv.lock @@ -30,8 +30,8 @@ name = "anyio" version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -43,7 +43,7 @@ name = "anysqlite" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } wheels = [ @@ -64,7 +64,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "python_full_version >= '3.12' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -176,11 +176,11 @@ name = "hishel" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "anysqlite" }, - { name = "httpx" }, - { name = "msgpack" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "anysqlite", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "msgpack", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468, upload-time = "2025-10-18T13:32:41.854Z" } wheels = [ @@ -192,8 +192,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -205,10 +205,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "httpcore", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -229,7 +229,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -374,10 +374,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version >= '3.12'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -389,7 +389,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -519,7 +519,7 @@ name = "pygit2" version = "1.19.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/44/415aa93422b4bfc21a6448acb7e16280d5f33a9a3fae38a384e37b046ae4/pygit2-1.19.3.tar.gz", hash = "sha256:a543e6d4ebb43825564935758dc234e770016fed673b84370d46ae9580558831", size = 810489, upload-time = "2026-06-13T08:06:04.982Z" } wheels = [ @@ -594,8 +594,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -607,14 +607,14 @@ name = "rooster" version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hishel" }, - { name = "httpx" }, - { name = "marko" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pygit2" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "hishel", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "marko", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "pygit2", marker = "python_full_version >= '3.12'" }, + { name = "tqdm", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/02/8ce565271dc52bd0d0d812043b12ec60111d947f81dc30301d19d7bfd453/rooster-0.1.1.tar.gz", hash = "sha256:c9823122f0c2b035985e70384323cdd353477af988e0f065bc302646a49da482", size = 18608, upload-time = "2025-10-29T15:18:49.478Z" } wheels = [ @@ -623,7 +623,7 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" +version = "0.15.22" source = { editable = "." } [package.dev-dependencies] @@ -654,7 +654,7 @@ name = "tqdm" version = "4.68.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } wheels = [ @@ -666,10 +666,10 @@ name = "typer" version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "shellingham", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ @@ -690,7 +690,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From 16dca0f4b8c938ece1b0252d18c2142e6452c3ab Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 16 Jul 2026 14:05:30 -0400 Subject: [PATCH 118/222] [ty] Enforce declared receiver TypeVar domains (#26863) ## Summary Right now, when an annotated receiver uses a TypeVar with a declared bound or constraints, the stored relation only includes the inferred receiver constraint. The TypeVar is then removed during signature comparison without restricting to the bounds, allowing for an invalid concrete receiver: ```python from typing import Callable class Receiver: def method[T: int](self: T) -> None: ... callback: Callable[[], None] = Receiver().method # error ``` This PR intersects the receiver relation with `BoundTypeVarInstance::valid_specializations` when the bound method is created. --- .../mdtest/generics/pep695/callables.md | 35 ++++++++++--- .../resources/mdtest/protocols.md | 28 +++++++--- .../src/types/signatures.rs | 51 ++++++++++++++++++- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md index 3dfad84c6a..5092037f6c 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/callables.md @@ -68,22 +68,45 @@ accepts_object: Callable[[object], object] = receiver.method accepts_int: Callable[[int], int] = receiver.method # error: [invalid-assignment] ``` -Declared bounds and constraints on receiver TypeVars are not yet enforced when comparing a bound -method with another callable: +The receiver must also satisfy a method type variable's declared bound or constraints: ```py from typing import Callable class InvalidBoundedReceiver: - # TODO: Binding should reject this method because the receiver is outside `T`'s declared bound. + def method[T: int](self: T) -> None: ... + +class ValidBoundedReceiver(int): def method[T: int](self: T) -> None: ... class InvalidConstrainedReceiver: - # TODO: Binding should reject this method because the receiver is outside `T`'s constraints. def method[T: (int, str)](self: T) -> None: ... -invalid_bound: Callable[[], None] = InvalidBoundedReceiver().method -invalid_constraints: Callable[[], None] = InvalidConstrainedReceiver().method +class ValidConstrainedReceiver(str): + def method[T: (int, str)](self: T) -> None: ... + +type ReceiverAlias[T] = T + +class InvalidAliasedBoundedReceiver: + def method[T: int](self: ReceiverAlias[T]) -> None: ... + +class InvalidNestedBoundedReceiver(list[str]): + def method[T: int](self: list[T]) -> None: ... + +class InvalidUnionConstrainedReceiver: + def method[T: (int, str)](self: T | None) -> None: ... + +invalid_bound: Callable[[], None] = InvalidBoundedReceiver().method # error: [invalid-assignment] +valid_bound: Callable[[], None] = ValidBoundedReceiver().method + +invalid_constraints: Callable[[], None] = InvalidConstrainedReceiver().method # error: [invalid-assignment] +valid_constraints: Callable[[], None] = ValidConstrainedReceiver().method + +invalid_aliased_bound: Callable[[], None] = InvalidAliasedBoundedReceiver().method # error: [invalid-assignment] + +# TODO: Enforce valid specializations for TypeVars nested inside receiver annotations. +invalid_nested_bound: Callable[[], None] = InvalidNestedBoundedReceiver().method # TODO: error: [invalid-assignment] +invalid_union_constraints: Callable[[], None] = InvalidUnionConstrainedReceiver().method # TODO: error: [invalid-assignment] ``` When we coerce a generic callable into a `Callable` type, it remembers that it is generic: diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index c8415b748d..9853202d8a 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -3763,13 +3763,20 @@ class ReceiverOnly(Protocol): def method(self) -> None: ... class InvalidBoundedReceiver: - # TODO: Use `BoundTypeVarInstance::valid_specializations` to reject this receiver. + def method[T: int](self: T) -> None: ... + +class ValidBoundedReceiver(int): def method[T: int](self: T) -> None: ... class InvalidConstrainedReceiver: - # TODO: Use `BoundTypeVarInstance::valid_specializations` to reject this receiver. def method[T: (int, str)](self: T) -> None: ... +class ValidConstrainedReceiver(str): + def method[T: (int, str)](self: T) -> None: ... + +class RecursiveReceiverBound: + def method[T: ReceiverOnly](self: T) -> None: ... + static_assert(is_equivalent_to(LegacyFunctionScoped, NewStyleFunctionScoped)) static_assert(is_assignable_to(NominalNewStyle, NewStyleFunctionScoped)) static_assert(is_assignable_to(NominalNewStyle, LegacyFunctionScoped)) @@ -3821,10 +3828,19 @@ static_assert(is_subtype_of(StructuralExplicitReceiver, ExplicitReceiverProtocol static_assert(not is_assignable_to(OverloadedExplicitReceiverImplementation, OverloadedExplicitReceiverProtocol)) static_assert(not is_subtype_of(OverloadedExplicitReceiverImplementation, OverloadedExplicitReceiverProtocol)) -static_assert(is_assignable_to(InvalidBoundedReceiver, ReceiverOnly)) -static_assert(is_subtype_of(InvalidBoundedReceiver, ReceiverOnly)) -static_assert(is_assignable_to(InvalidConstrainedReceiver, ReceiverOnly)) -static_assert(is_subtype_of(InvalidConstrainedReceiver, ReceiverOnly)) +# A bound receiver must choose a specialization within its declared domain. +static_assert(not is_assignable_to(InvalidBoundedReceiver, ReceiverOnly)) +static_assert(not is_subtype_of(InvalidBoundedReceiver, ReceiverOnly)) +static_assert(is_assignable_to(ValidBoundedReceiver, ReceiverOnly)) +static_assert(is_subtype_of(ValidBoundedReceiver, ReceiverOnly)) +static_assert(not is_assignable_to(InvalidConstrainedReceiver, ReceiverOnly)) +static_assert(not is_subtype_of(InvalidConstrainedReceiver, ReceiverOnly)) +static_assert(is_assignable_to(ValidConstrainedReceiver, ReceiverOnly)) +static_assert(is_subtype_of(ValidConstrainedReceiver, ReceiverOnly)) + +# Verifying the receiver's bound recurses into the protocol relation currently being checked. +static_assert(is_assignable_to(RecursiveReceiverBound, ReceiverOnly)) +static_assert(is_subtype_of(RecursiveReceiverBound, ReceiverOnly)) # These test cases are taken from the typing conformance suite: class ShapeProtocolImplicitSelf(Protocol): diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 4d8597d40d..5eea757e1c 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -39,8 +39,8 @@ use crate::types::{ ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance, CallableType, ErrorContext, ErrorContextTree, FindLegacyTypeVarsVisitor, KnownClass, MaterializationKind, ParamSpecAttrKind, ParameterDescription, SelfBinding, TypeContext, - TypeMapping, TypeVarNonce, TypedDictType, UnionBuilder, VarianceInferable, - infer_complete_scope_types, todo_type, + TypeMapping, TypeVarBoundOrConstraints, TypeVarNonce, TypedDictType, UnionBuilder, + VarianceInferable, infer_complete_scope_types, todo_type, }; use crate::{Db, FxOrderSet}; use ruff_python_ast::{self as ast, name::Name}; @@ -1072,6 +1072,19 @@ impl<'db> Signature<'db> { } else { parameter.annotated_type() }; + // TODO: Also intersect nested receiver type variables, such as the `T` in + // `self: list[T]`, with their valid specializations when constructing or solving the + // receiver constraint set. + let receiver_typevar = match annotation { + Type::TypeVar(typevar) => Some(typevar), + Type::TypeAlias(_) => annotation.resolve_type_alias(db).as_typevar(), + _ => None, + }; + if receiver_typevar.is_some_and(|typevar| { + Self::receiver_violates_typevar_domain(db, receiver, typevar) + }) { + return std::borrow::Cow::Owned(OwnedConstraintSet::default()); + } receiver.when_constraint_set_assignable_to_owned(db, annotation) }); let receiver_constraints = merge_receiver_constraints( @@ -1103,6 +1116,40 @@ impl<'db> Signature<'db> { } } + /// Returns whether a concrete receiver violates a direct receiver type variable's domain. + /// + /// Unbounded or non-concrete receivers do not provably violate the domain and return `false`, + /// leaving the original receiver relation available to normal inference. Transparent PEP 695 + /// receiver aliases are resolved by the caller before this check. + /// + /// ```python + /// class C: + /// def method[T: int](self: T) -> None: ... + /// ``` + fn receiver_violates_typevar_domain( + db: &'db dyn Db, + receiver: Type<'db>, + typevar: BoundTypeVarInstance<'db>, + ) -> bool { + let Some(domain) = typevar.typevar(db).bound_or_constraints(db) else { + return false; + }; + if receiver.has_typevar(db) { + return false; + } + + !match domain { + TypeVarBoundOrConstraints::UpperBound(bound) => { + receiver.is_assignable_to(db, bound.top_materialization(db)) + } + TypeVarBoundOrConstraints::Constraints(constraints) => { + constraints.elements(db).iter().any(|constraint| { + receiver.is_assignable_to(db, constraint.top_materialization(db)) + }) + } + } + } + /// Returns `true` if this signature's first parameter can accept the bound `self` type. /// /// This is used to prune impossible overloads when a method is bound to a concrete receiver. From 1797ed542380c5aa0f7832e3877b1df56ddb2e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Thu, 16 Jul 2026 11:41:47 -0700 Subject: [PATCH 119/222] [ty] Remove duplicated docstring parsing test (#26893) ## Summary Despite its name, `test_numpy_style_with_different_indentation` was identical to [`test_numpy_style_parameter_documentation`](https://github.com/astral-sh/ruff/blob/16dca0f4b8c938ece1b0252d18c2142e6452c3ab/crates/ty_ide/src/docstring.rs#L1307-L1383). ## Test Plan N/A --- crates/ty_ide/src/docstring.rs | 78 ---------------------------------- 1 file changed, 78 deletions(-) diff --git a/crates/ty_ide/src/docstring.rs b/crates/ty_ide/src/docstring.rs index 40a8a91da1..f61a733138 100644 --- a/crates/ty_ide/src/docstring.rs +++ b/crates/ty_ide/src/docstring.rs @@ -1684,84 +1684,6 @@ Summary. "); } - #[test] - fn test_numpy_style_with_different_indentation() { - let _snap = bind_docstring_snapshot_filters(); - let docstring = r#" - This is a function description. - - Parameters - ---------- - param1 : str - The first parameter description - param2 : int - The second parameter description - This is a continuation of param2 description. - param3 - A parameter without type annotation - - Returns - ------- - str - The return value description - "#; - - let docstring = Docstring::new(docstring.to_owned()); - let param_docs = docstring.parameter_documentation(); - - assert_eq!(param_docs.len(), 3); - assert_eq!( - param_docs.get("param1").expect("param1 should exist"), - "The first parameter description" - ); - assert_eq!( - param_docs.get("param2").expect("param2 should exist"), - "The second parameter description\nThis is a continuation of param2 description." - ); - assert_eq!( - param_docs.get("param3").expect("param3 should exist"), - "A parameter without type annotation" - ); - - assert_snapshot!(docstring.render_plaintext(), @" - This is a function description. - - Parameters - ---------- - param1 : str - The first parameter description - param2 : int - The second parameter description - This is a continuation of param2 description. - param3 - A parameter without type annotation - - Returns - ------- - str - The return value description - "); - - assert_snapshot!(docstring.render_markdown(), @" - This is a function description. - - Parameters - ---------- - param1 : str -     The first parameter description - param2 : int -     The second parameter description -     This is a continuation of param2 description. - param3 -     A parameter without type annotation - - Returns - ------- - str -     The return value description - "); - } - #[test] fn test_numpy_style_with_tabs_and_mixed_indentation() { let _snap = bind_docstring_snapshot_filters(); From 301b7ed4d623458bc70d9e194ec6dd9e7ae26959 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 16 Jul 2026 14:53:48 -0400 Subject: [PATCH 120/222] [ty] Share heap-backed parser names with CharStr (#26594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR changes `ruff_python_ast::Name` from a 24-byte `CompactString` to a 16-byte immutable `CharStr` from [`astral-sh/char_str`](https://github.com/astral-sh/char_str). `char_str` is an Astral-maintained fork of [`lean_string`](https://github.com/ryota2357/lean_string) that provides compact owned strings with mutable and immutable variants: growable `CharString` for construction and immutable, exactly allocated `CharStr` for storage. (It also provides Salsa and get-size features.) Names up to 16 UTF-8 bytes remain inline, static names remain allocation-free, and longer names use an exactly sized, reference-counted allocation with no unused capacity. Removing eight bytes from every `Name` also shrinks common AST nodes. For example, `Identifier` and `ExprName` drop from 40 to 32 bytes, while `ExprAttribute`, `StmtFunctionDef`, `StmtClassDef`, and several other variants each drop by eight bytes. The parser interns names that exceed the inline capacity, so repeated longer names share one backing allocation within a parser invocation. ## Why 16 bytes? Across the full `mypy_primer` corpus, there 20.8 million name occurrences: | UTF-8 length | Occurrences | Share | Unique name/file pairs | Share | |---|---:|---:|---:|---:| | 1–16 bytes | 18,955,757 | 91.02% | 3,192,424 | 79.42% | | 17–24 bytes | 1,348,894 | 6.48% | 529,826 | 13.18% | | 25+ bytes | 522,376 | 2.51% | 297,575 | 7.40% | | Total | 20,827,027 | 100% | 4,019,825 | 100% | The 16-byte cutoff keeps 91% of occurrences inline while saving eight bytes for every occurrence. The 1–16-byte bucket alone removes 151.6 MB of handle storage across the corpus. The tradeoff is the 17–24-byte bucket: `CompactString` stored those names inline, while `CharStr` allocates once per unique name within a file. But the interning amortizes that cost when a name repeats. Names longer than 24 bytes already allocated on `main`; for those names, this change both reduces the handle size and deduplicates repeated backing allocations. ## Memory impact Accounting for the observed per-file multiplicities and structural sharing, modeled `Name` storage across the corpus changes as follows: | | `main` | This PR | Difference | |---|---:|---:|---:| | `Name` handles and backing storage | 526.18 MB | 366.27 MB | -159.91 MB (-30.4%) | The current [instrumented memory report](https://github.com/astral-sh/ruff/pull/26594#issuecomment-4906725175) shows a consistent reduction in total retained memory: | Project | `main` | This PR | Difference | |---|---:|---:|---:| | flake8 | 29.21 MB | 28.71 MB | -1.69% | | trio | 71.01 MB | 70.01 MB | -1.41% | | sphinx | 168.81 MB | 166.82 MB | -1.18% | | prefect | 454.75 MB | 449.06 MB | -1.25% | Within those totals, `parsed_module` decreases by roughly 2.1% and `semantic_index` by 2.4–2.7%. Several name-heavy collections decrease by considerably more. --- Cargo.lock | 18 +- Cargo.toml | 1 + crates/ruff_cache/Cargo.toml | 1 + crates/ruff_cache/src/cache_key.rs | 8 + crates/ruff_python_ast/Cargo.toml | 5 +- crates/ruff_python_ast/src/name.rs | 148 ++++++++----- crates/ruff_python_ast/src/nodes.rs | 12 +- crates/ruff_python_parser/Cargo.toml | 1 - .../src/parser/expression.rs | 6 +- crates/ruff_python_parser/src/parser/mod.rs | 54 ++++- .../src/parser/statement.rs | 19 +- crates/ruff_python_parser/src/parser/tests.rs | 13 ++ .../src/call_hierarchy/incoming_calls.rs | 4 +- crates/ty_ide/src/completion.rs | 8 +- crates/ty_python_core/Cargo.toml | 1 + crates/ty_python_core/src/builder.rs | 5 +- crates/ty_python_core/src/member.rs | 195 ++++++++++++------ crates/ty_python_core/src/symbol.rs | 3 +- crates/ty_python_semantic/Cargo.toml | 1 + .../ty_python_semantic/src/semantic_model.rs | 8 +- .../src/types/class/static_literal.rs | 23 ++- .../src/types/infer/builder/named_tuple.rs | 3 +- .../ty_python_semantic/src/types/typevar.rs | 6 +- fuzz/Cargo.lock | 18 +- ruff.schema.json | 12 +- 25 files changed, 398 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61d491c2e4..f8804713f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,19 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "char_str" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576ba56f6ca18ebb069d0d07260407171712aff4dfe15ee3f8982dc16a455bcf" +dependencies = [ + "castaway", + "get-size2", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "chrono" version = "0.4.44" @@ -3174,6 +3187,7 @@ dependencies = [ name = "ruff_cache" version = "0.0.5" dependencies = [ + "char_str", "filetime", "glob", "globset", @@ -3475,6 +3489,7 @@ dependencies = [ "aho-corasick", "arrayvec", "bitflags 2.13.0", + "char_str", "compact_str", "get-size2", "is-macro", @@ -3592,7 +3607,6 @@ dependencies = [ "anyhow", "bitflags 2.13.0", "bstr", - "compact_str", "datatest-stable", "drop_bomb", "get-size2", @@ -4815,6 +4829,7 @@ dependencies = [ "anyhow", "bitflags 2.13.0", "bitvec", + "char_str", "get-size2", "hashbrown 0.17.1", "itertools 0.15.0", @@ -4848,6 +4863,7 @@ dependencies = [ "anyhow", "bitflags 2.13.0", "camino", + "char_str", "compact_str", "datatest-stable", "drop_bomb", diff --git a/Cargo.toml b/Cargo.toml index e76e79ad67..b67032532f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ authors = ["Charlie Marsh "] license = "MIT" [workspace.dependencies] +char_str = { version = "0.0.2" } ruff = { version = "0.15.22", path = "crates/ruff" } ruff_annotate_snippets = { version = "0.0.5", path = "crates/ruff_annotate_snippets" } ruff_cache = { version = "0.0.5", path = "crates/ruff_cache" } diff --git a/crates/ruff_cache/Cargo.toml b/crates/ruff_cache/Cargo.toml index 697f7e65b6..933e85eae3 100644 --- a/crates/ruff_cache/Cargo.toml +++ b/crates/ruff_cache/Cargo.toml @@ -11,6 +11,7 @@ repository = { workspace = true } license = { workspace = true } [dependencies] +char_str = { workspace = true } filetime = { workspace = true } glob = { workspace = true } globset = { workspace = true } diff --git a/crates/ruff_cache/src/cache_key.rs b/crates/ruff_cache/src/cache_key.rs index e371f4baba..e08606eb9e 100644 --- a/crates/ruff_cache/src/cache_key.rs +++ b/crates/ruff_cache/src/cache_key.rs @@ -7,6 +7,7 @@ use std::num::{ }; use std::path::{Path, PathBuf}; +use char_str::CharStr; use glob::Pattern; use itertools::Itertools; use regex::Regex; @@ -256,6 +257,13 @@ impl CacheKey for String { } } +impl CacheKey for CharStr { + #[inline] + fn cache_key(&self, state: &mut CacheKeyHasher) { + self.as_str().cache_key(state); + } +} + impl CacheKey for Option { #[inline] fn cache_key(&self, state: &mut CacheKeyHasher) { diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index 48a4d38e05..f838d6cc4d 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -26,6 +26,7 @@ ruff_text_size = { workspace = true } aho-corasick = { workspace = true } arrayvec = { workspace = true } bitflags = { workspace = true } +char_str = { workspace = true } compact_str = { workspace = true } get-size2 = { workspace = true, optional = true } is-macro = { workspace = true } @@ -45,10 +46,12 @@ serde = [ "dep:serde", "ruff_text_size/serde", "dep:ruff_cache", + "char_str/serde", "compact_str/serde", "thin-vec/serde", ] -get-size = ["dep:get-size2", "ruff_text_size/get-size"] +get-size = ["dep:get-size2", "char_str/get-size", "ruff_text_size/get-size"] +salsa = ["dep:salsa"] [lints] workspace = true diff --git a/crates/ruff_python_ast/src/name.rs b/crates/ruff_python_ast/src/name.rs index 652529b029..cc4b20f570 100644 --- a/crates/ruff_python_ast/src/name.rs +++ b/crates/ruff_python_ast/src/name.rs @@ -4,10 +4,35 @@ use std::hash::{Hash, Hasher}; use std::ops::Deref; use arrayvec::ArrayVec; +use char_str::{CharStr, CharString}; use crate::Expr; use crate::generated::ExprName; +/// An immutable name. +/// +/// # Choosing a string representation +/// +/// On 64-bit targets, [`CharStr`] occupies 16 bytes and stores up to 16 UTF-8 bytes inline. Longer +/// values use an exactly-sized, reference-counted allocation, so cloning a heap-backed value +/// reuses its allocation. [`compact_str::CompactString`] occupies 24 bytes, stores up to 24 bytes +/// inline, and remains mutable; cloning a heap-backed value copies its contents into a new +/// allocation. +/// +/// Prefer `CharStr` for immutable text that is retained densely or passed between owners, when +/// either the smaller handle or structural sharing offsets the extra heap allocations for values +/// between 17 and 24 bytes. Prefer `CompactString` for uniquely owned text, especially when it is +/// built incrementally, mutated, or commonly falls in that 17-to-24-byte range. +/// +/// `Name` uses `CharStr` because names appear throughout the AST and repeated heap-backed parser +/// names share an allocation. By contrast, [`crate::DebugText`] uses `CompactString` because it +/// builds a uniquely owned buffer incrementally, and `ty_module_resolver::ModuleName` uses +/// `CompactString` because module names can be extended in place. +/// +/// Converting a borrowed `&str` into `CharStr` creates a new value and does not preserve structural +/// sharing. When an API retains text already held in a `CharStr` (including a `Name`), pass or clone +/// the owned value rather than converting it through `&str`. This is especially relevant at Salsa +/// interning boundaries. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[cfg_attr(feature = "salsa", derive(salsa::SalsaValue))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -18,34 +43,58 @@ use crate::generated::ExprName; derive(schemars::JsonSchema), schemars(with = "String") )] -pub struct Name(compact_str::CompactString); +pub struct Name(CharStr); impl Name { #[inline] pub fn empty() -> Self { - Self(compact_str::CompactString::default()) + Self(CharStr::new()) } #[inline] pub fn new(name: impl AsRef) -> Self { - Self(compact_str::CompactString::new(name)) + Self(CharStr::from(name.as_ref())) + } + + /// Creates an inline name, returning `None` if `name` does not fit inline. + #[inline] + pub fn new_inline(name: impl AsRef) -> Option { + CharStr::new_inline(name.as_ref()).map(Self) + } + + /// Creates an exactly-sized, heap-allocated name. + #[inline] + pub fn new_heap(name: impl AsRef) -> Self { + Self(CharStr::new_heap(name.as_ref())) } #[inline] pub const fn new_static(name: &'static str) -> Self { - Self(compact_str::CompactString::const_new(name)) + Self(CharStr::from_static_str(name)) } - pub fn shrink_to_fit(&mut self) { - self.0.shrink_to_fit(); + /// Creates an exactly-sized name by concatenating string slices. + /// + /// The combined length is computed up front, so heap storage is allocated at most once. + #[inline] + pub fn concat>(slices: &[T]) -> Self { + Self(CharStr::concat(slices)) } - pub fn as_str(&self) -> &str { - self.0.as_str() + /// Creates an exactly-sized name by joining string slices with a separator. + /// + /// Like [`Name::concat`], this computes the combined length up front, so heap storage is + /// allocated at most once. For dynamically formatted names, use [`Name::from`] with + /// [`format_char!`](char_str::format_char). If a [`CharStr`] is sufficient, use + /// [`format_char_str!`](char_str::format_char_str) instead. + #[inline] + pub fn join>(slices: &[T], separator: &str) -> Self { + Self(CharStr::join(slices, separator)) } - pub fn push_str(&mut self, s: &str) { - self.0.push_str(s); + #[inline] + pub fn as_str(&self) -> &str { + self.0.as_str() } } @@ -55,13 +104,6 @@ impl Debug for Name { } } -impl std::fmt::Write for Name { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - self.0.push_str(s); - Ok(()) - } -} - impl AsRef for Name { #[inline] fn as_ref(&self) -> &str { @@ -88,7 +130,7 @@ impl Borrow for Name { impl<'a> From<&'a str> for Name { #[inline] fn from(s: &'a str) -> Self { - Name(s.into()) + Name::new(s) } } @@ -102,7 +144,7 @@ impl From for Name { impl<'a> From<&'a String> for Name { #[inline] fn from(s: &'a String) -> Self { - Name(s.into()) + Name::new(s) } } @@ -120,14 +162,35 @@ impl From> for Name { } } -impl From for Name { +#[cfg(feature = "salsa")] +impl salsa::Lookup for &str { + #[inline] + fn into_owned(self) -> Name { + Name::new(self) + } +} + +#[cfg(feature = "salsa")] +impl salsa::HashEqLike<&str> for Name { + #[inline] + fn hash(&self, state: &mut H) { + self.as_str().hash(state); + } + + #[inline] + fn eq(&self, data: &&str) -> bool { + self.as_str() == *data + } +} + +impl From for String { #[inline] - fn from(value: compact_str::CompactString) -> Self { - Self(value) + fn from(name: Name) -> Self { + name.0.into() } } -impl From for compact_str::CompactString { +impl From for CharStr { #[inline] fn from(name: Name) -> Self { name.0 @@ -138,7 +201,7 @@ impl From for compact_str::CompactString { impl salsa::Lookup for Name { #[inline] fn into_owned(self) -> compact_str::CompactString { - self.0 + compact_str::CompactString::new(self.as_str()) } } @@ -146,15 +209,7 @@ impl salsa::Lookup for Name { impl salsa::Lookup for &Name { #[inline] fn into_owned(self) -> compact_str::CompactString { - self.0.clone() - } -} - -#[cfg(feature = "salsa")] -impl salsa::Lookup for &str { - #[inline] - fn into_owned(self) -> Name { - Name::new(self) + compact_str::CompactString::new(self.as_str()) } } @@ -162,12 +217,12 @@ impl salsa::Lookup for &str { impl salsa::HashEqLike for compact_str::CompactString { #[inline] fn hash(&self, state: &mut H) { - std::hash::Hash::hash(self, state); + Hash::hash(self, state); } #[inline] fn eq(&self, data: &Name) -> bool { - self == data.as_str() + self.as_str() == data.as_str() } } @@ -175,32 +230,19 @@ impl salsa::HashEqLike for compact_str::CompactString { impl salsa::HashEqLike<&Name> for compact_str::CompactString { #[inline] fn hash(&self, state: &mut H) { - std::hash::Hash::hash(self, state); + Hash::hash(self, state); } #[inline] fn eq(&self, data: &&Name) -> bool { - self == data.as_str() - } -} - -#[cfg(feature = "salsa")] -impl salsa::HashEqLike<&str> for Name { - #[inline] - fn hash(&self, state: &mut H) { - self.as_str().hash(state); - } - - #[inline] - fn eq(&self, data: &&str) -> bool { - self.as_str() == *data + self.as_str() == data.as_str() } } -impl From for String { +impl From for Name { #[inline] - fn from(name: Name) -> Self { - name.as_str().into() + fn from(name: CharString) -> Self { + Self(name.freeze()) } } diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs index e91db4b2e8..1bdac31f35 100644 --- a/crates/ruff_python_ast/src/nodes.rs +++ b/crates/ruff_python_ast/src/nodes.rs @@ -3916,16 +3916,16 @@ mod tests { #[test] #[cfg(target_pointer_width = "64")] fn size() { - assert_eq!(std::mem::size_of::(), 96); - assert_eq!(std::mem::size_of::(), 96); - assert_eq!(std::mem::size_of::(), 88); + assert_eq!(std::mem::size_of::(), 88); + assert_eq!(std::mem::size_of::(), 88); + assert_eq!(std::mem::size_of::(), 80); assert_eq!(std::mem::size_of::(), 64); assert_eq!(std::mem::size_of::(), 32); - assert_eq!(std::mem::size_of::(), 80); + assert_eq!(std::mem::size_of::(), 72); assert_eq!(std::mem::size_of::(), 56); assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::size_of::(), 72); - assert_eq!(std::mem::size_of::(), 64); + assert_eq!(std::mem::size_of::(), 56); assert_eq!(std::mem::size_of::(), 24); assert_eq!(std::mem::size_of::(), 32); assert_eq!(std::mem::size_of::(), 40); @@ -3943,7 +3943,7 @@ mod tests { assert_eq!(std::mem::size_of::(), 32); assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::size_of::(), 48); - assert_eq!(std::mem::size_of::(), 40); + assert_eq!(std::mem::size_of::(), 32); assert_eq!(std::mem::size_of::(), 32); assert_eq!(std::mem::size_of::(), 12); assert_eq!(std::mem::size_of::(), 40); diff --git a/crates/ruff_python_parser/Cargo.toml b/crates/ruff_python_parser/Cargo.toml index 9d92940929..aebe2f1ae3 100644 --- a/crates/ruff_python_parser/Cargo.toml +++ b/crates/ruff_python_parser/Cargo.toml @@ -19,7 +19,6 @@ ruff_text_size = { workspace = true, features = ["get-size"] } bitflags = { workspace = true } bstr = { workspace = true } -compact_str = { workspace = true } drop_bomb = { workspace = true } get-size2 = { workspace = true } memchr = { workspace = true } diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 118a372a1c..26ffaf2211 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -583,7 +583,8 @@ impl<'src> Parser<'src> { } if self.current_token_kind().is_soft_keyword() { - let id = Name::new(self.src_text(range)); + let text = self.src_text(range); + let id = self.intern_name(text); self.bump_soft_keyword_as_name(); return ast::Identifier { id, @@ -617,7 +618,8 @@ impl<'src> Parser<'src> { range, ); - let id = Name::new(self.src_text(range)); + let text = self.src_text(range); + let id = self.intern_name(text); self.bump_any(); ast::Identifier { id, diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 0c7093793a..e3e9af569f 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -11,6 +11,7 @@ use ruff_python_ast::{ }; use ruff_python_trivia::is_python_whitespace; use ruff_text_size::{Ranged, TextRange, TextSize}; +use rustc_hash::FxHashSet; use thin_vec::ThinVec; use unicode_normalization::UnicodeNormalization; @@ -37,6 +38,28 @@ mod statement; #[cfg(test)] mod tests; +#[derive(Debug, Default)] +struct NameInterner { + names: FxHashSet, +} + +impl NameInterner { + /// Returns an inline name directly, or a shared clone of a heap-allocated name. + fn intern(&mut self, text: &str) -> Name { + if let Some(name) = Name::new_inline(text) { + return name; + } + + if let Some(name) = self.names.get(text) { + return name.clone(); + } + + let name = Name::new_heap(text); + self.names.insert(name.clone()); + name + } +} + #[derive(Debug)] pub(crate) struct Parser<'src> { source: &'src str, @@ -44,6 +67,12 @@ pub(crate) struct Parser<'src> { /// Token source for the parser that skips over any non-trivia token. tokens: TokenSource<'src>, + /// Deduplicates the backing allocations for repeated names that do not fit inline. + name_interner: NameInterner, + + /// Reusable storage for names that need to be constructed by the parser. + name_buffer: String, + /// Stores all the syntax errors found during the parsing. errors: Vec, @@ -113,6 +142,8 @@ impl<'src> Parser<'src> { errors: Vec::new(), unsupported_syntax_errors: Vec::new(), tokens, + name_interner: NameInterner::default(), + name_buffer: String::new(), recovery_context: RecoveryContext::empty(), prev_token_end: TextSize::new(0), start_offset, @@ -421,14 +452,28 @@ impl<'src> Parser<'src> { fn bump_name(&mut self) -> Name { let text = self.current_token_text(); let name = if !self.tokens.current_flags().is_non_ascii_name() { - Name::new(text) + self.intern_name(text) } else { - normalize_name(text) + self.intern_normalized_name(text) }; self.bump(TokenKind::Name); name } + fn intern_name(&mut self, text: &str) -> Name { + self.name_interner.intern(text) + } + + fn intern_normalized_name(&mut self, text: &str) -> Name { + let snapshot = self.name_buffer.len(); + self.name_buffer.extend(text.nfkc()); + + let name = self.name_interner.intern(&self.name_buffer[snapshot..]); + + self.name_buffer.truncate(snapshot); + name + } + fn bump_int(&mut self) -> Int { let text = self.current_token_text(); let value = if let Some(digits) = @@ -930,11 +975,6 @@ fn strip_underscores(text: &str) -> Cow<'_, str> { } } -#[cold] -fn normalize_name(text: &str) -> Name { - text.nfkc().collect::() -} - #[derive(Copy, Clone)] enum IpyEscapeContext { Assignment, diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 6785c8dc2f..910a7fd04f 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -1,4 +1,3 @@ -use compact_str::CompactString; use std::fmt::{Display, Write}; use ruff_python_ast::name::Name; @@ -829,7 +828,13 @@ impl<'src> Parser<'src> { fn parse_dotted_name(&mut self) -> ast::Identifier { let start = self.node_start(); - let mut dotted_name: CompactString = self.parse_identifier().id.into(); + let first = self.parse_identifier(); + if !self.at(TokenKind::Dot) { + return first; + } + + let snapshot = self.name_buffer.len(); + self.name_buffer.push_str(&first.id); let mut progress = ParserProgress::default(); while self.eat(TokenKind::Dot) { @@ -838,15 +843,19 @@ impl<'src> Parser<'src> { // test_err dotted_name_multiple_dots // import a..b // import a...b - dotted_name.push('.'); - dotted_name.push_str(&self.parse_identifier()); + self.name_buffer.push('.'); + let identifier = self.parse_identifier(); + self.name_buffer.push_str(&identifier.id); } + let id = self.name_interner.intern(&self.name_buffer[snapshot..]); + self.name_buffer.truncate(snapshot); + // test_ok dotted_name_normalized_spaces // import a.b.c // import a . b . c ast::Identifier { - id: Name::from(dotted_name), + id, range: self.node_range(start), node_index: AtomicNodeIndex::NONE, } diff --git a/crates/ruff_python_parser/src/parser/tests.rs b/crates/ruff_python_parser/src/parser/tests.rs index 0cd15515e0..2eafcda9b1 100644 --- a/crates/ruff_python_parser/src/parser/tests.rs +++ b/crates/ruff_python_parser/src/parser/tests.rs @@ -71,6 +71,19 @@ fn nfkc_normalizes_names() { assert_eq!(name.id.as_str(), "C"); } +#[test] +fn nfkc_normalizes_dotted_names() { + let suite = parse_module("import 𝒞.𝒟").unwrap().into_suite(); + let [Stmt::Import(import)] = suite.as_slice() else { + panic!("expected a single import statement, got {suite:?}"); + }; + let [alias] = import.names.as_slice() else { + panic!("expected a single import alias, got {:?}", import.names); + }; + + assert_eq!(alias.name.id.as_str(), "C.D"); +} + #[test] fn number_values() { let cases = [ diff --git a/crates/ty_ide/src/call_hierarchy/incoming_calls.rs b/crates/ty_ide/src/call_hierarchy/incoming_calls.rs index cb94a765ff..8d2c2e04c3 100644 --- a/crates/ty_ide/src/call_hierarchy/incoming_calls.rs +++ b/crates/ty_ide/src/call_hierarchy/incoming_calls.rs @@ -403,7 +403,7 @@ impl<'a> CallSitesFinder<'a, '_> { }) .unwrap_or(false); CallHierarchyItem { - name: Name::new(func.name.as_str()), + name: func.name.id.clone(), kind: if is_method { SymbolKind::Method } else { @@ -418,7 +418,7 @@ impl<'a> CallSitesFinder<'a, '_> { NodeWithScopeKind::Class(class) => { let class = class.node(self.module); CallHierarchyItem { - name: Name::new(class.name.as_str()), + name: class.name.id.clone(), kind: SymbolKind::Class, detail: module_detail(self.db, file), file, diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 3485689c01..6fd9b0b2e5 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -1,7 +1,7 @@ use std::cmp::Ordering; use std::collections::{BinaryHeap, binary_heap}; -use compact_str::CompactString; +use compact_str::{CompactString, CompactStringExt}; use ruff_db::files::File; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_db::source::{SourceText, source_text}; @@ -427,7 +427,7 @@ impl<'db> CompletionBuilder<'db> { fn argument(name: impl Into) -> CompletionBuilder<'db> { let name = name.into(); let insert = compact_str::format_compact!("{name}="); - Completion::builder(name) + Completion::builder(CompactString::new(name)) .kind(CompletionKind::Variable) .insert(insert) .context_specific(true) @@ -756,7 +756,7 @@ impl<'m> Context<'m> { // same scope (in which case the bases refer to the prior // definition). if !model.is_class_name_reassigned(class_def) { - bases.insert(CompactString::new(class_def.name.as_str())); + bases.insert(CompactString::new(&class_def.name.id)); } bases }); @@ -1621,7 +1621,7 @@ fn extract_base_class_names(class_def: &ast::StmtClassDef) -> FxHashSet SemanticIndexBuilder<'db, 'ast> { .enumerate() { if let Some(target) = MemberExprBuilder::visit_subscript_expr( - target.clone(), + target, &ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value: ast::Number::Int(ast::Int::from(i as u64)), range: TextRange::default(), @@ -1670,8 +1670,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { continue; }; - let Some(member_expr) = MemberExprBuilder::visit_subscript_expr(target.clone(), key) - else { + let Some(member_expr) = MemberExprBuilder::visit_subscript_expr(target, key) else { continue; }; diff --git a/crates/ty_python_core/src/member.rs b/crates/ty_python_core/src/member.rs index 9d5face2d3..3de8127005 100644 --- a/crates/ty_python_core/src/member.rs +++ b/crates/ty_python_core/src/member.rs @@ -1,13 +1,13 @@ use ruff_index::{IndexVec, newtype_index}; -use ruff_python_ast::{self as ast, name::Name}; +use ruff_python_ast as ast; use ruff_text_size::{TextLen as _, TextRange, TextSize}; use bitflags::bitflags; +use char_str::{CharStr, CharString, format_char}; use hashbrown::hash_table::Entry; use rustc_hash::FxHasher; use smallvec::SmallVec; -use std::fmt::Write as _; use std::hash::{Hash, Hasher as _}; use std::ops::{Deref, DerefMut}; @@ -156,8 +156,8 @@ impl get_size2::GetSize for MemberFlags {} /// The symbol name can be extracted from the path by taking the text up to the first segment's start offset. #[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] pub(crate) struct MemberExpr { - /// The entire path as a single Name - path: Name, + /// The entire path as a single immutable string. + path: CharStr, /// Metadata for each segment (in forward order) segments: Segments, } @@ -187,10 +187,6 @@ impl MemberExpr { SegmentsIterator::new(self.path.as_str(), self.segment_infos()) } - fn shrink_to_fit(&mut self) { - self.path.shrink_to_fit(); - } - /// Returns the left most part of the member expression, e.g. `x` in `x.y.z`. /// /// This is the symbol on which the member access is performed. @@ -211,64 +207,118 @@ impl MemberExpr { } /// A builder for a [`MemberExpr`]. -#[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct MemberExprBuilder { - path: Name, + path: CharStr, segments: SmallVec<[SegmentInfo; 8]>, } impl MemberExprBuilder { pub(super) fn visit_expr(expr: ast::ExprRef) -> Option { match expr { - ast::ExprRef::Name(name) => Some(MemberExprBuilder { - path: name.id.clone(), - segments: smallvec::SmallVec::new_const(), - }), + ast::ExprRef::Name(name) => { + return Some(MemberExprBuilder { + path: CharStr::from(name.id.clone()), + segments: SmallVec::new_const(), + }); + } ast::ExprRef::Named(named) if named.target.is_name_expr() => { - MemberExprBuilder::visit_expr(ast::ExprRef::from(named.target.as_ref())) + return Self::visit_expr(ast::ExprRef::from(named.target.as_ref())); } - ast::ExprRef::Named(_) => None, + _ => {} + } - ast::ExprRef::Attribute(attribute) => { - let mut builder = - MemberExprBuilder::visit_expr(ast::ExprRef::from(&attribute.value))?; + let mut parts = SmallVec::new_const(); + let mut segments = SmallVec::new_const(); + let mut path_len = TextSize::new(0); + Self::collect_expr(expr, &mut parts, &mut segments, &mut path_len)?; + + Some(MemberExprBuilder { + path: CharStr::concat(&parts), + segments, + }) + } - let start_offset = builder.path.text_len(); - let _ = write!(builder.path, "{}", attribute.attr.id); - builder - .segments - .push(SegmentInfo::new(SegmentKind::Attribute, start_offset)); + fn collect_expr<'a>( + expr: ast::ExprRef<'a>, + parts: &mut SmallVec<[MemberPathPart<'a>; 8]>, + segments: &mut SmallVec<[SegmentInfo; 8]>, + path_len: &mut TextSize, + ) -> Option<()> { + match expr { + ast::ExprRef::Name(name) => { + let text = name.id.as_str(); + *path_len += text.text_len(); + parts.push(MemberPathPart::Borrowed(text)); + Some(()) + } + ast::ExprRef::Named(named) if named.target.is_name_expr() => Self::collect_expr( + ast::ExprRef::from(named.target.as_ref()), + parts, + segments, + path_len, + ), + ast::ExprRef::Named(_) => None, - Some(builder) + ast::ExprRef::Attribute(attribute) => { + Self::collect_expr( + ast::ExprRef::from(&attribute.value), + parts, + segments, + path_len, + )?; + + let start_offset = *path_len; + let text = attribute.attr.id.as_str(); + *path_len += text.text_len(); + parts.push(MemberPathPart::Borrowed(text)); + segments.push(SegmentInfo::new(SegmentKind::Attribute, start_offset)); + + Some(()) } ast::ExprRef::Subscript(subscript) => { - let subscript_value = - MemberExprBuilder::visit_expr(ast::ExprRef::from(&subscript.value))?; - MemberExprBuilder::visit_subscript_expr(subscript_value, &subscript.slice) + Self::collect_expr( + ast::ExprRef::from(&subscript.value), + parts, + segments, + path_len, + )?; + + let start_offset = *path_len; + let (kind, part) = Self::subscript_part(&subscript.slice)?; + *path_len += part.as_ref().text_len(); + parts.push(part); + segments.push(SegmentInfo::new(kind, start_offset)); + + Some(()) } _ => None, } } pub(super) fn visit_subscript_expr( - subscript_value: MemberExprBuilder, + subscript_value: &MemberExprBuilder, subscript_slice: &ast::Expr, ) -> Option { - let MemberExprBuilder { - mut path, - mut segments, - } = subscript_value; - let start_offset = path.text_len(); + let start_offset = subscript_value.path.text_len(); + let (kind, part) = Self::subscript_part(subscript_slice)?; + let path = CharStr::concat(&[subscript_value.path.as_str(), part.as_ref()]); + let mut segments = subscript_value.segments.clone(); + segments.push(SegmentInfo::new(kind, start_offset)); + + Some(MemberExprBuilder { path, segments }) + } + fn subscript_part(subscript_slice: &ast::Expr) -> Option<(SegmentKind, MemberPathPart<'_>)> { match subscript_slice { // Handle integer subscripts, like `x[0]`. ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value: ast::Number::Int(index), .. - }) => { - let _ = write!(path, "{index}"); - segments.push(SegmentInfo::new(SegmentKind::IntSubscript, start_offset)); - } + }) => Some(( + SegmentKind::IntSubscript, + MemberPathPart::Owned(format_char!("{index}")), + )), // Handle negative integer subscripts, like `x[-1]`. ast::Expr::UnaryOp(ast::ExprUnaryOp { op: ast::UnaryOp::USub, @@ -278,11 +328,11 @@ impl MemberExprBuilder { ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value: ast::Number::Int(index), .. - }) => { - let _ = write!(path, "-{index}"); - segments.push(SegmentInfo::new(SegmentKind::IntSubscript, start_offset)); - } - _ => return None, + }) => Some(( + SegmentKind::IntSubscript, + MemberPathPart::Owned(format_char!("-{index}")), + )), + _ => None, }, // Handle positive integer subscripts with explicit plus, like `x[+1]`. ast::Expr::UnaryOp(ast::ExprUnaryOp { @@ -293,34 +343,51 @@ impl MemberExprBuilder { ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value: ast::Number::Int(index), .. - }) => { - let _ = write!(path, "{index}"); - segments.push(SegmentInfo::new(SegmentKind::IntSubscript, start_offset)); - } - _ => return None, + }) => Some(( + SegmentKind::IntSubscript, + MemberPathPart::Owned(format_char!("{index}")), + )), + _ => None, }, // Handle boolean subscripts, like `x[True]` or `x[False]`. // In Python, `True` and `False` are equivalent to `1` and `0` for indexing. - ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { - let _ = write!(path, "{}", u8::from(*value)); - segments.push(SegmentInfo::new(SegmentKind::IntSubscript, start_offset)); - } - ast::Expr::StringLiteral(string) => { - let _ = write!(path, "{}", string.value); - segments.push(SegmentInfo::new(SegmentKind::StringSubscript, start_offset)); - } + ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => Some(( + SegmentKind::IntSubscript, + MemberPathPart::Borrowed(if *value { "1" } else { "0" }), + )), + ast::Expr::StringLiteral(string) => Some(( + SegmentKind::StringSubscript, + MemberPathPart::Borrowed(string.value.to_str()), + )), // Handle bytes literal subscripts, like `x[b"key"]`. ast::Expr::BytesLiteral(bytes) => { let bytes_vec: Vec = bytes.value.bytes().collect(); - let _ = write!(path, "{}", String::from_utf8_lossy(&bytes_vec)); - segments.push(SegmentInfo::new(SegmentKind::BytesSubscript, start_offset)); - } - _ => { - return None; + let text = String::from_utf8_lossy(&bytes_vec); + Some(( + SegmentKind::BytesSubscript, + MemberPathPart::Owned(CharString::from(text.as_ref())), + )) } + _ => None, } + } +} - Some(MemberExprBuilder { path, segments }) +/// A borrowed or owned fragment collected while building an immutable member path. +/// +/// AST-backed text is borrowed directly, while formatted subscripts use a temporary [`CharString`]. +/// The complete set of fragments is concatenated once into the builder's [`CharStr`]. +enum MemberPathPart<'a> { + Borrowed(&'a str), + Owned(CharString), +} + +impl AsRef for MemberPathPart<'_> { + fn as_ref(&self) -> &str { + match self { + MemberPathPart::Borrowed(text) => text, + MemberPathPart::Owned(text) => text.as_str(), + } } } @@ -552,7 +619,7 @@ impl MemberTableBuilder { /// Adds a member to the table or updates the flags of an existing member if it already exists. /// /// Members are identified by their expression, which is hashed to find the entry in the table. - pub(super) fn add(&mut self, mut member: Member) -> (ScopedMemberId, bool) { + pub(super) fn add(&mut self, member: Member) -> (ScopedMemberId, bool) { let entry = self.reverse.entry(&self.table.members, &member); match entry { @@ -566,8 +633,6 @@ impl MemberTableBuilder { (id, false) } Entry::Vacant(entry) => { - member.expression.shrink_to_fit(); - let id = self.table.members.push(member); entry.insert(id); (id, true) diff --git a/crates/ty_python_core/src/symbol.rs b/crates/ty_python_core/src/symbol.rs index 0c6be89726..b54f482ae9 100644 --- a/crates/ty_python_core/src/symbol.rs +++ b/crates/ty_python_core/src/symbol.rs @@ -273,7 +273,7 @@ impl SymbolTableBuilder { } /// Add a new symbol to this scope or update the flags if a symbol with the same name already exists. - pub(super) fn add(&mut self, mut symbol: Symbol) -> (ScopedSymbolId, bool) { + pub(super) fn add(&mut self, symbol: Symbol) -> (ScopedSymbolId, bool) { let entry = self.reverse.entry(&self.table.symbols, &symbol); match entry { @@ -287,7 +287,6 @@ impl SymbolTableBuilder { (id, false) } Entry::Vacant(entry) => { - symbol.name.shrink_to_fit(); let id = self.table.symbols.push(symbol); entry.insert(id); (id, true) diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index 5885ee9ba9..7bf6b48a57 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -28,6 +28,7 @@ ty_site_packages = { workspace = true } ty_python_core = { workspace = true } bitflags = { workspace = true } +char_str = { workspace = true } compact_str = { workspace = true } drop_bomb = { workspace = true } get-size2 = { workspace = true, features = ["indexmap", "ordermap"] } diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 7e29a8fccb..59b579c1a1 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -188,7 +188,7 @@ impl<'db> SemanticModel<'db> { )] for Member { name, ty } in all_members(self.db, ty) { completions.push(Completion { - name: name.into(), + name: CompactString::new(name), ty: Some(ty), builtin, }); @@ -223,7 +223,7 @@ impl<'db> SemanticModel<'db> { all_members(self.db, ty) .into_iter() .map(|member| Completion { - name: member.name.into(), + name: CompactString::new(member.name), ty: Some(member.ty), builtin: false, }) @@ -245,7 +245,7 @@ impl<'db> SemanticModel<'db> { completions.extend( all_reachable_members(self.db, file_scope.to_scope_id(self.db, self.file)).map( |memberdef| Completion { - name: memberdef.member.name.into(), + name: CompactString::new(memberdef.member.name), ty: Some(memberdef.member.ty), builtin: false, }, @@ -259,7 +259,7 @@ impl<'db> SemanticModel<'db> { // not `str | None`). completions.extend( all_implicit_module_globals(self.db, self.file).map(|(name, ty)| Completion { - name: name.into(), + name: CompactString::new(name), ty: Some(ty), builtin: true, }), diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index f7b9c8cc39..2171c157a9 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -1,4 +1,3 @@ -use compact_str::CompactString; use itertools::{Either, Itertools}; use ruff_db::{ diagnostic::Span, @@ -2365,16 +2364,20 @@ impl<'db> StaticClassLiteral<'db> { ) -> Member<'db> { // Collect names in a tracked query so unrelated edits can preserve dependent member // lookups, and avoid retaining query entries for names that no method can define. - if implicit_attribute_names(db, class_body_scope) - .binary_search_by(|candidate| candidate.as_str().cmp(name)) - .is_err() - { + let names = implicit_attribute_names(db, class_body_scope); + let Ok(name_index) = names.binary_search_by(|candidate| candidate.as_str().cmp(name)) + else { return Member::unbound(); - } + }; Self::implicit_attribute_inner( db, - ImplicitAttributeName::new(db, class_body_scope, name, target_method_decorator), + ImplicitAttributeName::new( + db, + class_body_scope, + &names[name_index], + target_method_decorator, + ), ) } @@ -2391,7 +2394,7 @@ impl<'db> StaticClassLiteral<'db> { attribute: ImplicitAttributeName<'db>, ) -> Member<'db> { let class_body_scope = attribute.class_body_scope(db); - let name = attribute.name(db); + let name = attribute.name(db).as_str(); let target_method_decorator = attribute.target_method_decorator(db); // If we do not see any declarations of an attribute, neither in the class body nor in @@ -3357,8 +3360,8 @@ fn explicit_bases_cycle_fn<'db>( struct ImplicitAttributeName<'db> { #[returns(copy)] class_body_scope: ScopeId<'db>, - #[returns(deref)] - name: CompactString, + #[returns(ref)] + name: Name, #[returns(copy)] target_method_decorator: MethodDecorator, } diff --git a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs index 47e3513fb1..16dcda2c89 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/named_tuple.rs @@ -16,6 +16,7 @@ use crate::{ infer::TypeInferenceBuilder, }, }; +use char_str::format_char; use ruff_python_ast::{self as ast, name::Name}; use ruff_python_stdlib::{identifiers::is_identifier, keyword::is_keyword}; use rustc_hash::FxHashSet; @@ -495,7 +496,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { || !is_identifier(name_str) || seen_names.contains(name_str); if needs_rename { - *field_name = Name::new(format!("_{i}")); + *field_name = Name::from(format_char!("_{i}")); } seen_names.insert(field_name.as_str()); } diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index afc19eb919..eee7675cf5 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -344,7 +344,7 @@ impl<'db> TypeVarInstance<'db> { }; let identity = TypeVarIdentity::new( db, - Name::new(format!("{}'instance", self.name(db))), + Name::concat(&[self.name(db).as_str(), "'instance"]), None, // definition self.kind(db), ); @@ -1366,8 +1366,8 @@ impl get_size2::GetSize for TypeVarIdentity<'_> {} impl<'db> TypeVarIdentity<'db> { fn with_name_suffix(self, db: &'db dyn Db, suffix: &str) -> Self { - let name = format!("{}'{}", self.name(db), suffix); - Self::new(db, Name::from(name), self.definition(db), self.kind(db)) + let name = Name::concat(&[self.name(db).as_str(), "'", suffix]); + Self::new(db, name, self.definition(db), self.kind(db)) } } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 58817ab358..36de7c8dae 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -234,6 +234,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "char_str" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f826e1dd81cad0bcd59e3ba09153b87b1d0630423af6083204d08c310eb0cccf" +dependencies = [ + "castaway", + "get-size2", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1512,6 +1525,7 @@ dependencies = [ name = "ruff_cache" version = "0.0.4" dependencies = [ + "char_str", "filetime", "glob", "globset", @@ -1692,6 +1706,7 @@ dependencies = [ "aho-corasick", "arrayvec", "bitflags 2.13.0", + "char_str", "compact_str", "get-size2", "is-macro", @@ -1786,7 +1801,6 @@ version = "0.0.4" dependencies = [ "bitflags 2.13.0", "bstr", - "compact_str", "get-size2", "memchr", "ruff_python_ast", @@ -2330,6 +2344,7 @@ version = "0.0.4" dependencies = [ "bitflags 2.13.0", "bitvec", + "char_str", "get-size2", "hashbrown 0.17.1", "itertools 0.15.0", @@ -2356,6 +2371,7 @@ name = "ty_python_semantic" version = "0.0.4" dependencies = [ "bitflags 2.13.0", + "char_str", "compact_str", "drop_bomb", "get-size2", diff --git a/ruff.schema.json b/ruff.schema.json index 212dbdf6aa..7564b9c57f 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1188,7 +1188,7 @@ "null" ], "items": { - "type": "string" + "$ref": "#/definitions/Name" } }, "function-names": { @@ -1198,7 +1198,7 @@ "null" ], "items": { - "type": "string" + "$ref": "#/definitions/Name" } } }, @@ -1418,7 +1418,7 @@ "null" ], "items": { - "type": "string" + "$ref": "#/definitions/Name" } }, "ignore-names": { @@ -1428,7 +1428,7 @@ "null" ], "items": { - "type": "string" + "$ref": "#/definitions/Name" } } }, @@ -2667,6 +2667,10 @@ }, "additionalProperties": false }, + "Name": { + "description": "An immutable name.\n\n# Choosing a string representation\n\nOn 64-bit targets, [`CharStr`] occupies 16 bytes and stores up to 16 UTF-8 bytes inline. Longer\nvalues use an exactly-sized, reference-counted allocation, so cloning a heap-backed value\nreuses its allocation. [`compact_str::CompactString`] occupies 24 bytes, stores up to 24 bytes\ninline, and remains mutable; cloning a heap-backed value copies its contents into a new\nallocation.\n\nPrefer `CharStr` for immutable text that is retained densely or passed between owners, when\neither the smaller handle or structural sharing offsets the extra heap allocations for values\nbetween 17 and 24 bytes. Prefer `CompactString` for uniquely owned text, especially when it is\nbuilt incrementally, mutated, or commonly falls in that 17-to-24-byte range.\n\n`Name` uses `CharStr` because names appear throughout the AST and repeated heap-backed parser\nnames share an allocation. By contrast, [`crate::DebugText`] uses `CompactString` because it\nbuilds a uniquely owned buffer incrementally, and `ty_module_resolver::ModuleName` uses\n`CompactString` because module names can be extended in place.\n\nConverting a borrowed `&str` into `CharStr` creates a new value and does not preserve structural\nsharing. When an API retains text already held in a `CharStr` (including a `Name`), pass or clone\nthe owned value rather than converting it through `&str`. This is especially relevant at Salsa\ninterning boundaries.", + "type": "string" + }, "NameImports": { "type": "string" }, From c72fc2e4ca85708319958a564025b1466e9e80db Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 16 Jul 2026 14:54:02 -0400 Subject: [PATCH 121/222] [ty] Deduplicate completion labels (#26890) ## Summary Most completions display either their explicit insert text or their matching name as the label. We currently clone that value into a third `CompactString` retained on every completion. This stores an explicit label only for callable completions, where the displayed label intentionally differs from the generated insertion containing parentheses or a snippet placeholder. Other completions derive the label from their insert text or name on demand, avoiding the redundant retained string without changing the labels exposed by IDE, server, or Wasm consumers. Split out of #26594 following review. --- crates/ty_ide/src/completion.rs | 36 ++++++++++++++----- .../src/server/api/requests/completion.rs | 2 +- crates/ty_wasm/src/lib.rs | 2 +- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 6fd9b0b2e5..5430c73370 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -287,7 +287,9 @@ pub struct Completion<'db> { /// The name used when matching the query and ranking this suggestion. pub name: CompactString, /// The label shown to the user for this suggestion. - pub label: CompactString, + /// + /// This is only set when the label differs from the insert text. + label: Option, /// The fully qualified name, when available. /// /// This is only set when `module_name` is available. @@ -352,6 +354,13 @@ pub struct Completion<'db> { } impl<'db> Completion<'db> { + pub fn label(&self) -> &str { + self.label + .as_deref() + .or(self.insert.as_deref()) + .unwrap_or(self.name.as_str()) + } + fn builder(name: impl Into) -> CompletionBuilder<'db> { CompletionBuilder::new(name) } @@ -478,17 +487,26 @@ impl<'db> CompletionBuilder<'db> { .kind .or_else(|| self.ty.and_then(|ty| completion_kind_from_type(db, ty))); let relevance = Relevance::new(ctx, query, &self); - let label = self.insert.as_ref().unwrap_or(&self.name).clone(); - let (insert, insert_text_format) = if ctx.should_complete_callable_parentheses(kind) { + let (label, insert, insert_text_format) = if ctx.should_complete_callable_parentheses(kind) + { + let label = self.insert.unwrap_or_else(|| self.name.clone()); if ctx.capabilities.snippets { let insert = compact_str::format_compact!("{label}($0)"); - (Some(insert), CompletionInsertTextFormat::Snippet) + ( + Some(label), + Some(insert), + CompletionInsertTextFormat::Snippet, + ) } else { let insert = compact_str::format_compact!("{label}()"); - (Some(insert), CompletionInsertTextFormat::PlainText) + ( + Some(label), + Some(insert), + CompletionInsertTextFormat::PlainText, + ) } } else { - (self.insert, CompletionInsertTextFormat::PlainText) + (None, self.insert, CompletionInsertTextFormat::PlainText) }; Completion { name: self.name, @@ -7928,7 +7946,7 @@ x: Literal["can't", "won't"] = '' completion .insert .as_deref() - .unwrap_or(completion.label.as_str()) + .unwrap_or(completion.label()) .to_string() }) .collect::>(); @@ -7952,7 +7970,7 @@ x: Literal['say "hi"', 'say "bye"'] = "" completion .insert .as_deref() - .unwrap_or(completion.label.as_str()) + .unwrap_or(completion.label()) .to_string() }) .collect::>(); @@ -10922,7 +10940,7 @@ raise self.filtered .iter() .map(|c| { - let mut snapshot = c.insert.as_deref().unwrap_or(c.label.as_str()).to_string(); + let mut snapshot = c.insert.as_deref().unwrap_or(c.label()).to_string(); if self.type_signatures { let ty = c.ty.map(|ty| ty.display(self.db).to_string()) diff --git a/crates/ty_server/src/server/api/requests/completion.rs b/crates/ty_server/src/server/api/requests/completion.rs index e3d0a60f57..cf1868f535 100644 --- a/crates/ty_server/src/server/api/requests/completion.rs +++ b/crates/ty_server/src/server/api/requests/completion.rs @@ -88,7 +88,7 @@ impl BackgroundDocumentRequestHandler for CompletionRequestHandler { }) }); - let label = comp.label.to_string(); + let label = comp.label().to_string(); let import_suffix = comp .module_name .and_then(|name| import_edit.is_some().then(|| format!(" (import {name})"))); diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index 35e0b6d6de..c5f4dde36f 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -530,7 +530,7 @@ impl Workspace { Ok(completions .into_iter() .map(|comp| { - let name = comp.label.to_string(); + let name = comp.label().to_string(); let kind = comp.kind.map(CompletionKind::from); let type_display = comp.ty.map(|ty| ty.display(&self.db).to_string()); let import_edit = comp.import.as_ref().map(|edit| { From e84ac0f592e1ac679fb615711c3a97d6bb89af8c Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Thu, 16 Jul 2026 15:10:34 -0400 Subject: [PATCH 122/222] [ty] Remove inferable typevar artifacts recursively (#26787) Fixes the issues identified in https://github.com/astral-sh/ruff/pull/26782#issuecomment-4963166161 by improving our inferable typevar artifact detection, e.g., ```py from typing import Literal, Mapping # error[invalid-assignment] Object of type `dict[str, str]` is not assignable to `Mapping[str, Literal["+", "-"]]` operators: Mapping[str, Literal["+", "-"]] = { "plus": "+", "minus": "-", } ``` Note that these were fixed indirectly by https://github.com/astral-sh/ruff/pull/26782, but that PR did not solve the underlying a problem, instead avoiding the solver path entirely. --- .../resources/mdtest/bidirectional.md | 16 +++++++ .../ty_python_semantic/src/types/generics.rs | 43 ++++++------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 2af7228ac2..8c8b9801d1 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -945,6 +945,22 @@ reveal_type(x20) # revealed: dict[Hashable, list[(...) -> Any]] x21: Mapping[Hashable, list[Callable[..., Any]]] = dict(x=[variadic]) reveal_type(x21) # revealed: dict[Hashable, list[(...) -> Any]] + +x22: Mapping[str, Literal["+", "-"]] = { + "plus": "+", + "minus": "-", +} +reveal_type(x22) # revealed: dict[str, Literal["+", "-"]] + +class DataFrame: ... + +type Aggregate = Callable[[DataFrame], object] | str +type AggregateSpec = Aggregate | list[Aggregate] + +def mean(data: DataFrame) -> float: + return 0.0 + +x23: Mapping[Hashable, AggregateSpec] = {"col1": ["sum", mean], "col2": mean} ``` ## Implicit generic class specialization diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index b3a1cd59d9..0a50937349 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2333,35 +2333,6 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { &self, target: BoundTypeVarInstance<'db>, ty: Type<'db>, - ) -> Type<'db> { - let ty = self.remove_inferable_typevar_artifacts_from_lower_bound(target, ty); - self.remove_inferable_typevar_artifacts_from_upper_bound(target, ty) - } - - fn remove_inferable_typevar_artifacts_from_lower_bound( - &self, - target: BoundTypeVarInstance<'db>, - ty: Type<'db>, - ) -> Type<'db> { - match ty { - Type::Union(union) - if union - .elements(self.db) - .iter() - .any(|element| !self.is_inferable_typevar_artifact(target, *element)) => - { - union.filter(self.db, |element| { - !self.is_inferable_typevar_artifact(target, *element) - }) - } - _ => ty, - } - } - - fn remove_inferable_typevar_artifacts_from_upper_bound( - &self, - target: BoundTypeVarInstance<'db>, - ty: Type<'db>, ) -> Type<'db> { match ty { Type::Intersection(intersection) @@ -2377,6 +2348,20 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } }) } + Type::Union(union) + if union + .elements(self.db) + .iter() + .any(|element| !self.is_inferable_typevar_artifact(target, *element)) => + { + union.map(self.db, |element| { + if self.is_inferable_typevar_artifact(target, *element) { + Type::Never + } else { + self.remove_inferable_typevar_artifacts_from_solution(target, *element) + } + }) + } _ => ty, } } From 5315bc80b3491d22ab9daf20f829390b5818fe32 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 16 Jul 2026 12:47:21 -0700 Subject: [PATCH 123/222] [ty] Narrow tagged unions by tag truthiness (#26887) ## Summary Narrow tagged unions when a discriminator is used directly as a truthiness condition, for both `TypedDict` subscripts and regular-class attributes. The semantic-index pass previously recorded only the subscript or attribute place as potentially narrowed, so resolving the containing value never evaluated a narrowing constraint. Record the base as well, then exclude definitely-falsy variants on the truthy branch and definitely-truthy variants on the falsy branch. `TypedDict` unions use a negative synthesized-`TypedDict` constraint, while nominal unions use the existing arm-filtering strategy. Truthiness is deliberately kept separate from equality semantics: the tests cover `Literal[True]`/`Literal[1]`, `Literal[False]`/`Literal[0]`, and ambiguous `bool` tags together so the correct alternatives are retained. Closes https://github.com/astral-sh/ty/issues/4013. ## Test plan Added mdtests. --- crates/ty_python_core/src/place.rs | 16 +++- .../resources/mdtest/narrow/truthiness.md | 42 +++++++++ .../resources/mdtest/typed_dict.md | 49 ++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 94 ++++++++++++++++++- 4 files changed, 196 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_core/src/place.rs b/crates/ty_python_core/src/place.rs index 5e1c034742..b74fdd0903 100644 --- a/crates/ty_python_core/src/place.rs +++ b/crates/ty_python_core/src/place.rs @@ -627,9 +627,19 @@ impl<'db, 'a> PossiblyNarrowedPlacesBuilder<'db, 'a> { fn expression_node(&self, expr: &ast::Expr) -> PossiblyNarrowedPlaces { match expr { - // Simple expressions that directly narrow a place - ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.simple_expr(expr) + // Simple expressions that directly narrow a place. + ast::Expr::Name(_) => self.simple_expr(expr), + // Attribute truthiness can also narrow its base (nominal tagged unions). + ast::Expr::Attribute(attribute) => { + let mut places = self.simple_expr(expr); + places.extend(self.simple_expr(&attribute.value)); + places + } + // Subscript truthiness can also narrow its base (`TypedDict` tagged unions). + ast::Expr::Subscript(subscript) => { + let mut places = self.simple_expr(expr); + places.extend(self.simple_expr(&subscript.value)); + places } // Compare expressions can narrow places on either side ast::Expr::Compare(expr_compare) => self.expr_compare(expr_compare), diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md index 29822aec0f..cbea64c83d 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md @@ -71,6 +71,48 @@ if (foo1 := Foo()).val: reveal_type(foo1.val) # revealed: int & ~AlwaysFalsy ``` +## Narrowing tagged unions of nominal classes by attribute truthiness + +```py +from typing import Literal + +class Success: + success: Literal[True] + result: int + +class Failure: + success: Literal[False] + errors: list[str] + +def _(response: Success | Failure): + if response.success: + reveal_type(response) # revealed: Success + reveal_type(response.result) # revealed: int + else: + reveal_type(response) # revealed: Failure + reveal_type(response.errors) # revealed: list[str] + + if not response.success: + reveal_type(response) # revealed: Failure + else: + reveal_type(response) # revealed: Success + +class TruthyIntTag: + success: Literal[1] + +class FalsyIntTag: + success: Literal[0] + +class AmbiguousTag: + success: bool + +def _(response: Success | Failure | TruthyIntTag | FalsyIntTag | AmbiguousTag): + if response.success: + reveal_type(response) # revealed: Success | TruthyIntTag | AmbiguousTag + else: + reveal_type(response) # revealed: Failure | FalsyIntTag | AmbiguousTag +``` + ## Function Literals Basically functions are always truthy. diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index c09394201d..414b3afc88 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -5037,6 +5037,55 @@ def _(u: Foo | Bar): reveal_type(u) # revealed: Bar ``` +Boolean tags can be narrowed by truthiness, including through a generic `TypedDict` and a type +alias: + +```py +import json +from typing import Generic, TypeAlias, TypeVar + +T = TypeVar("T") + +class Success(TypedDict, Generic[T]): + success: Literal[True] + result: T + +class Failure(TypedDict): + success: Literal[False] + errors: list[str] + +Response: TypeAlias = Success[int] | Failure + +def _(response: Response): + if response["success"]: + reveal_type(response) # revealed: Success[int] + reveal_type(response["result"]) # revealed: int + else: + reveal_type(response) # revealed: Failure + reveal_type(response["errors"]) # revealed: list[str] + +response: Response = json.loads("{}") + +if not response["success"]: + reveal_type(response) # revealed: Failure + reveal_type(response["errors"]) # revealed: list[str] +else: + reveal_type(response) # revealed: Success[int] + reveal_type(response["result"]) # revealed: int + +class TruthyIntTag(TypedDict): + success: Literal[1] + +class FalsyIntTag(TypedDict): + success: Literal[0] + +def _(response: Response | TruthyIntTag | FalsyIntTag): + if response["success"]: + reveal_type(response) # revealed: Success[int] | TruthyIntTag + else: + reveal_type(response) # revealed: Failure | FalsyIntTag +``` + Enum literals are also supported as tags: ```py diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index d405b3a3d7..55ecee57f1 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1115,8 +1115,37 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints } } - ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.evaluate_simple_expr(expression_node, is_positive) + ast::Expr::Attribute(attribute) => { + let constraints = self.evaluate_simple_expr(expression_node, is_positive); + let inference = infer_expression_types(self.db, expression, TypeContext::default()); + let nominal_constraints = self + .narrow_nominal_attribute_by_truthiness( + inference.expression_type(&*attribute.value), + &attribute.value, + attribute.attr.id(), + is_positive, + ) + .map(|(place, constraint)| { + NarrowingConstraints::from_iter([(place, constraint)]) + }); + + Self::merge_optional_constraints_and(constraints, nominal_constraints) + } + ast::Expr::Subscript(subscript) => { + let constraints = self.evaluate_simple_expr(expression_node, is_positive); + let inference = infer_expression_types(self.db, expression, TypeContext::default()); + let typeddict_constraints = self + .narrow_typeddict_subscript_by_truthiness( + inference.expression_type(&*subscript.value), + &subscript.value, + inference.expression_type(&*subscript.slice), + is_positive, + ) + .map(|(place, constraint)| { + NarrowingConstraints::from_iter([(place, constraint)]) + }); + + Self::merge_optional_constraints_and(constraints, typeddict_constraints) } ast::Expr::Compare(expr_compare) => { self.evaluate_expr_compare(expr_compare, expression, is_positive) @@ -4078,6 +4107,36 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { Some((place, NarrowingConstraint::intersection(intersection))) } + /// Narrow tagged unions of `TypedDict`s based on the truthiness of a `Literal` key. + fn narrow_typeddict_subscript_by_truthiness( + &self, + subscript_value_type: Type<'db>, + subscript_value_expr: &ast::Expr, + subscript_key_type: Type<'db>, + is_positive: bool, + ) -> Option<(ScopedPlaceId, NarrowingConstraint<'db>)> { + if !is_or_contains_typeddict(self.db, subscript_value_type) { + return None; + } + let subscript_place_expr = PlaceExpr::try_from_expr(subscript_value_expr)?; + let key_literal = subscript_key_type.as_string_literal()?; + + let excluded_field_type = if is_positive { + Type::AlwaysFalsy + } else { + Type::AlwaysTruthy + }; + let field = TypedDictFieldBuilder::new(excluded_field_type) + .required(false) + .read_only(true) + .build(); + let schema = TypedDictSchema::from_iter([(Name::from(key_literal.value(self.db)), field)]); + let synthesized_typeddict = TypedDictType::from_schema_items(self.db, schema); + let intersection = Type::TypedDict(synthesized_typeddict).negate(self.db); + let place = self.expect_place(&subscript_place_expr); + Some((place, NarrowingConstraint::intersection(intersection))) + } + // TODO: Restructure this helper to return the key-presence constraint and apply it with // `NarrowingConstraint::intersection` at the call site instead of constructing a replacement // type here. @@ -4211,6 +4270,37 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let place = self.expect_place(&attribute_value_place_expr); Some((place, NarrowingConstraint::replacement(narrowed))) } + + fn narrow_nominal_attribute_by_truthiness( + &self, + attribute_value_type: Type<'db>, + attribute_value_expr: &ast::Expr, + attribute_name: &str, + is_positive: bool, + ) -> Option<(ScopedPlaceId, NarrowingConstraint<'db>)> { + let Type::Union(union) = attribute_value_type.resolve_type_alias(self.db) else { + return None; + }; + + let narrowed = union.filter(self.db, |element| { + nominal_attribute_type(self.db, *element, attribute_name).is_none_or(|attribute_type| { + let truthiness = attribute_type.bool(self.db); + if is_positive { + !truthiness.is_always_false() + } else { + !truthiness.is_always_true() + } + }) + }); + + if narrowed == Type::Union(union) { + return None; + } + + let attribute_value_place_expr = PlaceExpr::try_from_expr(attribute_value_expr)?; + let place = self.expect_place(&attribute_value_place_expr); + Some((place, NarrowingConstraint::replacement(narrowed))) + } } // Return true if the given type is a `TypedDict` or a union or intersection that includes at least From 403bd20a1b87905779a29795665ee77476d3ea95 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 16 Jul 2026 20:51:36 -0400 Subject: [PATCH 124/222] [ty] Avoid extending own-line suppressions in `--add-ignore` (#26808) ## Summary This builds on #26785 by teaching `ty check --add-ignore` how to handle applicable own-line suppressions without broadening a preceding directive to cover additional diagnostics. In effect, we never use own-line comments for `--add-ignore`, whereas previously we may have applied them to more lines than intended. Put differently, we now only extend an existing suppression when its comment is on the same physical line as the diagnostic's suppression range. If an applicable own-line suppression appears on a preceding line, we leave it unchanged and add an end-of-line suppression at the diagnostic instead: ```python # ty: ignore[invalid-assignment] values: tuple[int] = f( missing, # ty:ignore[unresolved-reference] "bad", # ty:ignore[invalid-argument-type] ) ``` --- crates/ty_ide/src/code_action.rs | 48 +++++ crates/ty_python_semantic/src/fixes.rs | 180 +++++++++++++++++- crates/ty_python_semantic/src/suppression.rs | 17 ++ .../src/suppression/add_ignore.rs | 28 +-- 4 files changed, 258 insertions(+), 15 deletions(-) diff --git a/crates/ty_ide/src/code_action.rs b/crates/ty_ide/src/code_action.rs index 36fd1ade59..582b254b34 100644 --- a/crates/ty_ide/src/code_action.rs +++ b/crates/ty_ide/src/code_action.rs @@ -159,6 +159,54 @@ mod tests { "); } + #[test] + fn add_code_existing_empty_ignore() { + let test = CodeActionTest::with_source( + r#" + b = a / 10 # ty:ignore[] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:5 + | + 2 | b = a / 10 # ty:ignore[] + | ^ + | + | + 1 | + - b = a / 10 # ty:ignore[] + 2 + b = a / 10 # ty:ignore[unresolved-reference] + | + "); + } + + #[test] + fn add_ignore_does_not_update_preceding_own_line_suppression() { + let test = CodeActionTest::with_source( + r#" + seen_code = True + # ty:ignore[] + b = a / 10 + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:4:5 + | + 4 | b = a / 10 + | ^ + | + | + 3 | # ty:ignore[] + - b = a / 10 + 4 + b = a / 10 # ty:ignore[unresolved-reference] + | + "); + } + #[test] fn add_code_existing_type_ignore() { let test = CodeActionTest::with_source( diff --git a/crates/ty_python_semantic/src/fixes.rs b/crates/ty_python_semantic/src/fixes.rs index 37192873e9..77982447e9 100644 --- a/crates/ty_python_semantic/src/fixes.rs +++ b/crates/ty_python_semantic/src/fixes.rs @@ -818,6 +818,7 @@ mod tests { use crate::Db; use crate::db::tests::TestDbBuilder; use crate::fixes::{FixMode, fix_all}; + use crate::is_unused_ignore_comment_lint; #[test] fn simple_suppression() { @@ -1104,6 +1105,148 @@ class B(A): "#); } + #[test] + fn add_ignore_updates_empty_same_line_suppression() { + assert_snapshot!( + suppress_all_in(r#" + value = missing # ty: ignore[] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + value = missing # ty: ignore[unresolved-reference] + ``` + " + ); + } + + #[test] + fn add_ignore_groups_diagnostics_for_same_line_suppression() { + assert_snapshot!( + suppress_all_in(r#" + def f() -> str: return "" + + result: int = f(missing) # ty: ignore[division-by-zero] + "#), + @r#" + Added 3 suppressions + + ## Fixed source + + ```py + def f() -> str: return "" + + result: int = f(missing) # ty: ignore[division-by-zero, invalid-assignment, too-many-positional-arguments, unresolved-reference] + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` directive: 'division-by-zero' + --> test.py:3:40 + | + 1 | def f() -> str: return "" + 2 | + 3 | result: int = f(missing) # ty: ignore[division-by-zero, invalid-assignment, too-many-positional-arguments, unresolved-reference] + | ^^^^^^^^^^^^^^^^ + | + help: Remove the unused suppression code + "# + ); + } + + #[test] + fn add_ignore_does_not_update_inner_own_line_suppression() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + values = [ + # ty: ignore[] + missing, + ] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + seen_code = True + values = [ + # ty: ignore[] + missing, # ty:ignore[unresolved-reference] + ] + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` without a code + --> test.py:3:5 + | + 1 | seen_code = True + 2 | values = [ + 3 | # ty: ignore[] + | ^^^^^^^^^^^^^^ + 4 | missing, # ty:ignore[unresolved-reference] + 5 | ] + | + help: Remove the unused suppression comment + " + ); + } + + #[test] + fn add_ignore_does_not_update_preceding_own_line_suppressions() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[] + value = missing + + def f(a: int, b: int) -> list[int]: return [] + + # ty: ignore[invalid-assignment] + values: tuple[int] = f( + missing, + "bad", + ) + "#), + @r#" + Added 3 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[] + value = missing # ty:ignore[unresolved-reference] + + def f(a: int, b: int) -> list[int]: return [] + + # ty: ignore[invalid-assignment] + values: tuple[int] = f( + missing, # ty:ignore[unresolved-reference] + "bad", # ty:ignore[invalid-argument-type] + ) + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` without a code + --> test.py:2:1 + | + 1 | seen_code = True + 2 | # ty: ignore[] + | ^^^^^^^^^^^^^^ + 3 | value = missing # ty:ignore[unresolved-reference] + | + help: Remove the unused suppression comment + "# + ); + } + /// Tests that the `fix_all` doesn't end up in an infinite loop /// if the fixes never converge and that it emits a diagnostic in that case. #[test] @@ -1439,12 +1582,47 @@ class B(A): let diagnostics = db.check_file(file); let total_diagnostics = diagnostics.len(); + let suppressible_diagnostics = diagnostics + .iter() + .filter(|diagnostic| FixMode::Suppress.is_fixable(diagnostic)) + .count(); + let unsuppressible_diagnostics: Vec<_> = diagnostics + .iter() + .filter(|diagnostic| !FixMode::Suppress.is_fixable(diagnostic)) + .cloned() + .collect(); let cancellation_token_source = CancellationTokenSource::new(); let fixes = suppress_all_diagnostics(&mut db, diagnostics, &cancellation_token_source.token()) .expect("operation never gets cancelled"); - assert_eq!(fixes.count, total_diagnostics - fixes.diagnostics.len()); + if had_syntax_errors { + assert_eq!(fixes.count, 0); + assert_eq!(fixes.diagnostics.len(), total_diagnostics); + } else { + assert_eq!(fixes.count, suppressible_diagnostics); + assert!( + fixes + .diagnostics + .iter() + .all(|diagnostic| !FixMode::Suppress.is_fixable(diagnostic)) + ); + + let unexpected_diagnostics = + diff_diagnostics(&unsuppressible_diagnostics, &fixes.diagnostics); + assert!(unexpected_diagnostics.is_empty()); + + let incidentally_fixed_diagnostics = + diff_diagnostics(&fixes.diagnostics, &unsuppressible_diagnostics); + // Adding a code to an empty suppression also resolves its + // `unused-ignore-comment`, without adding another suppression. + assert!(incidentally_fixed_diagnostics.iter().all(|diagnostic| { + diagnostic + .id() + .as_lint() + .is_some_and(is_unused_ignore_comment_lint) + })); + } File::sync_path(&mut db, SystemPath::new("test.py")); diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 98c6fd766d..9e5682fea9 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -367,6 +367,23 @@ impl Suppressions { }) } + /// Returns the inline suppressions whose comments are on `line_range`. + fn inline_suppressions_on_line( + &self, + line_range: TextRange, + ) -> impl Iterator + '_ { + // The interval index retains source order, so comment ranges are also ordered by start. + let start = self + .inline + .entries + .partition_point(|entry| entry.value.comment_range.start() < line_range.start()); + + self.inline.entries[start..] + .iter() + .map(|entry| &entry.value) + .take_while(move |suppression| suppression.comment_range.start() < line_range.end()) + } + fn iter(&self) -> impl Iterator { self.file.iter().chain(self.inline.iter()) } diff --git a/crates/ty_python_semantic/src/suppression/add_ignore.rs b/crates/ty_python_semantic/src/suppression/add_ignore.rs index e0e8f89065..a7df1670b9 100644 --- a/crates/ty_python_semantic/src/suppression/add_ignore.rs +++ b/crates/ty_python_semantic/src/suppression/add_ignore.rs @@ -8,6 +8,7 @@ use ruff_db::parsed::parsed_module; use ruff_db::source::source_text; use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::token::TokenKind; +use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use smallvec::SmallVec; @@ -271,31 +272,30 @@ fn add_to_existing_suppression( codes: &[LintName], offset: TextSize, ) -> Option { - let mut existing_suppressions = suppressions - .inline_suppressions(TextRange::empty(offset)) - .filter(|suppression| { + let existing = suppressions + .inline_suppressions_on_line(source.line_range(offset)) + .find(|suppression| { matches!( suppression.target, SuppressionTarget::Lint(_) | SuppressionTarget::Empty, ) - }); - - // If there's an existing `ty: ignore[]` comment, append the code to it instead of creating a new suppression comment. - let existing = existing_suppressions.next()?; + })?; let comment_text = &source[existing.comment_range]; // Only add to the existing ignore comment if it has no reason. - let before_closing_paren = comment_text.trim_end().strip_suffix(']')?; - let up_to_last_code = before_closing_paren.trim_end(); - - let insertion = if up_to_last_code.ends_with(',') { - format!(" {codes}", codes = Codes(existing.kind, codes)) + let before_closing_bracket = comment_text.trim_end().strip_suffix(']')?; + let up_to_last_code = before_closing_bracket.trim_end(); + let separator = if up_to_last_code.ends_with('[') { + "" + } else if up_to_last_code.ends_with(',') { + " " } else { - format!(", {codes}", codes = Codes(existing.kind, codes)) + ", " }; - let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len(); + let insertion = format!("{separator}{codes}", codes = Codes(existing.kind, codes)); + Some(Fix::safe_edit(Edit::insertion( insertion, existing.comment_range.end() - relative_offset_from_end, From 7f16205240b4dc0f7459752dd9ee2486682fb7e3 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 16 Jul 2026 20:25:41 -0700 Subject: [PATCH 125/222] [ty] Project intersections in to_instance (#26892) ## Summary Support `to_instance` on intersection types instead of returning a `Todo` type. This is split out of #26712 (for [astral-sh/ty#3557](https://github.com/astral-sh/ty/issues/3557)). We project instantiable positive elements of the intersection into an instance intersection, discarding non-instantiable elements and negative elements. This is a sound over-approximation, which is also the case for several other existing arms of `to_instance`, and is fine for use in type inference. However, this did expose one case where we used `to_instance` on the "target" side of an assignability relation. In that position an over-approximation is _not_ sound; it can cause us to claim an incorrect relation to be true. This was a pre-existing issue (for all existing arms of `to_instance` that returned an over-approximation), but adding intersections exposed a new case of it in the ecosystem. To fix that, `to_instance` now returns an enum distinguishing exact projections (safe to use everywhere, including in relation target position) from over-approximating projections (safe to use in inference). In general, an exact projection would be one that round-trips precisely back through `to_meta_type`, whereas an over-approximation would be one that results in a wider type when converted back via `to_meta_type`. We add a new `to_instance_approximation` method which collapses this distinction (same as the old `to_instance`). This adds noise to the PR diff (converting most existing uses of `to_instance` to `to_instance_approximation`) but seems like better API clarity for future. This clear distinction between exact and over-approximating `to_instance` allowed simplifying what was previously a confusing set of special cases in the relation code, which were effectively attempting to model the same distinction in an ad-hoc way by matching on specific types.) ## Test plan - Added an exception-handling mdtest covering multiple positive class constraints, an exact-class negative/proper-subclass case, and the mixed `except A and B` path. - Added generic narrowing and subtype regressions covering direct and nested intersections and constrained type variables; updated the existing decorator fixtures to expect the newly exposed invalid-return diagnostic. --- .../resources/mdtest/decorators.md | 3 + .../resources/mdtest/exception/basic.md | 33 ++++++ .../resources/mdtest/type_of/generics.md | 51 ++++++++- crates/ty_python_semantic/src/types.rs | 107 +++++++++++++++--- .../src/types/attribute_write.rs | 2 +- .../ty_python_semantic/src/types/call/bind.rs | 3 +- crates/ty_python_semantic/src/types/class.rs | 14 ++- .../src/types/class/known.rs | 2 +- .../src/types/class/typed_dict.rs | 2 +- .../ty_python_semantic/src/types/generics.rs | 4 +- .../src/types/infer/builder.rs | 12 +- .../builder/post_inference/static_class.rs | 2 +- crates/ty_python_semantic/src/types/method.rs | 4 +- .../types/property_tests/type_generation.rs | 8 +- .../src/types/protocol_class.rs | 2 +- .../ty_python_semantic/src/types/relation.rs | 93 ++++++++------- .../src/types/set_theoretic.rs | 58 +++++++++- .../src/types/subclass_of.rs | 2 +- .../ty_python_semantic/src/types/type_form.rs | 2 +- .../ty_python_semantic/src/types/typevar.rs | 67 ++++++----- 20 files changed, 343 insertions(+), 128 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/decorators.md b/crates/ty_python_semantic/resources/mdtest/decorators.md index 924ed752a0..6d252dfa98 100644 --- a/crates/ty_python_semantic/resources/mdtest/decorators.md +++ b/crates/ty_python_semantic/resources/mdtest/decorators.md @@ -670,6 +670,7 @@ class ResourceEnabled(Protocol): SchemaT = TypeVar("SchemaT") def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]: + # error: [invalid-return-type] "Return type does not match returned value: expected `type[SchemaT@register] & ResourceEnabled`, found `type[SchemaT@register]`" return cls @register @@ -699,6 +700,7 @@ class ResourceEnabled(Protocol): SchemaT = TypeVar("SchemaT") def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]: + # error: [invalid-return-type] "Return type does not match returned value: expected `type[SchemaT@register] & ResourceEnabled`, found `type[SchemaT@register]`" return cls @dataclass @@ -729,6 +731,7 @@ class ResourceEnabled(Protocol): SchemaT = TypeVar("SchemaT") def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]: + # error: [invalid-return-type] "Return type does not match returned value: expected `type[SchemaT@register] & ResourceEnabled`, found `type[SchemaT@register]`" return cls def identity(cls: type[SchemaT]) -> type[SchemaT]: diff --git a/crates/ty_python_semantic/resources/mdtest/exception/basic.md b/crates/ty_python_semantic/resources/mdtest/exception/basic.md index 97becca84d..8a76d4db5a 100644 --- a/crates/ty_python_semantic/resources/mdtest/exception/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/exception/basic.md @@ -65,6 +65,39 @@ def foo( reveal_type(j) # revealed: ValueError ``` +## Exception-class expressions + +Exception-class expressions can produce intersections, gradual refinements, or unions. Positive +class-object constraints project into the caught instance type; unmappable positive constraints and +exact-class negatives do not exclude valid subclass instances. + +```py +from typing import Any +from ty_extensions import Intersection + +class FirstError(Exception): ... +class SecondError(Exception): ... + +def catch_intersection(exc: Intersection[type[FirstError], type[SecondError]]) -> None: + try: + help() + except exc as caught: + reveal_type(caught) # revealed: FirstError & SecondError + +def catch_proper_subclass(exc: Any) -> None: + if exc != ValueError and issubclass(exc, ValueError): + reveal_type(exc) # revealed: Any & type[ValueError] & ~ + try: + help() + except exc as caught: + reveal_type(caught) # revealed: Any & ValueError + +try: + help() +except ZeroDivisionError and ValueError as caught: + reveal_type(caught) # revealed: ZeroDivisionError | ValueError +``` + We do not emit an `invalid-exception-caught` if a class is caught that has `Any` or `Unknown` in its MRO, as the dynamic element in the MRO could materialize to some subclass of `BaseException`: diff --git a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md index e05a6b94b7..d434e974e4 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md +++ b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md @@ -146,6 +146,21 @@ def pep695_typevar_narrowing[T: (int, str)](x: int | str, t: type[T]) -> T: return x ``` +Narrowing a union of `type[T]` and a callable factory must preserve the `type[T]` alternative: + +```py +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + +class Holder(Generic[T]): + value: type[T] | Callable[[], type[T]] + + def narrow(self) -> None: + if isinstance(self.value, type): + reveal_type(self.value) # revealed: type[T@Holder] | ((() -> type[T@Holder]) & type) +``` + ## `__class__` ```py @@ -180,9 +195,9 @@ class B: A class `A` is a subtype of `type[T]` if any instance of `A` is a subtype of `T`. ```py -from typing import Any, Callable, Protocol -from ty_extensions import static_assert -from ty_extensions._internal import is_assignable_to, is_subtype_of, is_disjoint_from +from typing import Any, Callable, NewType, Protocol, TypeVar +from ty_extensions import Intersection, static_assert +from ty_extensions._internal import TypeOf, is_assignable_to, is_subtype_of, is_disjoint_from class Callback[T](Protocol): def __call__(self, *args, **kwargs) -> T: ... @@ -202,6 +217,9 @@ def _[T](_: T): static_assert(is_assignable_to(type[T], Callable[..., T] | Callable[..., Any])) static_assert(not is_disjoint_from(type[T], Callable[..., T] | Callable[..., Any])) + static_assert(not is_subtype_of(type[T], Intersection[Callable[[], type[T]], type])) + static_assert(not is_subtype_of(type[T], Intersection[Callable[[], type[T]], type] | type[int])) + static_assert(not is_assignable_to(type[T], Callback[int])) static_assert(not is_disjoint_from(type[T], Callback[int])) @@ -277,12 +295,35 @@ def _[T: (int, str)](_: T): static_assert(is_subtype_of(type[T], type[int] | type[str])) static_assert(is_subtype_of(type[T], type[int | str])) + static_assert(is_subtype_of(type[T], Intersection[Callable[[], type[T]], type] | type[int] | type[str])) static_assert(not is_disjoint_from(type[T], type[int | str])) static_assert(not is_disjoint_from(type[T], type[int] | type[str])) def _[T: (int | str, int)](_: T): static_assert(is_subtype_of(type[int], type[T])) static_assert(not is_disjoint_from(type[int], type[T])) + +def _[T, U: (Intersection[Callable[[], type], type], type)](_: T): + static_assert(not is_subtype_of(type[T], U)) + static_assert(not is_subtype_of(type[T], U | type[int])) + +class Base: ... +class Other: ... +class OtherMeta(type): ... + +UserId = NewType("UserId", int) + +BoundBase = TypeVar("BoundBase", bound=Base) +BoundList = TypeVar("BoundList", bound=list[int]) +BoundUserId = TypeVar("BoundUserId", bound=UserId) + +def lossy_class_object_projections(base: BoundBase, items: BoundList, user_id: BoundUserId) -> None: + static_assert(not is_subtype_of(type[BoundBase], TypeOf[Base])) + static_assert(not is_subtype_of(type[BoundList], TypeOf[list[int]])) + static_assert(not is_subtype_of(type[BoundBase], TypeOf[Base] | type[Other])) + static_assert(not is_subtype_of(type[BoundList], TypeOf[list[int]] | type[Other])) + static_assert(not is_subtype_of(type[BoundBase], OtherMeta | type[Other])) + static_assert(not is_subtype_of(type[BoundUserId], TypeOf[UserId] | type[Other])) ``` ## Type aliases in final upper bounds @@ -296,7 +337,8 @@ python-version = "3.12" ```py from typing import final -from ty_extensions._internal import TypeOf +from ty_extensions import static_assert +from ty_extensions._internal import TypeOf, is_subtype_of @final class FinalClass: ... @@ -306,6 +348,7 @@ type Alias = FinalClass def accepts_exact(cls: TypeOf[FinalClass]) -> None: ... def bounded[T: Alias](cls: type[T]) -> None: accepts_exact(cls) + static_assert(is_subtype_of(type[T], TypeOf[FinalClass] | type[int])) ``` ## Metaclass instances diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 9ddd50e094..e710a25695 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1019,6 +1019,59 @@ pub enum Type<'db> { NewTypeInstance(NewType<'db>), } +/// The result of projecting class-object types into the corresponding instance types. +/// +/// An exact projection preserves all class-object constraints relevant to a `type[T]` relation; +/// where `to_meta_type` is a faithful inverse, it round-trips semantically. An over-approximation +/// may discard class-object constraints and cannot establish a subtype relation in target +/// position. +/// +/// For example, given these Python classes: +/// +/// ```py +/// class Base: ... +/// class Child(Base): ... +/// ``` +/// +/// `type[Base]` projects to `Base` exactly: both admit `Child`. In contrast, +/// `TypeOf[Base]` (the type of the expression `Base`) admits only the `Base` class object, but +/// also projects to `Base`, which admits `Child` instances. That projection is an +/// over-approximation. +#[derive(Copy, Clone, Debug)] +pub(crate) enum InstanceProjection { + Exact(T), + OverApproximation(T), +} + +impl InstanceProjection { + pub(crate) const fn is_exact(&self) -> bool { + matches!(self, Self::Exact(_)) + } + + pub(crate) fn into_inner(self) -> T { + match self { + Self::Exact(value) | Self::OverApproximation(value) => value, + } + } + + pub(crate) fn map(self, transform: impl FnOnce(T) -> U) -> InstanceProjection { + match self { + Self::Exact(value) => InstanceProjection::Exact(transform(value)), + Self::OverApproximation(value) => { + InstanceProjection::OverApproximation(transform(value)) + } + } + } + + pub(crate) const fn new(value: T, is_exact: bool) -> Self { + if is_exact { + Self::Exact(value) + } else { + Self::OverApproximation(value) + } + } +} + /// An ordered pair of types shared by type-relation and set-theoretic queries. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] struct TypePair<'db> { @@ -2916,7 +2969,7 @@ impl<'db> Type<'db> { return class_attr; } - let Some(metaclass_instance) = self.to_meta_type(db).to_instance(db) else { + let Some(metaclass_instance) = self.to_meta_type(db).to_instance_approximation(db) else { return class_attr; }; let metaclass_attr = metaclass_instance.instance_member(db, name); @@ -2976,7 +3029,7 @@ impl<'db> Type<'db> { .expect("The meta-type of an instance-like type should always have an MRO"); let Some(metaclass) = class .metaclass(db) - .to_instance(db) + .to_instance_approximation(db) .and_then(|metaclass| metaclass.nominal_class(db)) else { return class_attr; @@ -3290,7 +3343,7 @@ impl<'db> Type<'db> { } else { let self_type = instance.unwrap_or_else(|| { // For classmethod-like callables, bind to the owner class. - owner.to_instance(db).unwrap_or(owner) + owner.to_instance_approximation(db).unwrap_or(owner) }); Some(( @@ -4365,7 +4418,7 @@ impl<'db> Type<'db> { let class_attr_plain = this.class_object_member(db, name_str, policy); - let self_instance = receiver.to_instance(db).expect( + let self_instance = receiver.to_instance_approximation(db).expect( "The receiver for a class-object lookup should always be instantiable", ); let class_attr_plain = @@ -5308,7 +5361,9 @@ impl<'db> Type<'db> { // Keep bespoke constructor behavior for cases that don't map cleanly to `__new__`/`__init__`. let fallback_bindings = || { - let return_type = self.to_instance(db).unwrap_or(Type::unknown()); + let return_type = self + .to_instance_approximation(db) + .unwrap_or(Type::unknown()); Binding::single( self, Signature::new_generic( @@ -5386,7 +5441,7 @@ impl<'db> Type<'db> { | MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK, ); - let Some(constructor_instance_ty) = self_type.to_instance(db) else { + let Some(constructor_instance_ty) = self_type.to_instance_approximation(db) else { return fallback_bindings(); }; @@ -5964,23 +6019,43 @@ impl<'db> Type<'db> { .and_then(|generator_types| generator_types.send_ty) } + /// Return the instance approximation, discarding whether the projection is exact. + /// + /// Use this only when an over-approximation is sound, such as constructor inference or a + /// source-side relation. Target-side subtype checks must use [`Self::to_instance`]. + #[must_use] + pub(crate) fn to_instance_approximation(self, db: &'db dyn Db) -> Option> { + self.to_instance(db).map(InstanceProjection::into_inner) + } + + /// Project this class-object type into its instance type while preserving projection quality. #[must_use] - pub(crate) fn to_instance(self, db: &'db dyn Db) -> Option> { + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Option>> { match self { - Type::Dynamic(_) | Type::Divergent(_) | Type::Never => Some(self), - Type::ClassLiteral(class) => Some(Type::instance(db, class.default_specialization(db))), - Type::GenericAlias(alias) => Some(Type::instance(db, ClassType::from(alias))), - Type::SubclassOf(subclass_of_ty) => Some(subclass_of_ty.to_instance(db)), - Type::KnownInstance(KnownInstanceType::NewType(newtype)) => { - Some(Type::NewTypeInstance(newtype)) + Type::Dynamic(_) | Type::Divergent(_) | Type::Never => { + Some(InstanceProjection::Exact(self)) + } + Type::ClassLiteral(class) => Some(InstanceProjection::OverApproximation( + Type::instance(db, class.default_specialization(db)), + )), + Type::GenericAlias(alias) => Some(InstanceProjection::OverApproximation( + Type::instance(db, ClassType::from(alias)), + )), + Type::SubclassOf(subclass_of_ty) => { + Some(InstanceProjection::Exact(subclass_of_ty.to_instance(db))) } + Type::KnownInstance(KnownInstanceType::NewType(newtype)) => Some( + InstanceProjection::OverApproximation(Type::NewTypeInstance(newtype)), + ), Type::Union(union) => union.to_instance(db), // If there is no bound or constraints on a typevar `T`, `T: object` implicitly, which // has no instance type. Otherwise, synthesize a typevar with bound or constraints // mapped through `to_instance`. - Type::TypeVar(bound_typevar) => Some(Type::TypeVar(bound_typevar.to_instance(db)?)), + Type::TypeVar(bound_typevar) => bound_typevar + .to_instance(db) + .map(|projection| projection.map(Type::TypeVar)), Type::TypeAlias(alias) => alias.value_type(db).to_instance(db), - Type::Intersection(_) => Some(todo_type!("Type::Intersection.to_instance")), + Type::Intersection(intersection) => intersection.to_instance(db), // An instance of class `C` may itself have instances if `C` is a subclass of `type`. Type::NominalInstance(instance) if KnownClass::Type @@ -5990,7 +6065,7 @@ impl<'db> Type<'db> { instance.class(db).is_subclass_of(db, type_class) }) => { - Some(Type::object()) + Some(InstanceProjection::OverApproximation(Type::object())) } Type::FunctionLiteral(_) | Type::Callable(..) diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs index b49f30532f..990467172e 100644 --- a/crates/ty_python_semantic/src/types/attribute_write.rs +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -402,7 +402,7 @@ fn class_attribute_write_requirement<'db>( let Some(members) = assignment_attribute_members(db, object_ty, attribute) else { return AttributeWriteRequirement::Unconstrained; }; - let Some(class_attr_self_ty) = object_ty.to_instance(db) else { + let Some(class_attr_self_ty) = object_ty.to_instance_approximation(db) else { return AttributeWriteRequirement::Unconstrained; }; let (type_member, receiver_fallback) = match members { diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index deae06b439..a64a53f8f6 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1807,7 +1807,8 @@ impl<'db> Bindings<'db> { if bound_method.function(db).name(db) == "__iter__" && is_enum_class(db, bound_method.self_instance(db)) => { - if let Some(enum_instance) = bound_method.self_instance(db).to_instance(db) + if let Some(enum_instance) = + bound_method.self_instance(db).to_instance_approximation(db) { overload.set_return_type( KnownClass::Iterator.to_specialized_instance(db, &[enum_instance]), diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 6f117e1d02..5e8980676c 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -693,7 +693,7 @@ impl<'db> ClassLiteral<'db> { /// Return a type representing "the set of all instances of the metaclass of this class". pub(crate) fn metaclass_instance_type(self, db: &'db dyn Db) -> Type<'db> { self.metaclass(db) - .to_instance(db) + .to_instance_approximation(db) .expect("`Type::to_instance()` should always return `Some()` when called on the type of a metaclass") } @@ -1592,10 +1592,10 @@ impl<'db> ClassType<'db> { if other_metaclass == type_class { return true; } - let Some(self_metaclass_instance) = self_metaclass.to_instance(db) else { + let Some(self_metaclass_instance) = self_metaclass.to_instance_approximation(db) else { return true; }; - let Some(other_metaclass_instance) = other_metaclass.to_instance(db) else { + let Some(other_metaclass_instance) = other_metaclass.to_instance_approximation(db) else { return true; }; if types_are_disjoint(self_metaclass_instance, other_metaclass_instance) { @@ -1609,7 +1609,7 @@ impl<'db> ClassType<'db> { pub(super) fn metaclass_instance_type(self, db: &'db dyn Db) -> Type<'db> { self .metaclass(db) - .to_instance(db) + .to_instance_approximation(db) .expect("`Type::to_instance()` should always return `Some()` when called on the type of a metaclass") } @@ -2106,7 +2106,7 @@ impl<'db> ClassType<'db> { !signature.return_ty.is_assignable_to( db, self_ty - .to_instance(db) + .to_instance_approximation(db) .expect("ClassType should be instantiable"), ) }); @@ -2136,7 +2136,9 @@ impl<'db> ClassType<'db> { ) .place; - let correct_return_type = self_ty.to_instance(db).unwrap_or_else(Type::unknown); + let correct_return_type = self_ty + .to_instance_approximation(db) + .unwrap_or_else(Type::unknown); // If the class defines an `__init__` method, then we synthesize a callable type with the // same parameters as the `__init__` method after it is bound, and with the return type of diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 0c4cc55dc1..75949b2504 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -1133,7 +1133,7 @@ impl KnownClass { "Use `Type::heterogeneous_tuple` or `Type::homogeneous_tuple` to create `tuple` instances" ); self.to_specialized_class_type(db, specialization) - .and_then(|class_type| Type::from(class_type).to_instance(db)) + .and_then(|class_type| Type::from(class_type).to_instance_approximation(db)) .unwrap_or_else(Type::unknown) } diff --git a/crates/ty_python_semantic/src/types/class/typed_dict.rs b/crates/ty_python_semantic/src/types/class/typed_dict.rs index 3f9a08193b..5394fc50d8 100644 --- a/crates/ty_python_semantic/src/types/class/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/class/typed_dict.rs @@ -676,7 +676,7 @@ fn synthesize_typed_dict_view_method<'db>( .specialize(db, &[typed_dict.key_type(db), typed_dict.value_type(db)]) }) }) - .and_then(|class| Type::from(class).to_instance(db)) + .and_then(|class| Type::from(class).to_instance_approximation(db)) .unwrap_or_else(Type::unknown); synthesize_typed_dict_no_argument_method(db, typed_dict, return_ty) diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 0a50937349..a9a4304e21 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2749,7 +2749,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { actual @ (Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_)), ) => { let variance = TypeVarVariance::Covariant.compose(polarity); - if let Some(actual_instance) = actual.to_instance(self.db) { + if let Some(actual_instance) = actual.to_instance_approximation(self.db) { return self.infer_map_impl( formal_typeform.type_argument(self.db), actual_instance, @@ -3109,7 +3109,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { (Type::SubclassOf(subclass_of), ty) | (ty, Type::SubclassOf(subclass_of)) if let Some(type_var) = subclass_of.into_type_var() - && let Some(actual_instance) = ty.to_instance(self.db) => + && let Some(actual_instance) = ty.to_instance_approximation(self.db) => { return self.infer_map_impl( Type::TypeVar(type_var), diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 26c463f6d7..1b61def515 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2236,7 +2236,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for (index, element) in tuple_spec.iter_element_types(self.db()).enumerate() { builder = builder.add( if element.is_assignable_to(self.db(), type_base_exception) { - element.to_instance(self.db()).expect( + element.to_instance_approximation(self.db()).expect( "`Type::to_instance()` should always return `Some()` \ if called on a type assignable to `type[BaseException]`", ) @@ -2317,7 +2317,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.db(), tuple_spec.iter_element_types(self.db()).map(|element| { if element.is_assignable_to(self.db(), type_base_exception) { - Some(element.to_instance(self.db()).expect( + Some(element.to_instance_approximation(self.db()).expect( "`Type::to_instance()` should always return `Some()` \ if called on a type assignable to `type[BaseException]`", )) @@ -2328,7 +2328,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ) } else if ty.is_assignable_to(self.db(), type_base_exception) { // `except ValueError as e:` - Some(ty.to_instance(self.db()).expect( + Some(ty.to_instance_approximation(self.db()).expect( "`Type::to_instance()` should always return `Some()` \ if called on a type assignable to `type[BaseException]`", )) @@ -2343,7 +2343,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .and_then(|spec| { let specialization = spec .homogeneous_element_type(self.db()) - .to_instance(self.db()); + .to_instance_approximation(self.db()); debug_assert!(specialization.is_some_and(|specialization_type| { specialization_type.is_assignable_to( @@ -7024,7 +7024,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .specialize_recursive(self.db(), specialization.into_iter().map(Some)) }, ); - return Type::from(class_type).to_instance(self.db()); + return Type::from(class_type).to_instance_approximation(self.db()); } pre_inferred_elt_tys = Some(inferred_elts); @@ -7263,7 +7263,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) }); - Type::from(class_type).to_instance(self.db()) + Type::from(class_type).to_instance_approximation(self.db()) } /// Infer the type of the `iter` expression of the first comprehension. diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 9e0bab2f64..7d942557f6 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -1032,7 +1032,7 @@ fn check_class_namespace_against_metaclass_members<'db>( return; } - let Some(metaclass_instance) = metaclass.to_instance(db) else { + let Some(metaclass_instance) = metaclass.to_instance_approximation(db) else { return; }; diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index f9f97a5a6e..13ac62a4db 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -52,7 +52,9 @@ impl<'db> BoundMethodType<'db> { pub(crate) fn typing_self_type(self, db: &'db dyn Db) -> Type<'db> { let mut self_instance = self.self_instance(db); if self.function(db).is_classmethod(db) { - self_instance = self_instance.to_instance(db).unwrap_or_else(Type::unknown); + self_instance = self_instance + .to_instance_approximation(db) + .unwrap_or_else(Type::unknown); } self_instance } diff --git a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs index 65e49d9e93..eeadb93022 100644 --- a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs +++ b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs @@ -142,7 +142,7 @@ fn create_bound_method<'db>( Type::BoundMethod(BoundMethodType::new( db, function.expect_function_literal(), - builtins_class.to_instance(db).unwrap(), + builtins_class.to_instance_approximation(db).unwrap(), )) } @@ -182,12 +182,12 @@ impl Ty { Ty::BuiltinInstance(s) => builtins_symbol(db, s) .place .expect_type() - .to_instance(db) + .to_instance_approximation(db) .unwrap(), Ty::AbcInstance(s) => known_module_symbol(db, KnownModule::Abc, s) .place .expect_type() - .to_instance(db) + .to_instance_approximation(db) .unwrap(), Ty::AbcClassLiteral(s) => known_module_symbol(db, KnownModule::Abc, s) .place @@ -197,7 +197,7 @@ impl Ty { .expect_type(), Ty::UnittestMockInstance => Ty::UnittestMockLiteral .into_type(db) - .to_instance(db) + .to_instance_approximation(db) .unwrap(), Ty::TypingLiteral => Type::SpecialForm(SpecialFormType::Literal), Ty::BuiltinClassLiteral(s) => builtins_symbol(db, s).place.expect_type(), diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index dfa312ffd2..a94a20174b 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -2119,7 +2119,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { // that returns an instance cannot satisfy a protocol that promises the class object. let protocol_self_binding_ty = ty.literal_fallback_instance(db).unwrap_or(ty); let implementation_self_binding_ty = ty - .to_instance(db) + .to_instance_approximation(db) .or_else(|| ty.literal_fallback_instance(db)) .unwrap_or(ty); let implementation_receiver_binding_ty = if member.is_class_method() { diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 5e7f996f06..6e644cbc2b 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -965,28 +965,6 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { }) } - /// Can we check `target`s relation to a `type[T]` in either the metaclass-instance domain (it - /// must pass `is_metaclass_instance`) or the regular instance domain (it must have Some - /// `.to_instance()`)? - /// - /// Do not use instance subtyping for an exact class object. For `T: (Y, Z)` where `Z` extends - /// `Y`, doing so would incorrectly simplify `type[T] & ` to `type[T]`: both `Y` and - /// `Z` instances are subtypes of `Y`, but only the class object `Y` satisfies `klass is Y`. - /// - /// The exception is a type variable whose upper bound normalizes to this exact class object. - /// That can only happen for a final class, so the exact object is the only valid - /// specialization of the type variable. - fn can_check_typevar_subclass_relation_to_target( - db: &'db dyn Db, - source_subclass: SubclassOfType<'db>, - target: Type<'db>, - ) -> bool { - let is_exact_upper_bound = source_subclass.exact_typevar_upper_bound(db) == Some(target); - - (!matches!(target, Type::ClassLiteral(_) | Type::GenericAlias(_)) || is_exact_upper_bound) - && (Self::is_metaclass_instance(db, target) || target.to_instance(db).is_some()) - } - /// Check the relation between a `type[T]` and a target type `A` when `A` can either be /// projected into the ordinary instance/object domain via `.to_instance()`, or is a plain /// metaclass object type. @@ -1003,28 +981,51 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { /// the metaclass of the upper bound of `T`, and compare in the metaclass-instance domain /// directly. /// - /// Exact class objects, and types that have no `.to_instance()` projection and are not - /// metaclass instances, do not pass the `can_check_typevar_subclass_relation_to_target` guard. - /// This helper does not decide their relation; they fall through to other type-pair branches. + /// When `.to_instance()` is an over-approximation, compare the original target in the + /// class-object domain instead. This preserves constraints that the projection discards, as + /// well as source constraints so that `T: (Y, Z)` can still be related to + /// `type[Y] | type[Z]`. + /// + /// Exact class objects also have an over-approximated instance projection. For `T: (Y, Z)` + /// where `Z` extends `Y`, instance subtyping would incorrectly simplify + /// `type[T] & ` to `type[T]`: both `Y` and `Z` instances are subtypes of `Y`, but + /// only the class object `Y` satisfies `klass is Y`. The exception is a type variable whose + /// upper bound normalizes to this exact class object. That can only happen for a final class, + /// so the exact object is the only valid specialization of the type variable. + /// + /// Return `None` for targets without a `.to_instance()` projection, allowing other type-pair + /// branches to decide their relation. fn check_typevar_subclass_relation_to_target( &self, db: &'db dyn Db, source_subclass: SubclassOfType<'db>, target: Type<'db>, - ) -> ConstraintSet<'db, 'c> { - source_subclass - .into_type_var() - .when_some_and(db, self.constraints, |source_i| { - if Self::is_metaclass_instance(db, target) { - self.check_type_pair(db, source_subclass.to_metaclass_instance(db), target) - } else { - target - .to_instance(db) - .when_some_and(db, self.constraints, |target_i| { - self.check_type_pair(db, Type::TypeVar(source_i), target_i) - }) - } - }) + ) -> Option> { + let source_i = source_subclass.into_type_var()?; + let is_exact_upper_bound = source_subclass.exact_typevar_upper_bound(db) == Some(target); + + if Self::is_metaclass_instance(db, target) { + return Some(self.check_type_pair( + db, + source_subclass.to_metaclass_instance(db), + target, + )); + } + + let projection = target.to_instance(db)?; + if projection.is_exact() || is_exact_upper_bound { + return Some(self.check_type_pair( + db, + Type::TypeVar(source_i), + projection.into_inner(), + )); + } + + let source = source_subclass + .subclass_of() + .with_transposed_type_var(db) + .into_type_var()?; + Some(self.check_type_pair(db, Type::TypeVar(source), target)) } /// Return a constraint set indicating the conditions under which `self.relation` holds between `source` and `target`. @@ -1380,21 +1381,17 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // collapsing `A` through `to_instance()` would erase it to `object` (we have no // precise representation for "all instances of any classes with a given metaclass"). (Type::SubclassOf(subclass_of), _) - if subclass_of.is_type_var() - && Self::can_check_typevar_subclass_relation_to_target( - db, - subclass_of, - target, - ) => + if let Some(constraint_set) = + self.check_typevar_subclass_relation_to_target(db, subclass_of, target) => { - self.check_typevar_subclass_relation_to_target(db, subclass_of, target) + constraint_set } // And vice versa. (No special metaclass handling is needed in this direction, since // "collapse to 'object'" in this case is a sound over-approximation.) (_, Type::SubclassOf(subclass_of)) if let Some(type_var) = subclass_of.into_type_var() - && let Some(instance) = source.to_instance(db) => + && let Some(instance) = source.to_instance_approximation(db) => { self.check_type_pair(db, instance, Type::TypeVar(type_var)) } @@ -2641,7 +2638,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { // `type[T]` is disjoint from a class object `A` if every instance of `T` is disjoint from an instance of `A`. (Type::SubclassOf(subclass_of), other) | (other, Type::SubclassOf(subclass_of)) if let Some(type_var) = subclass_of.into_type_var() - && let Some(instance) = other.to_instance(db) => + && let Some(instance) = other.to_instance_approximation(db) => { self.check_type_pair(db, Type::TypeVar(type_var), instance) } diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index e0bf18d79a..17c89643b4 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -7,7 +7,7 @@ use crate::place::{ }; use crate::types::class::KnownClass; use crate::types::enums::EnumComplement; -use crate::types::{Type, TypePair, TypeQualifiers}; +use crate::types::{InstanceProjection, Type, TypePair, TypeQualifiers}; use crate::types::{TypeVarBoundOrConstraints, visitor}; use crate::{Db, FxOrderSet}; @@ -231,8 +231,14 @@ impl<'db> UnionType<'db> { Ok(Type::Union(self)) } - pub(crate) fn to_instance(self, db: &'db dyn Db) -> Option> { - self.try_map(db, |element| element.to_instance(db)) + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Option>> { + let mut is_exact = true; + let instance = self.try_map(db, |element| { + let projection = element.to_instance(db)?; + is_exact &= projection.is_exact(); + Some(projection.into_inner()) + })?; + Some(InstanceProjection::new(instance, is_exact)) } /// Returns a shared fully static supertype for a union of literal-value types. @@ -1087,6 +1093,52 @@ impl<'db> IntersectionType<'db> { self.negative(db).iter().copied() } + /// Project an intersection containing class-object types into the corresponding instance types. + /// + /// A projected positive element supplies a sound instance-space over-approximation for the + /// whole intersection. Other positive elements can constrain class objects in a domain with no + /// instance-space projection, so omitting them is also a sound over-approximation. Negative + /// elements cannot be projected: a class object excluded by an exact-class negative can still + /// have subclasses whose instances inhabit the excluded class's instance type. Without a + /// projected positive element, we cannot tell whether the intersection contains class objects + /// at all. The result is exact only when every positive element projects exactly and there are + /// no negative elements. + /// + /// For example, Python narrowing can produce `type[Base] & ~TypeOf[Base]`: + /// + /// ```py + /// class Base: ... + /// class Child(Base): ... + /// + /// def make(cls: type[Base]) -> Base: + /// if cls is not Base: + /// return cls() # `cls` can be `Child`, so this can return a `Child` instance. + /// return Base() + /// ``` + /// + /// Projecting only the positive `type[Base]` is an over-approximation, since we have no + /// representation of an exact instance type excluding subclasses, and projecting the negative + /// `~TypeOf[Base]` to `~Base` would incorrectly exclude `Child` instances too. + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Option>> { + let mut builder = IntersectionBuilder::new(db); + let mut has_projected_positive = false; + let mut is_exact = self.negative(db).is_empty(); + for positive in self.iter_positive(db) { + if let Some(projection) = positive.to_instance(db) { + has_projected_positive = true; + is_exact &= projection.is_exact(); + builder = builder.add_positive(projection.into_inner()); + } else { + is_exact = false; + } + } + if !has_projected_positive { + return None; + } + + Some(InstanceProjection::new(builder.build(), is_exact)) + } + pub(crate) fn has_one_element(self, db: &'db dyn Db) -> bool { (self.positive(db).len() + self.negative(db).len()) == 1 } diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 83460f2023..bf2fd0e0c4 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -291,7 +291,7 @@ impl<'db> SubclassOfType<'db> { // the upper bound `type[C]`, and transform that to the meta-type `type[M]`, which // `to_instance` then resolves to `M`. self.to_meta_type(db) - .to_instance(db) + .to_instance_approximation(db) .expect("the meta-type of a SubclassOf type should always be instantiable") } diff --git a/crates/ty_python_semantic/src/types/type_form.rs b/crates/ty_python_semantic/src/types/type_form.rs index 295668255b..356f977d5b 100644 --- a/crates/ty_python_semantic/src/types/type_form.rs +++ b/crates/ty_python_semantic/src/types/type_form.rs @@ -81,7 +81,7 @@ impl<'db> Type<'db> { instance.type_form_argument(db) } Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => { - ty.to_instance(db) + ty.to_instance_approximation(db) } _ => None, } diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index eee7675cf5..4006703e0b 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -14,9 +14,9 @@ use crate::{ }, types::{ ApplySpecialization, ApplyTypeMappingVisitor, CycleDetector, DynamicType, GenericContext, - KnownClass, KnownInstanceType, MaterializationKind, Parameter, Parameters, Type, - TypeAliasType, TypeContext, TypeMapping, TypeVarVariance, UnionBuilder, UnionType, - any_over_type, binding_type, definition_expression_type, + InstanceProjection, KnownClass, KnownInstanceType, MaterializationKind, Parameter, + Parameters, Type, TypeAliasType, TypeContext, TypeMapping, TypeVarVariance, UnionBuilder, + UnionType, any_over_type, binding_type, definition_expression_type, tuple::Tuple, variance::VarianceInferable, visitor::{self, TypeCollector, TypeVisitor, walk_type_with_recursion_guard}, @@ -333,14 +333,14 @@ impl<'db> TypeVarInstance<'db> { ) } - fn to_instance(self, db: &'db dyn Db) -> Option { + fn to_instance(self, db: &'db dyn Db) -> Option> { let bound_or_constraints = match self.bound_or_constraints(db)? { - TypeVarBoundOrConstraints::UpperBound(upper_bound) => { - TypeVarBoundOrConstraints::UpperBound(upper_bound.to_instance(db)?) - } - TypeVarBoundOrConstraints::Constraints(constraints) => { - TypeVarBoundOrConstraints::Constraints(constraints.to_instance(db)?) - } + TypeVarBoundOrConstraints::UpperBound(upper_bound) => upper_bound + .to_instance(db)? + .map(TypeVarBoundOrConstraints::UpperBound), + TypeVarBoundOrConstraints::Constraints(constraints) => constraints + .to_instance(db)? + .map(TypeVarBoundOrConstraints::Constraints), }; let identity = TypeVarIdentity::new( db, @@ -348,13 +348,15 @@ impl<'db> TypeVarInstance<'db> { None, // definition self.kind(db), ); - Some(Self::new( - db, - identity, - Some(bound_or_constraints.into()), - self.explicit_variance(db), - None, // _default - )) + Some(bound_or_constraints.map(|bound_or_constraints| { + Self::new( + db, + identity, + Some(bound_or_constraints.into()), + self.explicit_variance(db), + None, // _default + ) + })) } fn type_is_self_referential( @@ -1299,14 +1301,16 @@ impl<'db> BoundTypeVarInstance<'db> { ) } - pub(super) fn to_instance(self, db: &'db dyn Db) -> Option { - Some(Self::new( - db, - self.typevar(db).to_instance(db)?, - self.binding_context(db), - self.paramspec_attr(db), - self.freshness(db), - )) + pub(super) fn to_instance(self, db: &'db dyn Db) -> Option> { + Some(self.typevar(db).to_instance(db)?.map(|typevar| { + Self::new( + db, + typevar, + self.binding_context(db), + self.paramspec_attr(db), + self.freshness(db), + ) + })) } } @@ -1595,14 +1599,17 @@ impl<'db> TypeVarConstraints<'db> { UnionType::from_elements(db, self.elements(db)) } - fn to_instance(self, db: &'db dyn Db) -> Option> { + fn to_instance(self, db: &'db dyn Db) -> Option>> { let mut instance_elements = Vec::new(); + let mut is_exact = true; for ty in self.elements(db) { - instance_elements.push(ty.to_instance(db)?); + let projection = ty.to_instance(db)?; + is_exact &= projection.is_exact(); + instance_elements.push(projection.into_inner()); } - Some(TypeVarConstraints::new( - db, - instance_elements.into_boxed_slice(), + Some(InstanceProjection::new( + TypeVarConstraints::new(db, instance_elements.into_boxed_slice()), + is_exact, )) } From a16b84e9ed34cb82c3d4cb91ec5958f07bfd7517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Thu, 16 Jul 2026 22:14:32 -0700 Subject: [PATCH 126/222] [ty] Share structured docstring parsing components (#26889) ## Summary Prior to [parsing a document model for NumPy-style docstrings](https://github.com/astral-sh/ruff/pull/25924), this moves a few parsing components that are currently only used for the Google-style docstring format to a shared location. This just helps reduce noise in that upcoming PR for using those same components for a new target. ## Test Plan This is a behaviour-preserving refactor that is covered by existing tests. --- crates/ty_ide/src/docstring/document.rs | 203 +++++++++++++++++ .../ty_ide/src/docstring/document/google.rs | 208 +----------------- .../ty_ide/src/docstring/document/syntax.rs | 8 + 3 files changed, 218 insertions(+), 201 deletions(-) diff --git a/crates/ty_ide/src/docstring/document.rs b/crates/ty_ide/src/docstring/document.rs index 8b630a7201..3a233e222d 100644 --- a/crates/ty_ide/src/docstring/document.rs +++ b/crates/ty_ide/src/docstring/document.rs @@ -1,6 +1,9 @@ use indexmap::IndexMap; +use ruff_text_size::{TextRange, TextSize}; use strum_macros::EnumIter; +use self::syntax::{indentation, starts_with_markdown_list_item}; + pub(super) mod google; pub(super) mod preformatted; pub(super) mod rst; @@ -53,3 +56,203 @@ impl SectionKind { } } } + +/// A recognized structured docstring section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::docstring) struct Section { + kind: SectionKind, + range: TextRange, + body: SectionBody, +} + +impl Section { + /// Returns the section kind. + pub(in crate::docstring) const fn kind(&self) -> SectionKind { + self.kind + } + + /// Returns the section's source range. + pub(in crate::docstring) const fn range(&self) -> TextRange { + self.range + } + + /// Consumes this section and returns its fragments when it can be rendered structurally. + pub(in crate::docstring) fn into_renderable_fragments(self) -> Option>> { + let SectionBody::Parsed { + fragments, + has_structural_ambiguity, + } = self.body + else { + return None; + }; + (!has_structural_ambiguity).then_some(fragments) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SectionBody { + /// A body parsed into semantic fragments. + Parsed { + fragments: Vec>, + /// Whether the body's structure is ambiguous. + has_structural_ambiguity: bool, + }, + /// A body whose contents were not parsed. + Opaque, +} + +impl SectionBody { + /// Creates an unambiguous body containing the description as a single prose fragment. + fn from_prose(description: String) -> Self { + let fragments = (!description.is_empty()) + .then_some(BodyFragment::Prose(description)) + .into_iter() + .collect(); + Self::Parsed { + fragments, + has_structural_ambiguity: false, + } + } + + fn into_fragments(self) -> Vec> { + match self { + Self::Parsed { fragments, .. } => fragments, + Self::Opaque => Vec::new(), + } + } +} + +/// One parsed fragment in a structured section body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::docstring) enum BodyFragment { + /// Section-level prose that is not attached to an item. + Prose(String), + /// A named or anonymous section item. + Item(Item), +} + +/// An item in a structured docstring section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::docstring) struct Item { + display_name: Name, + ty: Option, + description: String, +} + +impl Item { + /// Consumes this item and returns its display parts. + pub(in crate::docstring) fn into_display_name_type_and_description( + self, + ) -> (Name, Option, String) { + (self.display_name, self.ty, self.description) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeaderKind { + Structured(SectionKind), + Opaque, +} + +impl HeaderKind { + fn is_parameter_section(self) -> bool { + matches!( + self, + Self::Structured( + SectionKind::Parameters + | SectionKind::KeywordArguments + | SectionKind::OtherParameters + ) + ) + } +} + +#[derive(Default)] +struct DescriptionBuilder<'a> { + inline: Option<&'a str>, + continuation_lines: Vec<&'a str>, +} + +impl<'a> DescriptionBuilder<'a> { + fn with_inline(inline: &'a str) -> Self { + let inline = inline.trim(); + Self { + inline: (!inline.is_empty()).then_some(inline), + continuation_lines: Vec::new(), + } + } + + fn push_line(&mut self, line: &'a str) { + // Keep a leading list item with the block so that its indentation establishes the baseline + // for nested items. Ordinary first lines use the allocation-free inline representation. + if self.inline.is_none() + && self.continuation_lines.is_empty() + && !starts_with_markdown_list_item(line.trim_start()) + { + self.inline = Some(line.trim()); + } else { + self.push_continuation(line); + } + } + + fn push_continuation(&mut self, line: &'a str) { + self.continuation_lines.push(line); + } + + fn finish(mut self) -> String { + if self.continuation_lines.is_empty() { + return self.inline.map_or_else(String::new, str::to_string); + } + + let continuation_indent = self + .continuation_lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| indentation(line)) + .min() + .unwrap_or_default(); + for line in &mut self.continuation_lines { + *line = if line.trim().is_empty() { + "" + } else { + strip_indentation(line, continuation_indent).trim_end() + }; + } + + if let Some(inline) = self.inline { + self.continuation_lines.insert(0, inline); + } + let lines = self.continuation_lines; + + let Some(start) = lines.iter().position(|line| !line.is_empty()) else { + return String::new(); + }; + let end = lines + .iter() + .rposition(|line| !line.is_empty()) + .map_or(start, |index| index + 1); + lines[start..end].join("\n") + } +} + +fn strip_indentation(line: &str, width: TextSize) -> &str { + let mut indentation_width = TextSize::default(); + for (index, char) in line.char_indices() { + let next_indentation_width = match char { + ' ' => indentation_width + TextSize::new(1), + '\t' => TextSize::new((indentation_width.to_u32() / 8 + 1) * 8), + _ => return &line[index..], + }; + + if next_indentation_width > width { + return &line[index..]; + } + + indentation_width = next_indentation_width; + if indentation_width == width { + return &line[index + char.len_utf8()..]; + } + } + + "" +} diff --git a/crates/ty_ide/src/docstring/document/google.rs b/crates/ty_ide/src/docstring/document/google.rs index 09e22ba36d..85322bffbf 100644 --- a/crates/ty_ide/src/docstring/document/google.rs +++ b/crates/ty_ide/src/docstring/document/google.rs @@ -35,12 +35,12 @@ use ruff_python_stdlib::identifiers::is_identifier; use ruff_python_trivia::Cursor; use ruff_text_size::{TextRange, TextSize}; -use super::SectionKind; use super::preformatted::PreformattedBlockScanner; use super::syntax::{ - ParsedLine, consume_quoted_string, container_block_end, indentation, parsed_lines, - split_once_at_top_level_colon, split_trailing_parenthetical, starts_with_markdown_list_item, + ParsedLine, consume_quoted_string, container_block_end, indentation, is_dotted_identifier, + parsed_lines, split_once_at_top_level_colon, split_trailing_parenthetical, }; +use super::{DescriptionBuilder, HeaderKind, SectionKind}; /// Returns parameter documentation from recognized Google-style parameter sections. /// @@ -73,95 +73,15 @@ pub(in crate::docstring) fn sections(source: &str) -> impl Iterator SectionKind { - self.kind - } - - /// Returns the section's source range. - pub(in crate::docstring) const fn range(&self) -> TextRange { - self.range - } - - /// Consumes this section and returns its fragments when it can be rendered structurally. - pub(in crate::docstring) fn into_renderable_fragments(self) -> Option> { - let SectionBody::Parsed { - fragments, - has_structural_ambiguity, - } = self.body - else { - return None; - }; - (!has_structural_ambiguity).then_some(fragments) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SectionBody { - /// A body parsed into semantic fragments. - Parsed { - fragments: Vec, - /// Whether the body's structure is ambiguous. - has_structural_ambiguity: bool, - }, - /// A body whose contents were not parsed. - Opaque, -} - -impl SectionBody { - /// Creates an unambiguous body containing the description as a single prose fragment. - fn from_prose(description: String) -> Self { - let fragments = (!description.is_empty()) - .then_some(BodyFragment::Prose(description)) - .into_iter() - .collect(); - Self::Parsed { - fragments, - has_structural_ambiguity: false, - } - } +pub(in crate::docstring) type Section = super::Section; - fn into_fragments(self) -> Vec { - match self { - Self::Parsed { fragments, .. } => fragments, - Self::Opaque => Vec::new(), - } - } -} +type SectionBody = super::SectionBody; /// One parsed fragment in a Google section body. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::docstring) enum BodyFragment { - /// Section-level prose that is not attached to a named item. - Prose(String), - /// A named item and its description. - Item(Item), -} +pub(in crate::docstring) type BodyFragment = super::BodyFragment; /// A named item in a Google section. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::docstring) struct Item { - display_name: String, - ty: Option, - description: String, -} - -impl Item { - /// Consumes this item and returns its display parts. - pub(in crate::docstring) fn into_display_name_type_and_description( - self, - ) -> (String, Option, String) { - (self.display_name, self.ty, self.description) - } -} +pub(in crate::docstring) type Item = super::Item; /// Splits a display name from a trailing parenthesized type. /// @@ -552,11 +472,6 @@ impl<'a> SectionBuilder<'a> { } } -/// Returns whether every component of `name` is a Python identifier. -fn is_dotted_identifier(name: &str) -> bool { - !name.is_empty() && name.split('.').all(is_identifier) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Header { kind: HeaderKind, @@ -571,12 +486,6 @@ enum HeaderForm { Inline, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HeaderKind { - Structured(SectionKind), - Opaque, -} - impl HeaderKind { fn from_name(name: &str) -> Option { let normalized = name @@ -757,74 +666,6 @@ impl<'a> ItemBuilder<'a> { } } -#[derive(Default)] -struct DescriptionBuilder<'a> { - inline: Option<&'a str>, - continuation_lines: Vec<&'a str>, -} - -impl<'a> DescriptionBuilder<'a> { - fn with_inline(inline: &'a str) -> Self { - let inline = inline.trim(); - Self { - inline: (!inline.is_empty()).then_some(inline), - continuation_lines: Vec::new(), - } - } - - fn push_line(&mut self, line: &'a str) { - // Keep a leading list item with the block so that its indentation establishes the baseline - // for nested items. Ordinary first lines use the allocation-free inline representation. - if self.inline.is_none() - && self.continuation_lines.is_empty() - && !starts_with_markdown_list_item(line.trim_start()) - { - self.inline = Some(line.trim()); - } else { - self.push_continuation(line); - } - } - - fn push_continuation(&mut self, line: &'a str) { - self.continuation_lines.push(line); - } - - fn finish(mut self) -> String { - if self.continuation_lines.is_empty() { - return self.inline.map_or_else(String::new, str::to_string); - } - - let continuation_indent = self - .continuation_lines - .iter() - .filter(|line| !line.trim().is_empty()) - .map(|line| indentation(line)) - .min() - .unwrap_or_default(); - for line in &mut self.continuation_lines { - *line = if line.trim().is_empty() { - "" - } else { - strip_indentation(line, continuation_indent).trim_end() - }; - } - - if let Some(inline) = self.inline { - self.continuation_lines.insert(0, inline); - } - let lines = self.continuation_lines; - - let Some(start) = lines.iter().position(|line| !line.is_empty()) else { - return String::new(); - }; - let end = lines - .iter() - .rposition(|line| !line.is_empty()) - .map_or(start, |index| index + 1); - lines[start..end].join("\n") - } -} - #[derive(Default)] struct ItemLine<'a> { /// Whether this line establishes item indentation for section-boundary detection. @@ -1051,41 +892,6 @@ fn is_attribute_display_name(display_name: &str) -> bool { .all(|name| is_dotted_identifier(name.trim())) } -fn strip_indentation(line: &str, width: TextSize) -> &str { - let mut indentation_width = TextSize::default(); - for (index, char) in line.char_indices() { - let next_indentation_width = match char { - ' ' => indentation_width + TextSize::new(1), - '\t' => TextSize::new((indentation_width.to_u32() / 8 + 1) * 8), - _ => return &line[index..], - }; - - if next_indentation_width > width { - return &line[index..]; - } - - indentation_width = next_indentation_width; - if indentation_width == width { - return &line[index + char.len_utf8()..]; - } - } - - "" -} - -impl HeaderKind { - fn is_parameter_section(self) -> bool { - matches!( - self, - Self::Structured( - SectionKind::Parameters - | SectionKind::KeywordArguments - | SectionKind::OtherParameters - ) - ) - } -} - #[cfg(test)] mod tests { use insta::assert_snapshot; diff --git a/crates/ty_ide/src/docstring/document/syntax.rs b/crates/ty_ide/src/docstring/document/syntax.rs index 4f9390aee3..401b7b5277 100644 --- a/crates/ty_ide/src/docstring/document/syntax.rs +++ b/crates/ty_ide/src/docstring/document/syntax.rs @@ -1,3 +1,4 @@ +use ruff_python_stdlib::identifiers::is_identifier; use ruff_python_trivia::{Cursor, leading_indentation, tab_offset_u32}; use ruff_source_file::UniversalNewlines; use ruff_text_size::{TextRange, TextSize}; @@ -30,6 +31,13 @@ pub(super) struct ParsedLine<'a> { pub(super) indent: TextSize, } +/// Returns whether every component of `name` is a Python identifier. +/// +/// For example, this returns `true` for `"package.Type"` and `false` for `"package.1"`. +pub(super) fn is_dotted_identifier(name: &str) -> bool { + !name.is_empty() && name.split('.').all(is_identifier) +} + /// Returns whether `line` starts with a `CommonMark` list-item marker. /// /// `CommonMark` limits ordered-list markers to nine digits to avoid integer From 437e4083d553b210bec1746bd6e60b2dcbe3a86d Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 17 Jul 2026 09:00:29 +0200 Subject: [PATCH 127/222] [ty] Fix memory report detail outcome icons (#26929) --- scripts/memory_report.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/memory_report.py b/scripts/memory_report.py index 91be2cb6db..cbbb93497e 100644 --- a/scripts/memory_report.py +++ b/scripts/memory_report.py @@ -224,9 +224,7 @@ def render_summary(projects: list[ProjectComparison]) -> str: ) for name, old_bytes, new_bytes in item_diffs[:MAX_CHANGED_ITEMS]: - outcome = format_outcome( - old_bytes=proj.old.total_bytes, new_bytes=proj.new.total_bytes - ) + outcome = format_outcome(old_bytes=old_bytes, new_bytes=new_bytes) lines.append( f"| `{name}` | {format_bytes(old_bytes)} | " From 543ea43a53c009ccbb8309a82aa6e2c5f96ac2ad Mon Sep 17 00:00:00 2001 From: Fabian Rutten <94969946+Aethryl@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:08:16 +0200 Subject: [PATCH 128/222] [ty] Collect Pydantic field metadata through subscripted generic `Annotated` aliases (#26885) ## Summary The bug: ty treats a Pydantic model field as a required constructor argument (`missing-argument`) when its default comes from a `Field(...)` that lives inside a generic `Annotated` type alias. For example: ```py from typing import Annotated, TypeVar from pydantic import BaseModel, Field T = TypeVar("T") MyField = Annotated[T, Field(default=0)] class M(BaseModel): value: MyField[int] M() # before this change: error[missing-argument] for `value` ``` At runtime pydantic makes `value` optional (it defaults to 0), so the error is a false positive. > AI assistance disclosure: I used AI to both draft the PR and the changes, while acknowledging your AI policy by carefully auditing the produced PR and changes. I understand them on a very narrow scoped basis (extending with the Subscript expression). Ofcourse I will reply to any comments on the PR myself, according to the policy. I want to be careful about whether this really belongs in ty, so I checked pydantic's runtime behavior and cross checked two other type checkers. Pydantic makes the field optional at runtime, so pydantic is doing the right thing and there is nothing to fix on that side. ty already honors this default for the inline and bare alias forms (added in #26650); the only case it misses is the subscripted generic alias, which pydantic treats exactly the same as the others. So to me this reads as an internal gap in ty's existing pydantic handling, not new behavior. The fix: in `FieldMetadata::collect_from_annotation`, the code that follows an alias back to its definition only ran when the annotation was a plain name (like `field: StrictInt`). A subscripted generic alias (`field: MyField[int]`) is an `Expr::Subscript`, so the function bailed out early and the default was lost. This change also handles `Expr::Subscript`: it takes the base name from `subscript.value` and then follows the alias the same way the plain name case already does. There is no existing issue for this at the ty repo, if thats the preferred way, I'll gladly open an issue and link it here. Scope: the metadata gets collected with the enclosing model's specialization rather than the alias's own subscript argument. From what I can tell that does not matter in practice, because a `Field` default is a runtime value, so its type cannot carry the alias's type variable, and I checked that a generic model using the alias still behaves correctly. There is a bigger direction hinted at in the existing comment and in astral-sh/ty#504 (keep the `Annotated` metadata through specialization instead of re-parsing the alias definition), but that is past what I understand and felt out of scope for this bug. ## Test Plan - Added a regression test to `resources/mdtest/external/pydantic.md` (`ModelWithGenericAliasField`). - Checked that it fails before the change (unexpected `missing-argument` for `value`) and passes after, run with `MDTEST_EXTERNAL=1` so real pydantic gets installed. - Ran a standalone repro through a locally built `ty` CLI, including a generic model variant. It went from `missing-argument` to clean. - Confirmed on pydantic 2.13.4 that all three forms above are optional at runtime, and that pyright and mypy (with the pydantic plugin) currently report all three as required. - Ran the pre-PR checks locally and they all pass: `cargo clippy --workspace --all-targets --all-features -- -D warnings` is clean, the full `cargo test --workspace` passes with 0 failures (whole ruff plus ty suite), the external regression mdtest passes with `MDTEST_EXTERNAL=1`, and `prek` passes on the changed files. One note: the markdown hook rewrapped a single prose line in the test file, which is folded into the commit. --- .../resources/mdtest/external/pydantic.md | 17 +++++++++++++++++ .../src/types/dedicated/pydantic.rs | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md index 63ac983339..03779eb0d1 100644 --- a/crates/ty_python_semantic/resources/mdtest/external/pydantic.md +++ b/crates/ty_python_semantic/resources/mdtest/external/pydantic.md @@ -940,6 +940,23 @@ class ModelWithAliasField(BaseModel): ModelWithAliasField() ``` +Field metadata is also collected through a generic alias, where the `Field(...)` default is carried +on a type variable that is only specialized at the use site: + +```py +from typing import TypeVar + +T = TypeVar("T") +GenericAliasField = Annotated[T, Field(default=0)] + +class ModelWithGenericAliasField(BaseModel): + value: GenericAliasField[int] + +# `value` is optional because the alias supplies `Field(default=0)`. +ModelWithGenericAliasField() +ModelWithGenericAliasField(value=1) +``` + ## Frozen models and fields There are various ways to make a field immutable. A model can be globally frozen using a class diff --git a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs index 908e65f3c8..43a2876b74 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pydantic.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pydantic.rs @@ -144,8 +144,15 @@ impl<'db> FieldMetadata<'db> { return; } - let Expr::Name(name) = annotation else { - return; + let name = match annotation { + Expr::Name(name) => name, + Expr::Subscript(subscript) => { + let Expr::Name(name) = subscript.value.as_ref() else { + return; + }; + name + } + _ => return, }; // The following part is unfortunate. Pydantic defines `StrictInt` and the other aliases From 676aefdb44dca6d8a52be55ae410183621b632b0 Mon Sep 17 00:00:00 2001 From: Jonathan Dung Date: Fri, 17 Jul 2026 21:01:28 +0800 Subject: [PATCH 129/222] Add missing period in `RUF200` "Why is this bad?" documentation portion (#26930) --- .../ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs index 9e9675041c..3c64e79ce6 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_pyproject_toml.rs @@ -12,7 +12,7 @@ use crate::{FixAvailability, Violation, checkers::ast::LintContext}; /// Checks for any pyproject.toml that does not conform to the schema from the relevant PEPs. /// /// ## Why is this bad? -/// Your project may contain invalid metadata or configuration without you noticing +/// Your project may contain invalid metadata or configuration without you noticing. /// /// ## Example /// ```toml From 7bdf9a82f3323090f5454705ef66084a3df1bc37 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:08:51 -0400 Subject: [PATCH 130/222] [`pyupgrade`] Fix false positive with `TypeVar` default before Python 3.13 (`UP040`) (#26888) Summary -- This PR fixes a latent bug in `UP040` related to the similar changes to the other PEP-695 rules `UP046` and `UP047` in #21045. In short, `typing_extensions` backports the `default` argument for `TypeVar`s to earlier versions of Python, so checking preview alone isn't sufficient. Even the existing preview check only applied to the `TypeAlias` part of `UP040`, not the `TypeAliasType` part, so I moved the check into `create_diagnostic`, which is shared by both paths. This was never reported, so I'm planning to stabilize the preview feature in 0.16 anyway. Test Plan -- New mdtests and updates to existing snapshots --- .../mdtest/pyupgrade/non-pep695-type-alias.md | 34 +++++++++++++++++++ .../rules/pep695/non_pep695_type_alias.rs | 16 +++++---- ...er__rules__pyupgrade__tests__UP040.py.snap | 18 ---------- ...pgrade__tests__UP040.py__preview_diff.snap | 21 +++++++++++- 4 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/non-pep695-type-alias.md diff --git a/crates/ruff_linter/resources/mdtest/pyupgrade/non-pep695-type-alias.md b/crates/ruff_linter/resources/mdtest/pyupgrade/non-pep695-type-alias.md new file mode 100644 index 0000000000..f0ffa45124 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyupgrade/non-pep695-type-alias.md @@ -0,0 +1,34 @@ +# `non-pep695-type-alias` (`UP040`) + +## `TypeVar` defaults before Python 3.13 + +`typing_extensions` backports the `default` argument to Python 3.12 and earlier, but the PEP-695 +syntax enforced by the rule is only available on 3.13 and later, so we have to avoid a diagnostic in +both of these cases. + +```toml +target-version = "py312" + +[lint] +preview = true +select = ["non-pep695-type-alias"] +``` + +### `TypeAlias` + +```py +from typing import TypeAlias +from typing_extensions import TypeVar + +T = TypeVar("T", default=int) +Alias: TypeAlias = list[T] +``` + +### `TypeAliasType` + +```py +from typing_extensions import TypeAliasType, TypeVar + +T = TypeVar("T", default=int) +Alias = TypeAliasType("Alias", list[T], type_params=(T,)) +``` diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs index f8f18c213e..f786b36c8c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/pep695/non_pep695_type_alias.rs @@ -238,13 +238,6 @@ pub(crate) fn non_pep695_type_alias(checker: &Checker, stmt: &StmtAnnAssign) { .unique_by(|tvar| tvar.name) .collect::>(); - // Skip if any TypeVar has defaults and preview mode is not enabled - if vars.iter().any(|tv| tv.default.is_some()) - && !is_type_var_default_enabled(checker.settings()) - { - return; - } - create_diagnostic( checker, stmt.into(), @@ -264,6 +257,15 @@ fn create_diagnostic( type_vars: &[TypeVar], type_alias_kind: TypeAliasKind, ) { + // If any type variables have defaults, skip the rule unless + // running with preview mode enabled and targeting Python 3.13+. + if (checker.target_version() < PythonVersion::PY313 + || !is_type_var_default_enabled(checker.settings())) + && type_vars.iter().any(|type_var| type_var.default.is_some()) + { + return; + } + let source = checker.source(); let tokens = checker.tokens(); let comment_ranges = checker.comment_ranges(); diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap index a05ea4241b..e3acbbb7a5 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py.snap @@ -321,24 +321,6 @@ help: Use the `type` keyword 86 | | -UP040 [*] Type alias `AnyList` uses `TypeAliasType` assignment instead of the `type` keyword - --> UP040.py:95:1 - | -93 | # `default` was added in Python 3.13 -94 | T = typing.TypeVar("T", default=Any) -95 | AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -96 | -97 | # unsafe fix if comments within the fix - | -help: Use the `type` keyword - | -94 | T = typing.TypeVar("T", default=Any) - - AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) -95 + type AnyList[T = Any] = list[T] -96 | - | - UP040 [*] Type alias `PositiveList` uses `TypeAliasType` assignment instead of the `type` keyword --> UP040.py:99:1 | diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap index d26e2ae8e4..f2eed99224 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP040.py__preview_diff.snap @@ -7,7 +7,7 @@ source: crates/ruff_linter/src/rules/pyupgrade/mod.rs --- Summary --- Removed: 0 -Added: 2 +Added: 3 --- Added --- UP040 [*] Type alias `x` uses `TypeAlias` annotation instead of the `type` keyword @@ -30,6 +30,25 @@ help: Use the `type` keyword note: This is an unsafe fix and may change runtime behavior +UP040 [*] Type alias `AnyList` uses `TypeAliasType` assignment instead of the `type` keyword + --> UP040.py:95:1 + | +93 | # `default` was added in Python 3.13 +94 | T = typing.TypeVar("T", default=Any) +95 | AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +96 | +97 | # unsafe fix if comments within the fix + | +help: Use the `type` keyword + | +94 | T = typing.TypeVar("T", default=Any) + - AnyList = TypeAliasType("AnyList", list[T], type_params=(T,)) +95 + type AnyList[T = Any] = list[T] +96 | + | + + UP040 [*] Type alias `DefaultList` uses `TypeAlias` annotation instead of the `type` keyword --> UP040.py:134:1 | From 9e2d4a93743599ebc6e0589ea4974c90b5e0640e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 17 Jul 2026 14:17:40 -0400 Subject: [PATCH 131/222] [ty] Infer isinstance results for fixed class tuples (#26935) ## Summary Right now, we infer `isinstance(other, Foo)` as `Literal[True]` when `other` is known to be a `Foo`, but the equivalent tuple form remains `bool`. That causes a false implicit-return diagnostic in: ```py class Foo: ... class Bar: ... def func(other: Foo) -> bool: if isinstance(other, (Foo, Bar)): return True ``` This PR extends the existing `isinstance` truthiness inference to handle fixed, top-level tuples of exact class literals. Closes https://github.com/astral-sh/ty/issues/4021. --- .../resources/mdtest/call/builtins.md | 107 ++++++++++++++++++ .../ty_python_semantic/src/types/function.rs | 51 +++++++-- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 00edf29e61..67c9b13df6 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -161,6 +161,113 @@ def _( reveal_type(isinstance(x_constrained_sub_a, B)) # revealed: bool ``` +An `isinstance` check against a tuple is always true when each possible type of the checked value is +accepted by at least one class in the tuple. This avoids a false implicit-return error when the +check is the only path that returns a value. The same applies when the tuple is assigned to a local +variable. + +```py +def reveal_tuple_result(x: A | B): + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + +def accepts_a(x: A) -> bool: + if isinstance(x, (B, A)): + return True + +def accepts_a_or_b(x: A | B) -> bool: + if isinstance(x, (A, B)): + return True + +def accepts_object(x: object) -> bool: + if isinstance(x, (object,)): + return True + +def accepts_stored_tuple(x: A | B) -> bool: + targets = (A, B) + if isinstance(x, targets): + return True +``` + +The tuple must contain a fixed set of known classes. A partial tuple cannot cover a union, a +variadic tuple can be empty, and a value annotated as `type[A]` can refer to a subclass of `A`. +Nested tuples, unions used as tuple elements, and aliases such as `typing.List` are also left as +`bool`. + +```py +from typing import List + +def reveal_unsupported_tuple_results(x: A | B, items: list[int]): + reveal_type(isinstance(x, (A, (B, bytes)))) # revealed: bool + reveal_type(isinstance(x, (A | B,))) # revealed: bool + reveal_type(isinstance(items, (List,))) # revealed: bool + +def partial_tuple(x: A | B) -> bool: # error: [invalid-return-type] + if isinstance(x, (A, bytes)): + return True + +def variadic_tuple(x: A, targets: tuple[type[A], ...]) -> bool: # error: [invalid-return-type] + if isinstance(x, targets): + return True + +def subclass_target(x: A, target: type[A]) -> bool: # error: [invalid-return-type] + if isinstance(x, (target,)): + return True +``` + +The single-class path already leaves runtime-checkable protocol checks as `bool`. Tuple members use +the same inference, so a class that only appears structurally compatible does not make the tuple +check certain. + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class RuntimeProtocol(Protocol): + value: int + +class StructuralImplementation: + value: int + +reveal_type(isinstance(StructuralImplementation(), RuntimeProtocol)) # revealed: bool +reveal_type(isinstance(StructuralImplementation(), (RuntimeProtocol,))) # revealed: bool +``` + +Single-class `isinstance` inference does not account for an overridden `__instancecheck__`. Tuple +members again use the same inference. Python returns `False` for these checks, while ty infers +`Literal[True]`. + +```py +class RejectingMeta(type): + def __instancecheck__(self, instance: object, /) -> bool: + return False + +class RejectingBase(metaclass=RejectingMeta): ... +class RejectingChild(RejectingBase): ... + +reveal_type(isinstance(RejectingChild(), RejectingBase)) # revealed: Literal[True] +reveal_type(isinstance(RejectingChild(), (RejectingBase,))) # revealed: Literal[True] +``` + +The same limitation applies to `type`: `list[int]` is accepted where `type` is expected, but +`isinstance(list[int], type)` is false at runtime. Single-class and tuple checks both infer +`Literal[True]` for a bare `type` and a type variable bound to `type`. + +```py +T_bound_type = TypeVar("T_bound_type", bound=type) + +def bare_type(x: type): + reveal_type(isinstance(x, type)) # revealed: Literal[True] + reveal_type(isinstance(x, (type,))) # revealed: Literal[True] + +bare_type(list[int]) + +def type_variable_bound_to_type(x: T_bound_type): + reveal_type(isinstance(x, type)) # revealed: Literal[True] + reveal_type(isinstance(x, (type,))) # revealed: Literal[True] + +type_variable_bound_to_type(list[int]) +``` + Certain special forms in the typing module are not instances of `type`, so are strictly-speaking disallowed as the second argument to `isinstance()` according to typeshed's annotations. However, at runtime they work fine as the second argument, and we implement that special case in ty: diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index e87557f4ab..bc48abeece 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1890,6 +1890,41 @@ fn is_instance_truthiness<'db>( } } +/// Return whether a fixed `isinstance` tuple covers every member of an input union. +/// +/// Each class in the tuple uses the same truthiness inference as a single-class `isinstance` check. +/// +/// ```python +/// def f(x: A | B) -> bool: +/// if isinstance(x, (A, B)): +/// return True +/// ``` +fn is_instance_tuple_exhaustive<'db>(db: &'db dyn Db, ty: Type<'db>, classinfo: Type<'db>) -> bool { + let Some(tuple) = classinfo + .as_nominal_instance() + .and_then(|nominal| nominal.tuple_spec(db)) + else { + return false; + }; + if tuple.is_variadic() { + return false; + } + + let is_covered = |ty: Type<'db>| { + tuple.fixed_elements().any(|element| { + let Type::ClassLiteral(class) = element else { + return false; + }; + is_instance_truthiness(db, ty, *class).is_always_true() + }) + }; + + match ty { + Type::Union(union) => union.elements(db).iter().copied().all(is_covered), + ty => is_covered(ty), + } +} + /// Returns `true` if the function body is stub-like, ignoring a leading docstring. pub(crate) fn function_has_stub_body(node: &ast::StmtFunctionDef) -> bool { let suite = ast::helpers::body_without_leading_docstring(&node.body); @@ -2562,13 +2597,15 @@ impl KnownFunction { call_expression.arguments.args.get(1), ); - if let Type::ClassLiteral(class) = second_argument - && self == KnownFunction::IsInstance - { - overload.set_return_type(Type::from_truthiness( - db, - is_instance_truthiness(db, *first_arg, *class), - )); + if self == KnownFunction::IsInstance { + let truthiness = match second_argument { + Type::ClassLiteral(class) => is_instance_truthiness(db, *first_arg, *class), + _ if is_instance_tuple_exhaustive(db, *first_arg, *second_argument) => { + Truthiness::AlwaysTrue + } + _ => Truthiness::Ambiguous, + }; + overload.set_return_type(Type::from_truthiness(db, truthiness)); } } From 1075faba5f122191150a8d6bd4834e11845bcc35 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 17 Jul 2026 09:18:39 -0400 Subject: [PATCH 132/222] [ty] Update to ignore 0.4.30 --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8804713f4..dd474670ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,7 +566,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -685,7 +685,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -1036,7 +1036,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -1116,7 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -1598,9 +1598,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.27" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" dependencies = [ "crossbeam-deque", "globset", @@ -3857,7 +3857,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -4253,10 +4253,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -5462,7 +5462,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b67032532f..f6e605f085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,7 +114,7 @@ hashbrown = { version = "0.17.0", default-features = false, features = [ ] } heck = "0.5.0" icu_properties = { version = "2.1.2" } -ignore = { version = "0.4.24" } +ignore = { version = "0.4.30" } imara-diff = { version = "0.2.0" } imperative = { version = "1.0.4" } indexmap = { version = "2.6.0" } From 86af403bcec40b1e492c672a432afec142b79569 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 17 Jul 2026 09:19:02 -0400 Subject: [PATCH 133/222] [ty] Minimally replace existing matcher with `ignore::IncrementalIgnore` This doesn't get rid of `Uncertain`. But this does update a couple tests to be correct since the `ignore` crate provides better precision. --- crates/ruff_db/src/system/os.rs | 17 +- crates/ruff_db/src/system/os/ignore.rs | 212 ++----------------------- 2 files changed, 26 insertions(+), 203 deletions(-) diff --git a/crates/ruff_db/src/system/os.rs b/crates/ruff_db/src/system/os.rs index 6732ed22bb..d2f88941ef 100644 --- a/crates/ruff_db/src/system/os.rs +++ b/crates/ruff_db/src/system/os.rs @@ -341,11 +341,18 @@ impl DirectoryWalker for OsDirectoryWalker { &self, configuration: WalkDirectoryConfiguration, ) -> Box { - // N.B. The current work-around `IgnoreFiles` implementation doesn't - // support any configuration right now. This will be fixed once we - // switch to the `ignore` crate's implementation. --AG - let WalkDirectoryConfiguration { paths, .. } = configuration; - Box::new(IgnoreFiles::new(paths)) + let WalkDirectoryConfiguration { + paths, + ignore_hidden: hidden, + standard_filters, + } = configuration; + + let mut builder = ::ignore::WalkBuilder::from_iter(paths.iter().map(|p| p.as_std_path())); + builder.current_dir(self.cwd.as_std_path()); + builder.standard_filters(standard_filters); + builder.hidden(hidden); + let root_matchers = builder.build_matchers(); + Box::new(IgnoreFiles { root_matchers }) } } diff --git a/crates/ruff_db/src/system/os/ignore.rs b/crates/ruff_db/src/system/os/ignore.rs index 664bfbb19f..c64d713150 100644 --- a/crates/ruff_db/src/system/os/ignore.rs +++ b/crates/ruff_db/src/system/os/ignore.rs @@ -1,96 +1,26 @@ -//! Checks whether a root ignore file lets incremental indexing skip a project -//! walk. -//! -//! A full project walk decides which files belong to a project. Incremental -//! file watcher updates must make the same decision for newly created paths, -//! including the effect of `.ignore` and `.gitignore` files. Ideally, the -//! `ignore` crate would expose an API that answers whether a given path is -//! ignored. It does not, and reimplementing that decision is involved because -//! it would need to reproduce all of the walker's ignore behavior: -//! -//! - parent `.ignore` files; -//! - parent `.gitignore` files; -//! - `.git/info/exclude`; -//! - the global gitignore; -//! - git and jj repository discovery; -//! - traversal ordering, such as pruning `build/` before reading a nested -//! `build/.ignore`; and -//! - linked git worktree handling. -//! -//! This module is a compromise. It avoids unnecessary traversal in the common -//! case where a file or directory is already ignored by an ignore file at the -//! project walk root. - -use ::ignore::gitignore; -use rustc_hash::FxHashMap; +//! Checks whether paths are ignored during incremental project indexing. +use crate::system::SystemPath; use crate::system::walk_directory::{IgnoreIncremental, Ignored}; -use crate::system::{SystemPath, SystemPathBuf}; pub(super) struct IgnoreFiles { - walk_roots: Box<[SystemPathBuf]>, - roots: FxHashMap, -} - -impl IgnoreFiles { - pub(super) fn new(walk_roots: Vec) -> Self { - Self { - walk_roots: walk_roots.into_boxed_slice(), - roots: FxHashMap::default(), - } - } - - /// Answers the question whether the ignore file in the `root` directory - /// ignores `path`. - fn root_ignore_prunes_path( - roots: &mut FxHashMap, - root: &SystemPath, - path: &SystemPath, - is_directory: bool, - ) -> bool { - let Ok(relative_path) = path.strip_prefix(root) else { - return false; - }; - let mut components = relative_path.components(); - let Some(first_component) = components.next() else { - return false; - }; - - let first_child = root.join(first_component); - let first_child_is_target = components.next().is_none(); - - let first_child_is_directory = !first_child_is_target || is_directory; - - Self::root_ignore_files(roots, root).is_ignored(&first_child, first_child_is_directory) - } - - fn root_ignore_files<'a>( - roots: &'a mut FxHashMap, - root: &SystemPath, - ) -> &'a RootIgnoreFiles { - roots - .entry(root.to_path_buf()) - .or_insert_with(|| RootIgnoreFiles::read(root)) - } + pub(super) root_matchers: Vec, } impl IgnoreIncremental for IgnoreFiles { - /// Returns `Yes` only when the matching walk root can prune `path` - /// from its own ignore files. In all other cases, return uncertain. fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { - let Self { walk_roots, roots } = self; - - // A nested explicit walk root gets its own depth-0 walk, so an ancestor - // root cannot prove that the nested root's descendants are ignored. - let Some(root) = walk_roots - .iter() - .filter(|root| path.starts_with(root)) - .max_by_key(|root| root.as_str().len()) + let Some(root) = self + .root_matchers + .iter_mut() + .filter(|root| path.as_std_path().starts_with(root.root())) + .max_by_key(|root| root.root().as_os_str().len()) else { return Ignored::Uncertain; }; - - if Self::root_ignore_prunes_path(roots, root, path, is_directory) { + let Some(norm) = root.normalize(path.as_std_path()) else { + return Ignored::Uncertain; + }; + if root.matched(norm, is_directory).is_ignore() { Ignored::Yes } else { Ignored::Uncertain @@ -98,120 +28,6 @@ impl IgnoreIncremental for IgnoreFiles { } } -/// The cached ignore files for a specific root-folder. -struct RootIgnoreFiles { - ignore: Option, - gitignore: Option, -} - -impl RootIgnoreFiles { - fn read(root: &SystemPath) -> Self { - let canonical_root = root.as_utf8_path().canonicalize_utf8().ok().map(|path| { - SystemPathBuf::from_utf8_path_buf(path) - .simplified() - .to_path_buf() - }); - - let gitignore = if let Some(canonical_root) = canonical_root.as_deref() - && in_git_repository(canonical_root) - // A parent `.ignore` allowlist takes precedence over a matching - // `.gitignore` at the walk root. Let the walker resolve that case. - && !has_parent_ignore_file(canonical_root) - { - IgnoreFile::read(root, ".gitignore") - } else { - None - }; - - Self { - ignore: IgnoreFile::read(root, ".ignore"), - gitignore, - } - } - - fn is_ignored(&self, path: &SystemPath, is_directory: bool) -> bool { - for ignore_file in [&self.ignore, &self.gitignore].into_iter().flatten() { - match ignore_file.match_path(path, is_directory) { - Ok(Some(is_ignored)) => return is_ignored, - Ok(_) => {} - Err(()) => return false, - } - } - - false - } -} - -enum IgnoreFile { - Matcher(gitignore::Gitignore), - /// Building the matcher failed. - Error, -} - -impl IgnoreFile { - fn read(root: &SystemPath, file_name: &str) -> Option { - let ignore_path = root.join(file_name); - let contents = match std::fs::read_to_string(ignore_path.as_std_path()) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, - Err(_) => return Some(Self::Error), - }; - - match build_matcher(root, &ignore_path, &contents) { - Some(matcher) => Some(Self::Matcher(matcher)), - None => Some(Self::Error), - } - } - - fn match_path(&self, path: &SystemPath, is_directory: bool) -> Result, ()> { - let matcher = match self { - Self::Matcher(matcher) => matcher, - Self::Error => return Err(()), - }; - - Ok(match matcher.matched(path.as_std_path(), is_directory) { - ::ignore::Match::None => None, - ::ignore::Match::Ignore(_) => Some(true), - ::ignore::Match::Whitelist(_) => Some(false), - }) - } -} - -fn build_matcher( - root: &SystemPath, - ignore_path: &SystemPath, - contents: &str, -) -> Option { - const UTF8_BOM: &str = "\u{feff}"; - - let mut builder = gitignore::GitignoreBuilder::new(root.as_std_path()); - - let contents = contents.trim_start_matches(UTF8_BOM); - - for line in contents.lines() { - builder - .add_line(Some(ignore_path.as_std_path().to_path_buf()), line) - .ok()?; - } - - builder.build().ok() -} - -fn in_git_repository(canonical_root: &SystemPath) -> bool { - canonical_root.ancestors().any(|directory| { - directory.join(".git").as_std_path().exists() - || directory.join(".jj").as_std_path().exists() - }) -} - -fn has_parent_ignore_file(canonical_root: &SystemPath) -> bool { - canonical_root - .parent() - .into_iter() - .flat_map(SystemPath::ancestors) - .any(|directory| directory.join(".ignore").as_std_path().exists()) -} - #[cfg(test)] mod tests { use tempfile::TempDir; @@ -341,7 +157,7 @@ mod tests { let path = project.path("pkg/keep.py"); project.write_files([(project.path(".ignore"), "pkg/keep.py\n")]); - assert_eq!(project.is_ignored(&path), Ignored::Uncertain); + assert_eq!(project.is_ignored(&path), Ignored::Yes); } #[test] @@ -354,7 +170,7 @@ mod tests { (project.path(".gitignore"), "build/\n"), ]); - assert_eq!(project.is_ignored(&path), Ignored::Uncertain); + assert_eq!(project.is_ignored(&path), Ignored::Yes); } #[test] From 9eecb45ad5c4040a5575b2e454df85513eefa8a7 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 17 Jul 2026 09:49:37 -0400 Subject: [PATCH 134/222] [ty] Simplify `IgnoreIncremental` interface We can now just return a predicate since the `ignore` crate should be "certain" (modulo bugs) about whether a path should be ignored or not. --- crates/ruff_db/src/system/memory_fs.rs | 10 ++--- crates/ruff_db/src/system/os/ignore.rs | 43 ++++++++------------- crates/ruff_db/src/system/walk_directory.rs | 21 +--------- crates/ty_project/src/db/changes.rs | 22 +++++------ 4 files changed, 32 insertions(+), 64 deletions(-) diff --git a/crates/ruff_db/src/system/memory_fs.rs b/crates/ruff_db/src/system/memory_fs.rs index 3b3da5c324..bba5c6e783 100644 --- a/crates/ruff_db/src/system/memory_fs.rs +++ b/crates/ruff_db/src/system/memory_fs.rs @@ -13,7 +13,7 @@ use crate::system::{ }; use super::walk_directory::{ - DirectoryWalker, IgnoreIncremental, Ignored, WalkDirectoryBuilder, WalkDirectoryConfiguration, + DirectoryWalker, IgnoreIncremental, WalkDirectoryBuilder, WalkDirectoryConfiguration, WalkDirectoryVisitor, WalkDirectoryVisitorBuilder, WalkState, }; @@ -569,16 +569,12 @@ struct MemoryIgnoreIncremental { } impl IgnoreIncremental for MemoryIgnoreIncremental { - fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> bool { // This matches the semantics of the in-memory recursive // directory traversal. That is, the only thing we care // about filtering is hidden files. We let everything else // through. - if self.ignore_hidden && !is_directory && is_hidden(path) { - Ignored::Yes - } else { - Ignored::Uncertain - } + self.ignore_hidden && !is_directory && is_hidden(path) } } diff --git a/crates/ruff_db/src/system/os/ignore.rs b/crates/ruff_db/src/system/os/ignore.rs index c64d713150..6279c593ef 100644 --- a/crates/ruff_db/src/system/os/ignore.rs +++ b/crates/ruff_db/src/system/os/ignore.rs @@ -1,30 +1,26 @@ //! Checks whether paths are ignored during incremental project indexing. use crate::system::SystemPath; -use crate::system::walk_directory::{IgnoreIncremental, Ignored}; +use crate::system::walk_directory::IgnoreIncremental; pub(super) struct IgnoreFiles { pub(super) root_matchers: Vec, } impl IgnoreIncremental for IgnoreFiles { - fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored { + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> bool { let Some(root) = self .root_matchers .iter_mut() .filter(|root| path.as_std_path().starts_with(root.root())) .max_by_key(|root| root.root().as_os_str().len()) else { - return Ignored::Uncertain; + return false; }; let Some(norm) = root.normalize(path.as_std_path()) else { - return Ignored::Uncertain; + return false; }; - if root.matched(norm, is_directory).is_ignore() { - Ignored::Yes - } else { - Ignored::Uncertain - } + root.matched(norm, is_directory).is_ignore() } } @@ -32,7 +28,6 @@ impl IgnoreIncremental for IgnoreFiles { mod tests { use tempfile::TempDir; - use crate::system::walk_directory::Ignored; use crate::system::{OsSystem, System, SystemPath, SystemPathBuf}; struct TestProject { @@ -75,11 +70,11 @@ mod tests { std::fs::create_dir_all(path.as_ref().as_std_path()).unwrap(); } - fn is_ignored(&self, path: &SystemPath) -> Ignored { + fn is_ignored(&self, path: &SystemPath) -> bool { self.is_ignored_from(std::slice::from_ref(&self.root), path) } - fn is_ignored_from(&self, walk_roots: &[SystemPathBuf], path: &SystemPath) -> Ignored { + fn is_ignored_from(&self, walk_roots: &[SystemPathBuf], path: &SystemPath) -> bool { let (first, additional) = walk_roots.split_first().unwrap(); let mut builder = self.system.walk_directory(first); @@ -100,7 +95,7 @@ mod tests { (project.path("build/.ignore"), "!keep.py\n"), ]); - assert_eq!(project.is_ignored(&path), Ignored::Yes); + assert!(project.is_ignored(&path)); } #[test] @@ -110,7 +105,7 @@ mod tests { let path = project.path("build/keep.py"); project.write_files([(project.path(".gitignore"), "build/\n")]); - assert_eq!(project.is_ignored(&path), Ignored::Uncertain); + assert!(!project.is_ignored(&path)); } #[test] @@ -119,7 +114,7 @@ mod tests { let path = project.path("build/keep.py"); project.write_files([(project.path(".ignore"), "\u{feff}build/\n")]); - assert_eq!(project.is_ignored(&path), Ignored::Yes); + assert!(project.is_ignored(&path)); } #[test] @@ -132,7 +127,7 @@ mod tests { (project.path(".gitignore"), "build/\n"), ]); - assert_eq!(project.is_ignored(&path), Ignored::Uncertain); + assert!(!project.is_ignored(&path)); } #[test] @@ -148,7 +143,7 @@ mod tests { ), ]); - assert_eq!(project.is_ignored(&path), Ignored::Uncertain); + assert!(!project.is_ignored(&path)); } #[test] @@ -157,7 +152,7 @@ mod tests { let path = project.path("pkg/keep.py"); project.write_files([(project.path(".ignore"), "pkg/keep.py\n")]); - assert_eq!(project.is_ignored(&path), Ignored::Yes); + assert!(project.is_ignored(&path)); } #[test] @@ -170,7 +165,7 @@ mod tests { (project.path(".gitignore"), "build/\n"), ]); - assert_eq!(project.is_ignored(&path), Ignored::Yes); + assert!(project.is_ignored(&path)); } #[test] @@ -179,10 +174,7 @@ mod tests { let path = project.path("build/keep.py"); project.write_files([(project.path(".ignore"), "build/\n")]); - assert_eq!( - project.is_ignored_from(&[project.root.clone(), path.clone()], &path), - Ignored::Uncertain - ); + assert!(!project.is_ignored_from(&[project.root.clone(), path.clone()], &path)); } #[test] @@ -195,9 +187,6 @@ mod tests { (project.path("pkg/.ignore"), "build/\n"), ]); - assert_eq!( - project.is_ignored_from(&[project.root.clone(), nested_root], &path), - Ignored::Yes - ); + assert!(project.is_ignored_from(&[project.root.clone(), nested_root], &path)); } } diff --git a/crates/ruff_db/src/system/walk_directory.rs b/crates/ruff_db/src/system/walk_directory.rs index 5653f212a7..baa59f1c35 100644 --- a/crates/ruff_db/src/system/walk_directory.rs +++ b/crates/ruff_db/src/system/walk_directory.rs @@ -4,27 +4,10 @@ use std::path::PathBuf; use super::{FileType, SystemPath}; -/// Whether a path is known to be ignored by a directory walker. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum Ignored { - /// An ignore file proves that the directory walker would skip this path. - Yes, - - /// The path might be ignored, but a directory walk is required to know for sure. - Uncertain, -} - -impl Ignored { - /// Returns `true` if a directory walk is required to determine whether the path is ignored. - pub const fn is_uncertain(self) -> bool { - matches!(self, Self::Uncertain) - } -} - /// A matcher for determining whether paths are ignored during incremental directory walking. pub trait IgnoreIncremental { - /// Returns whether the directory walker is known to ignore `path`. - fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> Ignored; + /// Returns whether the directory walker ignores `path`. + fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> bool; } /// A builder for constructing a directory recursive traversal. diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index ef418b7064..c27045e445 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -91,7 +91,7 @@ impl ProjectDatabase { reload_project_files = true; } else if project.is_directory_included(self, directory) && ignore_files.as_mut().is_none_or(|ignore_files| { - ignore_files.is_ignored(directory, true).is_uncertain() + !ignore_files.is_ignored(directory, true) }) { tracing::debug!( @@ -142,8 +142,8 @@ impl ProjectDatabase { } // A created file can be indexed directly unless project indexing needs the - // walker to apply ignore-file semantics. The ignore fast path below skips - // that walk when it can prove a root ignore file already prunes the path. + // walker to apply ignore-file semantics. The ignore check below skips that + // walk when the path is ignored. if !project.file_set(self).is_lazy() { if self.system().is_file(path) { if !project @@ -153,17 +153,17 @@ impl ProjectDatabase { continue; } - if let Some(ignore_files) = ignore_files.as_mut() { - if ignore_files.is_ignored(path, false).is_uncertain() { - added_paths.insert(path.to_path_buf()); - } - } else if let Ok(file) = system_path_to_file(self, path) { + if ignore_files + .as_mut() + .is_none_or(|ignore_files| !ignore_files.is_ignored(path, false)) + && let Ok(file) = system_path_to_file(self, path) + { project.add_file(self, file); } } else if project.is_directory_included(self, path) - && ignore_files.as_mut().is_none_or(|ignore_files| { - ignore_files.is_ignored(path, true).is_uncertain() - }) + && ignore_files + .as_mut() + .is_none_or(|ignore_files| !ignore_files.is_ignored(path, true)) { // Unlike a new file, a new directory needs walking to discover // project files that exist below it. From 990180a42eb64f385fb4e3c3cc0ce9adb73894a4 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 17 Jul 2026 09:53:51 -0400 Subject: [PATCH 135/222] [ty] Optimize ignore path checking Since our file paths come directly from the roots, we should be able to just do a simple prefix strip here instead of "full" normalization. --- crates/ruff_db/src/system/os/ignore.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ruff_db/src/system/os/ignore.rs b/crates/ruff_db/src/system/os/ignore.rs index 6279c593ef..03c73e4e73 100644 --- a/crates/ruff_db/src/system/os/ignore.rs +++ b/crates/ruff_db/src/system/os/ignore.rs @@ -9,18 +9,18 @@ pub(super) struct IgnoreFiles { impl IgnoreIncremental for IgnoreFiles { fn is_ignored(&mut self, path: &SystemPath, is_directory: bool) -> bool { - let Some(root) = self + let Some((root, relative)) = self .root_matchers .iter_mut() - .filter(|root| path.as_std_path().starts_with(root.root())) - .max_by_key(|root| root.root().as_os_str().len()) + .filter_map(|root| { + let relative = path.as_std_path().strip_prefix(root.root()).ok()?; + Some((root, relative)) + }) + .max_by_key(|(root, _)| root.root().as_os_str().len()) else { return false; }; - let Some(norm) = root.normalize(path.as_std_path()) else { - return false; - }; - root.matched(norm, is_directory).is_ignore() + root.matched(relative, is_directory).is_ignore() } } From b3ab2aa68763eac461766d314a0c814e593cef76 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 17 Jul 2026 12:50:23 -0700 Subject: [PATCH 136/222] [ty] Narrow walrus expression values (#26949) ## Summary Fixes astral-sh/ty#4020. Narrowing predicates on the right-hand side of a walrus expression were evaluated only as simple expressions, so checks such as `if result := isinstance(value, int):`, `TypeGuard`/`TypeIs` predicates, comparisons, and aliased conditions failed to narrow `value`. This recursively evaluates the walrus value as a predicate, preserves the target's truthiness constraint, and discards constraints on the rebound target and its member places. ## Test plan Added focused mdtests for `isinstance`, `TypeGuard`/`TypeIs`, equality/identity comparisons, and aliased predicates. --- crates/ty_python_core/src/builder.rs | 3 +++ .../mdtest/narrow/aliased_conditions.md | 7 +++-- .../mdtest/narrow/conditionals/eq.md | 21 +++++++++++++++ .../mdtest/narrow/conditionals/is.md | 14 ++++++++++ .../resources/mdtest/narrow/isinstance.md | 11 +++++++- .../resources/mdtest/narrow/type_guards.md | 26 +++++++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 24 +++++++++++++++-- 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index c507699b96..9e42c7e762 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -1253,6 +1253,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { Self::walk_narrowing_alias_predicate(&expr_if.body, f); Self::walk_narrowing_alias_predicate(&expr_if.orelse, f); } + ast::Expr::Named(expr_named) => { + Self::walk_narrowing_alias_predicate(&expr_named.value, f); + } _ => {} } } diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md b/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md index 468241e2a8..09f1587d1e 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/aliased_conditions.md @@ -152,8 +152,11 @@ def _(x: int | None): # TODO: same as above reveal_type(x) # revealed: int | None if y := is_none: - # TODO: same as above - reveal_type(x) # revealed: int | None + reveal_type(x) # revealed: None + reveal_type(y) # revealed: Literal[True] + else: + reveal_type(x) # revealed: int + reveal_type(y) # revealed: Literal[False] if (lambda: is_none)(): # TODO: same as above reveal_type(x) # revealed: int | None diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index f4124df95f..f8c8a360d6 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -1299,6 +1299,27 @@ if (x := f()) != 1: reveal_type(x) # revealed: Literal[2, 3] else: reveal_type(x) # revealed: Literal[1] + +value = f() +if result := (value == 1): + reveal_type(value) # revealed: Literal[1] + reveal_type(result) # revealed: Literal[True] +else: + reveal_type(value) # revealed: Literal[2, 3] + reveal_type(result) # revealed: Literal[False] + +class A: + tag: Literal["a"] + +class B: + tag: Literal["b"] + +def overwritten_tagged_union(value: A | B | bool): + if isinstance(value, (A, B)): + if value := (value.tag == "a"): + reveal_type(value) # revealed: Literal[True] + else: + reveal_type(value) # revealed: Literal[False] ``` ## Union with `Any` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/is.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/is.md index 436c63c103..6661ca5db0 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/is.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/is.md @@ -110,6 +110,20 @@ if (x := f()) is None: reveal_type(x) # revealed: None else: reveal_type(x) # revealed: Literal[1, 2] + +value = f() +if result := (value is None): + reveal_type(value) # revealed: None + reveal_type(result) # revealed: Literal[True] +else: + reveal_type(value) # revealed: Literal[1, 2] + reveal_type(result) # revealed: Literal[False] + +value = f() +if value := (value is None): + reveal_type(value) # revealed: Literal[True] +else: + reveal_type(value) # revealed: Literal[False] ``` ## `is` with two narrowable operands diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 7819d0fd23..0777762912 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -837,7 +837,8 @@ def test(a: Any, items: list[T]) -> None: ## Narrowing with named expressions (walrus operator) When `isinstance()` is used with a named expression, the target of the named expression should be -narrowed. +narrowed. When the `isinstance()` check is the value of a named expression, its argument should also +be narrowed. ```py def get_value() -> int | str: @@ -848,4 +849,12 @@ def f(): reveal_type(x) # revealed: int else: reveal_type(x) # revealed: str + + value = get_value() + if result := isinstance(value, int): + reveal_type(value) # revealed: int + reveal_type(result) # revealed: Literal[True] + else: + reveal_type(value) # revealed: str + reveal_type(result) # revealed: Literal[False] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md index 4ed3de2dd9..f09caedac3 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/type_guards.md @@ -586,6 +586,7 @@ def _(x: object): ## Narrowing with named expressions (walrus operator) When a type guard is used with a named expression, the target of the named expression should be +narrowed. When a type guard is the value of a named expression, its argument should also be narrowed. ```py @@ -610,4 +611,29 @@ def f(): reveal_type(y) # revealed: str else: reveal_type(y) # revealed: int | str + + value = get_value() + if result := is_str(value): + reveal_type(value) # revealed: str + reveal_type(result) # revealed: TypeIs[str @ value] & ~AlwaysFalsy + else: + reveal_type(value) # revealed: int + reveal_type(result) # revealed: TypeIs[str @ value] & ~AlwaysTruthy + + other = get_value() + if result := guard_str(other): + reveal_type(other) # revealed: str + reveal_type(result) # revealed: TypeGuard[str @ other] & ~AlwaysFalsy + else: + reveal_type(other) # revealed: int | str + reveal_type(result) # revealed: TypeGuard[str @ other] & ~AlwaysTruthy + +def guard_list(value: object) -> TypeGuard[list[int]]: + return isinstance(value, list) + +def overwritten_target(value: object): + if value := guard_list(value): + reveal_type(value) # revealed: TypeGuard[list[int] @ value] & ~AlwaysFalsy + else: + reveal_type(value) # revealed: TypeGuard[list[int] @ value] & ~AlwaysTruthy ``` diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 55ecee57f1..3e468f9590 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1158,7 +1158,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } ast::Expr::BoolOp(bool_op) => self.evaluate_bool_op(bool_op, expression, is_positive), ast::Expr::If(expr_if) => self.evaluate_expr_if(expr_if, expression, is_positive), - ast::Expr::Named(expr_named) => self.evaluate_expr_named(expr_named, is_positive), + ast::Expr::Named(expr_named) => { + self.evaluate_expr_named(expr_named, expression, is_positive) + } _ => None, } } @@ -2909,10 +2911,28 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { fn evaluate_expr_named( &mut self, expr_named: &ast::ExprNamed, + expression: Expression<'db>, is_positive: bool, ) -> Option> { let target_constraints = self.evaluate_simple_expr(&expr_named.target, is_positive); - let value_constraints = self.evaluate_simple_expr(&expr_named.value, is_positive); + let mut value_constraints = + self.evaluate_expression_node_predicate(&expr_named.value, expression, is_positive); + + if let Some(value_constraints) = value_constraints.as_mut() + && let Some(target) = PlaceExpr::try_from_expr(&expr_named.target) + && let Some(target_place) = self.places().place_id(&target) + { + let places = self.places(); + // The target is rebound after the value is evaluated, invalidating constraints on the + // target and any member places rooted at it. + value_constraints.retain(|place, _| { + *place != target_place + && !places + .parents(places.place(*place)) + .any(|parent| parent == target_place) + }); + } + match (target_constraints, value_constraints) { (Some(mut target), Some(value)) => { merge_constraints_and(&mut target, value); From 8a20a51d29646f538f899d6d74fc35b12723b497 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 17 Jul 2026 14:19:08 -0700 Subject: [PATCH 137/222] [ty] Narrow inline list and set membership checks (#26955) ## Summary Preserve the precise element types of immediately consumed list and set literals during membership narrowing. Collection inference normally promotes their members because the containers are mutable, which prevented both positive narrowing and the negative exclusions needed for exhaustive checks. This represents direct, non-starred list/set membership operands as temporary fixed-length tuples only while evaluating the narrowing constraint. The actual inferred container type is unchanged (including for hover and chained comparisons), starred expressions retain the conservative fallback, and existing equality/strict-literal-narrowing behavior is reused. Closes astral-sh/ty#4026. ## Test plan Adds mdtest coverage for: - positive and negative list/set membership branches; - literal-valued locals and mixed literals; - union-valued slots that cannot be safely excluded; - `assert_never` exhaustiveness; - strict literal narrowing. --- .../mdtest/narrow/conditionals/in.md | 90 +++++++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 36 +++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index 5c5674db30..c2c6fc1a52 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -68,6 +68,84 @@ def _(x: Literal["a", "b", "c", 1]): reveal_type(x) # revealed: Literal[1] ``` +## `in` for inline list and set literals + +Inline list and set literals are consumed immediately, so membership can use their precise element +types without promoting them as mutable containers. + +```py +from typing import Literal +from typing_extensions import assert_never + +Choice = Literal["a", "b", "c"] + +def inline_list(value: Choice): + if value in ["a", "b"]: + reveal_type(value) # revealed: Literal["a", "b"] + else: + reveal_type(value) # revealed: Literal["c"] + + if value not in ["a", "b"]: + reveal_type(value) # revealed: Literal["c"] + else: + reveal_type(value) # revealed: Literal["a", "b"] + +def inline_set(value: Choice): + if value in {"a", "b"}: + reveal_type(value) # revealed: Literal["a", "b"] + else: + reveal_type(value) # revealed: Literal["c"] + + if value not in {"a", "b"}: + reveal_type(value) # revealed: Literal["c"] + else: + reveal_type(value) # revealed: Literal["a", "b"] + +def literal_locals(value: Choice): + a = "a" + b = "b" + if value in [a, b]: + reveal_type(value) # revealed: Literal["a", "b"] + else: + reveal_type(value) # revealed: Literal["c"] + + if value in {a, b}: + reveal_type(value) # revealed: Literal["a", "b"] + else: + reveal_type(value) # revealed: Literal["c"] + +def mixed_literals(value: Literal["a", "b", 1, 2, b"x"] | None): + if value in ["a", 1, b"x", None]: + reveal_type(value) # revealed: Literal["a", 1, b"x"] | None + else: + reveal_type(value) # revealed: Literal["b", 2] + + if value in {"a", 1, b"x", None}: + reveal_type(value) # revealed: Literal["a", 1, b"x"] | None + else: + reveal_type(value) # revealed: Literal["b", 2] + +def union_valued_slots(value: Choice, a: Literal["a", "b"], b: Literal["a", "b"]): + if value not in [a, b]: + reveal_type(value) # revealed: Literal["a", "b", "c"] + if value not in {a, b}: + reveal_type(value) # revealed: Literal["a", "b", "c"] + +def exhaustive_list(value: Choice): + if value in ["a", "b"]: + return + if value == "c": + return + assert_never(value) + +def exhaustive_set(value: Choice): + if value in {"a", "b"}: + return + if value == "c": + return + assert_never(value) +``` + ## `in` for PEP 695 aliases ```toml @@ -144,6 +222,18 @@ type Foo = Literal["a", "b", "c"] | int def _(x: Foo): if x in ("a", "b"): reveal_type(x) # revealed: Literal["a", "b"] | int + +def inline_list(x: str): + if x in ["a", "b"]: + reveal_type(x) # revealed: str + else: + reveal_type(x) # revealed: str & ~Literal["a"] & ~Literal["b"] + +def inline_set(x: str): + if x in {"a", "b"}: + reveal_type(x) # revealed: str + else: + reveal_type(x) # revealed: str & ~Literal["a"] & ~Literal["b"] ``` ## `in` for `str` and literal strings diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 3e468f9590..0bd6a49704 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -3077,6 +3077,33 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { constrained.then(|| builder.build()) } + /// Preserve the precise element types of an immediately consumed list or set literal. + /// + /// These expressions cannot be mutated before membership is evaluated. Representing them as + /// fixed-length tuples also lets negative narrowing exclude values that are guaranteed present. + fn inline_membership_rhs_type( + &self, + rhs: &ast::Expr, + inference: &ExpressionInference<'db>, + ) -> Option> { + let elements = match rhs.expression_value() { + ast::Expr::List(list) => &list.elts, + ast::Expr::Set(set) => &set.elts, + _ => return None, + }; + + if elements.iter().any(ast::Expr::is_starred_expr) { + return None; + } + + Some(Type::heterogeneous_tuple( + self.db, + elements + .iter() + .map(|element| inference.expression_type(element)), + )) + } + fn evaluate_expr_compare_op( &mut self, lhs_ty: Type<'db>, @@ -3498,6 +3525,12 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { for (op, (left, right)) in std::iter::zip(&**ops, comparator_tuples) { let lhs_ty = last_rhs_ty.unwrap_or_else(|| inference.expression_type(left)); let rhs_ty = inference.expression_type(right); + let lhs_narrowing_rhs_ty = if matches!(op, ast::CmpOp::In | ast::CmpOp::NotIn) { + self.inline_membership_rhs_type(right, inference) + .unwrap_or(rhs_ty) + } else { + rhs_ty + }; // Narrowing for: // - `if type(x) is Y` @@ -3560,7 +3593,8 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { // - `if x not in y` if narrowable_ast(left) && let Some(narrowable) = PlaceExpr::try_from_expr(left) - && let Some(ty) = self.evaluate_expr_compare_op(lhs_ty, rhs_ty, *op, is_positive) + && let Some(ty) = + self.evaluate_expr_compare_op(lhs_ty, lhs_narrowing_rhs_ty, *op, is_positive) { let place = self.expect_place(&narrowable); let constraint = NarrowingConstraint::intersection(ty); From 6cd61338adf71d1bf388435572a9677a4dca1a1b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 17 Jul 2026 19:25:13 -0400 Subject: [PATCH 138/222] [ty] Respect mixin receiver domains in method overrides (#26941) ## Summary Prior to this change, we compared method overrides after binding each method to its declaring class. For a mixin whose method explicitly restricts `self`, that turns the inherited receiver constraint into a concrete failed relation and can reject an identical override: ```python from typing import Protocol class HasValue(Protocol): value: int class Mixin: def method(self: HasValue) -> None: ... class Sub(Mixin): def method(self: HasValue) -> None: ... # Previously: invalid-method-override ``` This compares an explicitly restricted base method on its actual callable domain, intersecting the subclass receiver with the base receiver annotation before binding both signatures. Closes https://github.com/astral-sh/ty/issues/4018. --------- Co-authored-by: Carl Meyer --- .../resources/mdtest/liskov.md | 204 ++++++++++++++++++ crates/ty_python_semantic/src/types/method.rs | 31 ++- .../ty_python_semantic/src/types/overrides.rs | 93 +++++++- 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md index f282e19aa8..599982283d 100644 --- a/crates/ty_python_semantic/resources/mdtest/liskov.md +++ b/crates/ty_python_semantic/resources/mdtest/liskov.md @@ -959,6 +959,136 @@ class B4(A4): def method(self, x: int) -> int: ... ``` +## Protocol annotations on mixin receivers + +A mixin can annotate `self` with a protocol that the mixin itself does not implement. An override +can keep that annotation or omit it: + +```pyi +from typing import Protocol, overload + +class HasValue(Protocol): + value: int + +class Mixin: + def method(self: HasValue, argument: int) -> None: ... + +class SameReceiver(Mixin): + def method(self: HasValue, argument: int) -> None: ... + +class ImplicitReceiver(Mixin): + def method(self, argument: int) -> None: ... +``` + +A subclass that provides the required protocol member can call the annotated method normally: + +```pyi +class ImplementsProtocol(Mixin): + value: int + def method(self: HasValue, argument: int) -> None: ... + +receiver: HasValue = ImplementsProtocol() +ImplementsProtocol().method(1) +``` + +An override cannot require an unrelated protocol or change the type of an argument accepted by the +mixin: + +```pyi +class HasOtherValue(Protocol): + other_value: str + +class InvalidReceiver(Mixin): + def method(self: HasOtherValue, argument: int) -> None: ... # error: [invalid-method-override] + +class InvalidArgument(Mixin): + value: int + def method(self: HasValue, argument: str) -> None: ... # snapshot: invalid-method-override +``` + +```snapshot +error[invalid-method-override]: Invalid override of method `method` + --> src/mdtest_snippet.pyi:28:9 + | +28 | def method(self: HasValue, argument: str) -> None: ... # snapshot: invalid-method-override + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Mixin.method` + | + ::: src/mdtest_snippet.pyi:7:9 + | + 7 | def method(self: HasValue, argument: int) -> None: ... + | --------------------------------------------- `Mixin.method` defined here + | +info: parameter `argument` has an incompatible type: `int` is not assignable to `str` +info: This violates the Liskov Substitution Principle +``` + +The receiver annotation can also accept more than one protocol: + +```pyi +class UnionMixin: + def method(self: HasValue | HasOtherValue, argument: int) -> None: ... + +class SameUnionReceiver(UnionMixin): + def method(self: HasValue | HasOtherValue, argument: int) -> None: ... +``` + +Overloaded mixin methods need the same treatment for every receiver-specific overload: + +```pyi +# TODO: We should emit an `invalid-method-override` diagnostic on the second +# `InvalidOverloadedReceiver.method` overload. Both overload sets need to be +# compared within the `HasValue` receiver domain instead of being filtered +# against the concrete mixin subclass. +class OverloadedMixin: + @overload + def method(self: HasValue, argument: int) -> None: ... + @overload + def method(self: HasValue, argument: str) -> None: ... + +class InvalidOverloadedReceiver(OverloadedMixin): + @overload + def method(self: HasValue, argument: int) -> None: ... + @overload + def method(self: HasValue, argument: bytes) -> None: ... +``` + +The protocol may include the overridden method itself. This must not cause a valid implementation to +be rejected while checking the override: + +```pyi +class Container(Protocol): + def __contains__(self, key: str) -> bool: ... + def method(self) -> None: ... + +class ContainerMixin: + def method(self: Container) -> None: ... + +class ImplementsContainer(ContainerMixin): + def __contains__(self, key: str) -> bool: ... + def method(self: Container) -> None: ... + +container: Container = ImplementsContainer() +ImplementsContainer().method() +``` + +## Generic instance-method receivers + +An instance method can relate its receiver and return type using a bounded type variable. An +override can express the same relationship with `Self`: + +```pyi +from typing import TypeVar +from typing_extensions import Self + +InstanceT = TypeVar("InstanceT", bound="InstanceBase") + +class InstanceBase: + def clone(self: InstanceT) -> InstanceT: ... + +class InstanceChild(InstanceBase): + def clone(self: Self) -> Self: ... +``` + ## Generic methods on generic classes work as expected ```toml @@ -1438,6 +1568,80 @@ info: `BadChild3B.static_method` is a classmethod but `Parent.static_method` is info: This violates the Liskov Substitution Principle ``` +## Explicitly annotated classmethod receivers + +An explicitly annotated `cls` can specialize the class receiver and return type. A subclass can also +replace a bounded type variable on the class receiver with `Self` while preserving compatible +arguments: + +```pyi +from typing import Any, TypeVar +from typing_extensions import Self + +class Event: + @classmethod + def deserialize(cls: type[Event], data: dict[str, object]) -> Event: ... + +class SwapEvent(Event): + @classmethod + def deserialize(cls: type[SwapEvent], data: dict[str, object]) -> SwapEvent: ... + +class Context: + @classmethod + def get(cls: type[Self]) -> Self | None: ... + +class SettingsContext(Context): + @classmethod + def get(cls) -> SettingsContext | None: ... + +ItemT = TypeVar("ItemT", bound="Item") + +class Item: + @classmethod + def from_component(cls: type[ItemT], component: object) -> ItemT: ... + +class DynamicItem(Item): + @classmethod + def from_component(cls: type[Self], component: object) -> Self: ... +``` + +The override can also specialize a gradual parameter or accept additional keyword arguments: + +```pyi +ModelT = TypeVar("ModelT", bound="BaseModel") + +class BaseModel: + @classmethod + def validate(cls: type[ModelT], value: Any) -> Any: ... + @classmethod + def strategy(cls: type[ModelT], *, size: int | None = None) -> Any: ... + @classmethod + def example(cls: type[ModelT], *, size: int | None = None) -> Any: ... + +class FrameModel(BaseModel): + @classmethod + def validate(cls: type[Self], value: int) -> Self: ... + @classmethod + def strategy(cls: type[Self], **kwargs: Any) -> Any: ... + @classmethod + def example(cls: type[Self], **kwargs: Any) -> Self: ... +``` + +Narrowing a non-gradual argument accepted by the superclass remains an invalid override for both +generic and concrete class receivers: + +```pyi +class InvalidDynamicItem(Item): + @classmethod + # error: [invalid-method-override] + def from_component(cls: type[Self], component: int) -> Self: ... + +class InvalidSwapEvent(Event): + @classmethod + # error: [invalid-method-override] + def deserialize(cls: type[InvalidSwapEvent], data: dict[str, int]) -> InvalidSwapEvent: ... +``` + ## Overloaded methods with positional-only parameters with defaults When a base class has an overloaded method where one overload accepts only keyword arguments diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index 13ac62a4db..ed12320b68 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -85,12 +85,41 @@ impl<'db> BoundMethodType<'db> { ) } + /// Converts this bound method into a callable using separate runtime-receiver and `Self` types. + pub(crate) fn into_callable_type_with_receiver( + self, + db: &'db dyn Db, + receiver_type: Type<'db>, + typing_self_type: Type<'db>, + ) -> CallableType<'db> { + let function = self.function(db); + + CallableType::new( + db, + self.bound_signatures_with_receiver(db, receiver_type, typing_self_type), + CallableTypeKind::FunctionLike, + CallableFunctionProvenance::from_function_return_annotation( + function.has_explicit_return_annotation(db), + ), + ) + } + #[salsa::tracked(returns(ref), cycle_initial=|_, _, _| CallableSignature::bottom(), heap_size=ruff_memory_usage::heap_size)] pub(crate) fn bound_signatures(self, db: &'db dyn Db) -> CallableSignature<'db> { - let function_signature = self.function(db).signature(db); let typing_self_type = self.typing_self_type(db); let receiver_type = self.self_instance(db); + self.bound_signatures_with_receiver(db, receiver_type, typing_self_type) + } + + fn bound_signatures_with_receiver( + self, + db: &'db dyn Db, + receiver_type: Type<'db>, + typing_self_type: Type<'db>, + ) -> CallableSignature<'db> { + let function_signature = self.function(db).signature(db); + let [signature] = function_signature.overloads.as_slice() else { if !function_signature .overloads diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 6eee77d877..a2ae48de89 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -19,8 +19,8 @@ use crate::{ place::{DefinedPlace, Place, PlaceAndQualifiers, TypeOrigin}, reachability::ReachabilityConstraintsExtension, types::{ - CallableType, ClassBase, ClassLiteral, ClassType, KnownClass, Parameter, Parameters, - Signature, StaticClassLiteral, Type, TypeContext, TypeQualifiers, + CallableType, ClassBase, ClassLiteral, ClassType, IntersectionType, KnownClass, Parameter, + Parameters, Signature, StaticClassLiteral, Type, TypeContext, TypeQualifiers, call::CallArguments, class::{CodeGeneratorKind, FieldKind}, constraints::ConstraintSetBuilder, @@ -559,14 +559,13 @@ fn check_class_declaration<'db>( continue; } - let Some(superclass_type_as_callable) = superclass_type.try_upcast_to_callable(db) + let Some((subclass_override_type, superclass_override_type)) = + method_override_types(db, type_on_subclass_instance, superclass_type) else { continue; }; - let superclass_type_as_type = superclass_type_as_callable.into_type(db); - - if type_on_subclass_instance.is_assignable_to(db, superclass_type_as_type) { + if subclass_override_type.is_assignable_to(db, superclass_override_type) { continue; } @@ -580,7 +579,7 @@ fn check_class_declaration<'db>( // The immediate parent already defines this method and is different from the // current ancestor we're checking. Check if the immediate parent's method // is also incompatible with this ancestor. - if !immediate_parent_type.is_assignable_to(db, superclass_type_as_type) { + if !is_assignable_method_override(db, immediate_parent_type, superclass_type) { // The immediate parent already has an LSP violation with this ancestor. // Don't report the same violation for the child. continue; @@ -597,10 +596,7 @@ fn check_class_declaration<'db>( superclass, superclass_type, method_kind, - || { - type_on_subclass_instance - .assignability_error_context(db, superclass_type_as_type) - }, + || subclass_override_type.assignability_error_context(db, superclass_override_type), ); liskov_diagnostic_emitted = true; @@ -668,6 +664,81 @@ fn check_class_declaration<'db>( } } +/// Checks whether a method override preserves its superclass method's callable domain. +/// +/// An explicitly annotated superclass receiver can restrict a method to a subset of subclass +/// receivers. Bind both methods to that common receiver domain before comparing their signatures. +/// +/// ```python +/// from typing import Protocol +/// +/// class HasValue(Protocol): +/// value: int +/// +/// class Mixin: +/// def method(self: HasValue) -> None: ... +/// +/// class Sub(Mixin): +/// def method(self: HasValue) -> None: ... +/// ``` +fn is_assignable_method_override<'db>( + db: &'db dyn Db, + subclass_type: Type<'db>, + superclass_type: Type<'db>, +) -> bool { + method_override_types(db, subclass_type, superclass_type).is_some_and( + |(subclass_type, superclass_type)| subclass_type.is_assignable_to(db, superclass_type), + ) +} + +fn method_override_types<'db>( + db: &'db dyn Db, + subclass_type: Type<'db>, + superclass_type: Type<'db>, +) -> Option<(Type<'db>, Type<'db>)> { + let (subclass_type, superclass_type) = match (subclass_type, superclass_type) { + (Type::BoundMethod(subclass_method), Type::BoundMethod(superclass_method)) => { + let superclass_signature = superclass_method.function(db).signature(db); + let receiver = match superclass_signature.overloads.as_slice() { + [signature] => signature + .parameters() + .get(0) + .filter(|parameter| parameter.is_positional() && !parameter.inferred_annotation) + .map(Parameter::annotated_type), + // TODO: Compare overloaded mixin methods within each overload's explicit receiver + // domain. Binding them directly to the concrete subclass can filter out applicable + // overloads when the subclass does not itself satisfy the receiver protocol. + _ => None, + }; + + receiver.map_or((subclass_type, superclass_type), |receiver| { + let typing_self_type = subclass_method.typing_self_type(db); + let receiver = receiver.bind_self_typevars(db, typing_self_type); + let receiver = IntersectionType::from_elements( + db, + [subclass_method.self_instance(db), receiver], + ); + ( + Type::Callable(subclass_method.into_callable_type_with_receiver( + db, + receiver, + typing_self_type, + )), + Type::Callable(superclass_method.into_callable_type_with_receiver( + db, + receiver, + typing_self_type, + )), + ) + }) + } + _ => (subclass_type, superclass_type), + }; + let superclass_callable = superclass_type.try_upcast_to_callable(db)?; + + Some((subclass_type, superclass_callable.into_type(db))) +} + /// Whether an attribute declaration is a class variable or an instance variable. #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, get_size2::GetSize)] enum VariableKind { From ace21713756518b0c4a188329c3fff4b6fdc1f1b Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 17 Jul 2026 16:52:22 -0700 Subject: [PATCH 139/222] [ty] Fix isinstance tuple coverage for aliases and type variables (#26961) Follow-up to #26935. Fixed `isinstance` tuple coverage only expanded a direct union. PEP 695 union aliases, constrained `TypeVar`s, and union-bounded `TypeVar`s therefore continued to infer `bool` and could incorrectly report an implicit `None` return even when every possible value was covered. This recursively resolves aliases and expands union members plus TypeVar bounds/constraints before checking fixed tuple coverage. The added mdtest covers nested PEP 695 aliases, constrained TypeVars, union bounds, and alias bounds. --- .../resources/mdtest/call/builtins.md | 67 +++++++++++++++++++ .../ty_python_semantic/src/types/function.rs | 47 ++++++++++--- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 67c9b13df6..40a08e75e0 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -311,6 +311,73 @@ isinstance("", t.Any) # error: [invalid-argument-type] isinstance("", (int, t.Any)) # error: [invalid-argument-type] ``` +## Calls to `isinstance` with tuple-covered aliases and type variables + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import TypeVar +from typing_extensions import TypeAliasType, Union + +class A: ... +class B: ... + +type AliasA = A +type AliasB = B +type AliasAB = AliasA | AliasB + +T_constrained_a_b = TypeVar("T_constrained_a_b", A, B) +T_bound_a_b = TypeVar("T_bound_a_b", bound=A | B) +T_bound_alias_a_b = TypeVar("T_bound_alias_a_b", bound=AliasAB) + +def accepts_alias(x: AliasAB) -> bool: + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +def accepts_constrained_typevar(x: T_constrained_a_b) -> bool: + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +def accepts_union_bound_typevar(x: T_bound_a_b) -> bool: + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +def accepts_alias_bound_typevar(x: T_bound_alias_a_b) -> bool: + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +def accepts_truthy_constrained_typevar(x: T_constrained_a_b) -> bool: + if not x: + return False + + reveal_type(x) # revealed: T_constrained_a_b@accepts_truthy_constrained_typevar & ~AlwaysFalsy + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +RecursiveA = TypeAliasType("RecursiveA", Union[A, "RecursiveB"]) +RecursiveB = TypeAliasType("RecursiveB", Union[B, "RecursiveA"]) +RecursivePartialA = TypeAliasType("RecursivePartialA", Union[A, "RecursivePartialB"]) +RecursivePartialB = TypeAliasType("RecursivePartialB", Union[bytes, "RecursivePartialA"]) + +def accepts_mutually_recursive_alias(x: RecursiveA) -> bool: + reveal_type(isinstance(x, (A, B))) # revealed: Literal[True] + if isinstance(x, (A, B)): + return True + +def partial_mutually_recursive_alias(x: RecursivePartialA) -> bool: # error: [invalid-return-type] + reveal_type(isinstance(x, (A, B))) # revealed: bool + if isinstance(x, (A, B)): + return True +``` + ## Generic builtins should not overfit upper-bound-only callback constraints These examples are minimized from ecosystem regressions seen while preserving explicit `Never` and diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index bc48abeece..9d46f81496 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -85,6 +85,7 @@ use crate::types::list_members::all_members; use crate::types::narrow::ClassInfoConstraintFunction; use crate::types::relation::TypeRelationChecker; use crate::types::signatures::{CallableSignature, ReturnCallableTypeVarScope, Signature}; +use crate::types::tuple::TupleSpec; use crate::types::variance::{TypeVarVariance, VarianceInferable}; use crate::types::visitor::non_any_dynamic_content; use crate::types::{ @@ -1890,7 +1891,7 @@ fn is_instance_truthiness<'db>( } } -/// Return whether a fixed `isinstance` tuple covers every member of an input union. +/// Return whether a fixed `isinstance` tuple covers every possible type of an input. /// /// Each class in the tuple uses the same truthiness inference as a single-class `isinstance` check. /// @@ -1910,18 +1911,46 @@ fn is_instance_tuple_exhaustive<'db>(db: &'db dyn Db, ty: Type<'db>, classinfo: return false; } - let is_covered = |ty: Type<'db>| { - tuple.fixed_elements().any(|element| { + is_instance_tuple_covers(db, &tuple, ty, &ActiveRecursionDetector::default()) +} + +fn is_instance_tuple_covers<'db>( + db: &'db dyn Db, + tuple: &TupleSpec<'db>, + ty: Type<'db>, + recursion_guard: &ActiveRecursionDetector>, +) -> bool { + match ty { + Type::TypeAlias(alias) => recursion_guard.visit( + &ty, + || true, + || is_instance_tuple_covers(db, tuple, alias.value_type(db), recursion_guard), + ), + Type::Union(union) => union + .elements(db) + .iter() + .all(|element| is_instance_tuple_covers(db, tuple, *element, recursion_guard)), + Type::Intersection(intersection) => intersection + .positive(db) + .iter() + .any(|element| is_instance_tuple_covers(db, tuple, *element, recursion_guard)), + Type::TypeVar(typevar) => match typevar.typevar(db).bound_or_constraints(db) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + is_instance_tuple_covers(db, tuple, bound, recursion_guard) + } + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + constraints.elements(db).iter().all(|constraint| { + is_instance_tuple_covers(db, tuple, *constraint, recursion_guard) + }) + } + None => is_instance_tuple_covers(db, tuple, Type::object(), recursion_guard), + }, + ty => tuple.fixed_elements().any(|element| { let Type::ClassLiteral(class) = element else { return false; }; is_instance_truthiness(db, ty, *class).is_always_true() - }) - }; - - match ty { - Type::Union(union) => union.elements(db).iter().copied().all(is_covered), - ty => is_covered(ty), + }), } } From 1b50a85e3b139e44f2b6c9e0d9c99c2190a3b6c9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 17 Jul 2026 21:13:21 -0400 Subject: [PATCH 140/222] [ty] Avoid recursive protocol structural comparisons (#26721) ## Summary When lazy type-variable inference relates two specializations of the same class-based protocol, we first check their nominal inheritance relation. If that relation is viable but conditional, we currently fall back to a full structural comparison of the protocol interface. For recursive protocols with overloaded members, that fallback can repeatedly expand the same inherited interface under new specializations. We now keep the viable nominal constraint for same-origin protocol specializations during lazy inference, avoiding the redundant structural expansion. When a structural comparison is still required, we compare attributes and simple single-signature methods before overloaded or protocol-returning methods. An incompatible non-recursive member can therefore reject the relation before a recursive method expands the interface again. This replaces the previous declaration-level occurs-check approach with a narrower structural ordering and nominal short-circuit; the original commits remain in the branch history and are explicitly reverted. Related: --- ..._recursive_protocol_structural_relation.md | 291 ++++++++++++++++++ .../ty_python_semantic/src/types/instance.rs | 170 ++++++++-- .../src/types/protocol_class.rs | 154 +++++++-- .../src/types/signatures.rs | 2 +- 4 files changed, 567 insertions(+), 50 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/regression/3954_recursive_protocol_structural_relation.md diff --git a/crates/ty_python_semantic/resources/mdtest/regression/3954_recursive_protocol_structural_relation.md b/crates/ty_python_semantic/resources/mdtest/regression/3954_recursive_protocol_structural_relation.md new file mode 100644 index 0000000000..ac97cbf5c6 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/regression/3954_recursive_protocol_structural_relation.md @@ -0,0 +1,291 @@ +# Recursive protocol structural relations + +When relating recursive protocol specializations, we should avoid expanding the structural interface +if the nominal relation is already viable. When structural comparison is required, an incompatible +non-recursive member should be checked before members that grow the specialization. + +This is a regression test for . + +```toml +[environment] +python-version = "3.12" +``` + +## Viable nominal relation + +`lazy.py`: + +```py +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable +from typing import Protocol + +class Streamable[T](Protocol): + def __aiter__(self) -> AsyncIterator[T]: ... + def enumerate(self) -> Streamable[tuple[int, T]]: ... + def flatten[U](self: Streamable[Streamable[U] | Iterable[U]]) -> Streamable[U]: ... + +def consume[T](items: Streamable[T]) -> None: ... +def check(items: Streamable[int]) -> None: + consume(items) +``` + +## Incompatible structural member + +`structural.py`: + +```py +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol +from ty_extensions import static_assert +from ty_extensions._internal import is_assignable_to, is_subtype_of + +class Source[T](Protocol): + def enumerate(self) -> Source[tuple[int, T]]: ... + def flatten[U](self: Source[Source[U] | Iterable[U]]) -> Source[U]: ... + def value(self) -> str: ... + +class Target[T](Protocol): + def enumerate(self) -> Target[tuple[int, T]]: ... + def flatten[U](self: Target[Target[U] | Iterable[U]]) -> Target[U]: ... + def value(self) -> int: ... + +static_assert(not is_subtype_of(Source[int], Target[int])) +static_assert(not is_assignable_to(Source[int], Target[int])) +``` + +## Recursive property and attribute + +An incompatible overloaded member must still be checked before an alphabetically earlier recursive +property or attribute. + +```py +from __future__ import annotations + +from typing import Protocol, overload + +class PropertySource[T](Protocol): + @property + def a_child(self) -> PropertySource[list[T]]: ... + @overload + def z_member(self, value: int) -> int: ... + @overload + def z_member(self, value: str) -> str: ... + def z_member(self, value: int | str) -> int | str: ... + +class PropertyTarget[T](Protocol): + @property + def a_child(self) -> PropertyTarget[list[T]]: ... + @overload + def z_member(self, value: int) -> str: ... + @overload + def z_member(self, value: str) -> int: ... + def z_member(self, value: int | str) -> int | str: ... + +class AttributeSource[T](Protocol): + a_child: AttributeSource[list[T]] + @overload + def z_member(self, value: int) -> int: ... + @overload + def z_member(self, value: str) -> str: ... + def z_member(self, value: int | str) -> int | str: ... + +class AttributeTarget[T](Protocol): + a_child: AttributeTarget[list[T]] + @overload + def z_member(self, value: int) -> str: ... + @overload + def z_member(self, value: str) -> int: ... + def z_member(self, value: int | str) -> int | str: ... + +def property_convert(value: PropertySource[int]) -> PropertyTarget[int]: + return value # error: [invalid-return-type] + +def attribute_convert(value: AttributeSource[int]) -> AttributeTarget[int]: + return value # error: [invalid-return-type] +``` + +## Structural alternatives after nominal constraints + +A conditional nominal relation must not discard a narrower structural solution. This applies both to +non-recursive protocols and to finite members of recursive protocols. + +```py +from __future__ import annotations + +from typing import Protocol + +class P[T](Protocol): + def get(self) -> T | int: ... + +class Recursive[T](Protocol): + def get(self) -> T | int: ... + def child(self) -> Recursive[T]: ... + +def consume[T](value: P[T], exact: list[T]) -> T: + raise NotImplementedError + +def consume_recursive[T](value: Recursive[T], exact: list[T]) -> T: + raise NotImplementedError + +def check(value: P[str | int], recursive: Recursive[str | int], exact: list[str]) -> None: + result: str = consume(value, exact) + recursive_result: str = consume_recursive(recursive, exact) +``` + +## Recursive parameters and aliases + +A single-signature method with a recursive parameter, or a method or property containing an alias to +a recursive protocol, must not be prioritized ahead of an incompatible overloaded member. + +```py +from __future__ import annotations + +from typing import Protocol, overload + +class NestedSource[T](Protocol): + @overload + def combine[U1](self, v1: U1, /) -> NestedSource[T | U1]: ... + @overload + def combine[U1, U2](self, v1: U1, v2: U2, /) -> NestedSource[T | U1 | U2]: ... + @overload + def combine[U1, U2, U3](self, v1: U1, v2: U2, v3: U3, /) -> NestedSource[T | U1 | U2 | U3]: ... + @overload + def combine[U1, U2, U3, U4](self, v1: U1, v2: U2, v3: U3, v4: U4, /) -> NestedSource[T | U1 | U2 | U3 | U4]: ... + @overload + def combine[U1, U2, U3, U4, U5]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, / + ) -> NestedSource[T | U1 | U2 | U3 | U4 | U5]: ... + @overload + def combine[U1, U2, U3, U4, U5, U6]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, v6: U6, / + ) -> NestedSource[T | U1 | U2 | U3 | U4 | U5 | U6]: ... + @overload + def combine[U1, U2, U3, U4, U5, U6, U7]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, v6: U6, v7: U7, / + ) -> NestedSource[T | U1 | U2 | U3 | U4 | U5 | U6 | U7]: ... + def combine(self, *values: object) -> NestedSource[object]: ... + +class NestedTarget[T](Protocol): + @overload + def combine[U1](self, v1: U1, /) -> NestedTarget[T | U1]: ... + @overload + def combine[U1, U2](self, v1: U1, v2: U2, /) -> NestedTarget[T | U1 | U2]: ... + @overload + def combine[U1, U2, U3](self, v1: U1, v2: U2, v3: U3, /) -> NestedTarget[T | U1 | U2 | U3]: ... + @overload + def combine[U1, U2, U3, U4](self, v1: U1, v2: U2, v3: U3, v4: U4, /) -> NestedTarget[T | U1 | U2 | U3 | U4]: ... + @overload + def combine[U1, U2, U3, U4, U5]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, / + ) -> NestedTarget[T | U1 | U2 | U3 | U4 | U5]: ... + @overload + def combine[U1, U2, U3, U4, U5, U6]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, v6: U6, / + ) -> NestedTarget[T | U1 | U2 | U3 | U4 | U5 | U6]: ... + @overload + def combine[U1, U2, U3, U4, U5, U6, U7]( + self, v1: U1, v2: U2, v3: U3, v4: U4, v5: U5, v6: U6, v7: U7, / + ) -> NestedTarget[T | U1 | U2 | U3 | U4 | U5 | U6 | U7]: ... + def combine(self, *values: object) -> NestedTarget[object]: ... + +type SourceAlias[T] = NestedSource[list[T]] +type TargetAlias[T] = NestedTarget[list[T]] + +class ParameterSource[T](Protocol): + @overload + def a_member(self, value: int) -> int: ... + @overload + def a_member(self, value: str) -> str: ... + def a_member(self, value: int | str) -> int | str: ... + def z_child(self, value: NestedSource[list[T]]) -> None: ... + +class ParameterTarget[T](Protocol): + @overload + def a_member(self, value: int) -> str: ... + @overload + def a_member(self, value: str) -> int: ... + def a_member(self, value: int | str) -> int | str: ... + def z_child(self, value: NestedTarget[list[T]]) -> None: ... + +class AliasMethodSource[T](Protocol): + @overload + def a_member(self, value: int) -> int: ... + @overload + def a_member(self, value: str) -> str: ... + def a_member(self, value: int | str) -> int | str: ... + def z_child(self) -> SourceAlias[T]: ... + +class AliasMethodTarget[T](Protocol): + @overload + def a_member(self, value: int) -> str: ... + @overload + def a_member(self, value: str) -> int: ... + def a_member(self, value: int | str) -> int | str: ... + def z_child(self) -> TargetAlias[T]: ... + +class AliasPropertySource[T](Protocol): + @overload + def a_member(self, value: int) -> int: ... + @overload + def a_member(self, value: str) -> str: ... + def a_member(self, value: int | str) -> int | str: ... + @property + def z_child(self) -> SourceAlias[T]: ... + +class AliasPropertyTarget[T](Protocol): + @overload + def a_member(self, value: int) -> str: ... + @overload + def a_member(self, value: str) -> int: ... + def a_member(self, value: int | str) -> int | str: ... + @property + def z_child(self) -> TargetAlias[T]: ... + +def parameter_convert(value: ParameterSource[int]) -> ParameterTarget[int]: + return value # error: [invalid-return-type] + +def alias_method_convert(value: AliasMethodSource[int]) -> AliasMethodTarget[int]: + return value # error: [invalid-return-type] + +def alias_property_convert(value: AliasPropertySource[int]) -> AliasPropertyTarget[int]: + return value # error: [invalid-return-type] +``` + +## Recursive self-binding + +The nominal shortcut must not bind an unconstrained TypeVar to another specialization of the same +protocol or one supplied by a sibling union arm. + +```py +from collections.abc import Iterable +from typing import Any, Protocol, TypeAlias, TypeVar, reveal_type + +T_co = TypeVar("T_co", covariant=True) + +class Recursive(Protocol[T_co]): + def method(self): ... + +def convert(value: Any | Recursive[T_co]) -> list[T_co]: + result = [value] + reveal_type(result) # revealed: list[T_co@convert | Any | Recursive[T_co@convert]] + return result # error: [invalid-return-type] + +U = TypeVar("U", covariant=True) + +class F(Protocol[U]): + def frame(self): ... + +class M(Protocol): + def marker(self): ... + +Options: TypeAlias = Iterable[U] | F[U] | M + +def s(value: Options[U]) -> list[U]: + result = [value] + reveal_type(result) # revealed: list[U@s | Iterable[M] | Iterable[Iterable[M]] | Iterable[U@s] | F[U@s] | M] + return result # error: [invalid-return-type] +``` diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index ab9068d1ca..957f17d846 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -1,6 +1,7 @@ //! Instance types: both nominal and structural. use std::borrow::Cow; +use std::cell::Cell; use std::marker::PhantomData; use ruff_python_ast::name::Name; @@ -9,7 +10,7 @@ use ty_module_resolver::{ModuleName, file_to_module}; use super::protocol_class::ProtocolInterface; use super::{ BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, KnownClass, - MaterializationKind, SubclassOfType, Type, TypeVarVariance, + MaterializationKind, SubclassOfType, Type, TypeAliasType, TypeVarVariance, }; use crate::place::PlaceAndQualifiers; use crate::types::constraints::{ @@ -18,13 +19,16 @@ use crate::types::constraints::{ use crate::types::enums::is_single_member_enum; use crate::types::generics::{InferableTypeVars, walk_specialization}; use crate::types::protocol_class::{ - ProtocolClass, has_all_protocol_members_defined, walk_protocol_interface, + ProtocolClass, has_all_protocol_members_defined, walk_protocol_instance_member, + walk_protocol_interface, }; use crate::types::relation::{ - DisjointnessChecker, HasRelationToVisitor, IsDisjointVisitor, TypeRelation, TypeRelationChecker, + DisjointnessChecker, HasRelationToVisitor, IsDisjointVisitor, TypeRelation, + TypeRelationChecker, TypeVarEvaluation, }; use crate::types::signatures::SignatureRelationVisitor; use crate::types::tuple::{TupleSpec, TupleType, walk_tuple_type}; +use crate::types::visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard}; use crate::types::{ ApplyTypeMappingVisitor, CallableType, ClassBase, ClassLiteral, ErrorContext, FindLegacyTypeVarsVisitor, LiteralValueTypeKind, TypeContext, TypeMapping, VarianceInferable, @@ -508,13 +512,14 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { let mut result = self.never(); if let Some(nominal_instance) = protocol.to_nominal_instance() { + let source_protocol_as_nominal = ty + .as_protocol_instance() + .and_then(ProtocolInstanceType::to_nominal_instance); // if `ty` and `protocol` are *both* protocols, we also need to treat `ty` as if it // were a nominal type, or we won't consider a protocol `P` that explicitly inherits // from a protocol `Q` to be a subtype of `Q` to be a subtype of `Q` if it overrides // `Q`'s members in a Liskov-incompatible way. - let type_to_test = ty - .as_protocol_instance() - .and_then(ProtocolInstanceType::to_nominal_instance) + let type_to_test = source_protocol_as_nominal .map(Type::NominalInstance) .unwrap_or(ty); @@ -528,6 +533,31 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { return result; } + // `Generator` special case: compare the type parameters nominally. Prior to 3.13, + // its return type does not appear non-recursively in the protocol; from 3.13 onward, + // structurally inferring through `close() -> ReturnT | None` can spuriously infer + // `None`. + // TODO: Remove the Python 3.13+ extension of this special case once + // https://github.com/astral-sh/ty/issues/3596 is fixed. + if let Some(source_protocol) = ty.as_protocol_instance() + && let Protocol::FromClass(source_class) = source_protocol.inner + && let Protocol::FromClass(proto_class) = protocol.inner + && source_class.is_known(db, KnownClass::Generator) + && proto_class.is_known(db, KnownClass::Generator) + { + return result; + } + + if let Some(structurally_satisfied) = self.try_check_non_recursive_protocol_members( + db, + ty, + protocol, + source_protocol_as_nominal, + nominal_instance, + ) { + return result.or(db, self.constraints, || structurally_satisfied); + } + // For union simplification, failing the nominal relation between two // specializations of the same protocol class is enough to keep both union elements. // Falling back to the structural relation can recursively compare every protocol @@ -546,20 +576,6 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } - // `Generator` special case: compare the type parameters nominally. Prior to 3.13, its - // return type does not appear non-recursively in the protocol; from 3.13 onward, - // structurally inferring through `close() -> ReturnT | None` can spuriously infer `None`. - // TODO: Remove the Python 3.13+ extension of this special case once - // https://github.com/astral-sh/ty/issues/3596 is fixed. - if let Some(source_protocol) = ty.as_protocol_instance() - && let Protocol::FromClass(source_class) = source_protocol.inner - && let Protocol::FromClass(proto_class) = protocol.inner - && source_class.is_known(db, KnownClass::Generator) - && proto_class.is_known(db, KnownClass::Generator) - { - return result; - } - // Fast path: skip expensive per-member type comparisons when members are plainly // missing. When collecting error context, we continue and let the structural check // below report per-member errors instead. @@ -596,6 +612,59 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { result.or(db, self.constraints, || structurally_satisfied) } + /// Tries to relate the finite members of two specializations of the same protocol. + /// + /// This retains structural solutions such as `T | int`, while recursive members are the + /// coinductive edge currently being proved. Returns `None` when the shortcut is inapplicable. + fn try_check_non_recursive_protocol_members( + &self, + db: &'db dyn Db, + ty: Type<'db>, + protocol: ProtocolInstanceType<'db>, + source_protocol_as_nominal: Option>, + nominal_instance: NominalInstanceType<'db>, + ) -> Option> { + if self.typevar_evaluation != TypeVarEvaluation::Lazy + || self.is_context_collection_enabled() + { + return None; + } + + let Type::ProtocolInstance(source_protocol) = ty else { + return None; + }; + let source_instance = source_protocol_as_nominal?; + let (ClassType::Generic(source_alias), ClassType::Generic(target_alias)) = + (source_instance.class(db), nominal_instance.class(db)) + else { + return None; + }; + if source_alias.origin(db) != target_alias.origin(db) { + return None; + } + let identity_protocol = target_alias + .origin(db) + .identity_specialization(db) + .into_protocol_class(db)?; + + let source_interface = source_protocol.interface(db); + let target_interface = protocol.interface(db); + let source_non_recursive = + non_recursive_protocol_interface(db, source_interface, identity_protocol, ty); + let target_non_recursive = non_recursive_protocol_interface( + db, + target_interface, + identity_protocol, + Type::ProtocolInstance(protocol), + ); + + if source_non_recursive == source_interface && target_non_recursive == target_interface { + return None; + } + + Some(self.check_protocol_interface_pair(db, ty, source_non_recursive, target_non_recursive)) + } + /// Return whether a class-object type inhabits `type[protocol]`. /// /// The effective constructor return must satisfy the instance protocol, while the class object @@ -640,6 +709,67 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } +/// Returns the finite members of a protocol interface, omitting members that refer back to its +/// class-backed origin. Type aliases are expanded, but lazy protocol attributes are not visited. +/// +/// For example, `value` is retained while `child` is omitted: +/// +/// ```python +/// class P[T](Protocol): +/// def value(self) -> T | int: ... +/// def child(self) -> P[list[T]]: ... +/// ``` +#[salsa::tracked(returns(copy), heap_size=ruff_memory_usage::heap_size)] +fn non_recursive_protocol_interface<'db>( + db: &'db dyn Db, + interface: ProtocolInterface<'db>, + protocol: ProtocolClass<'db>, + receiver_ty: Type<'db>, +) -> ProtocolInterface<'db> { + struct ProtocolReferenceFinder<'db> { + origin: ClassLiteral<'db>, + found: Cell, + recursion_guard: TypeCollector<'db>, + } + + impl<'db> TypeVisitor<'db> for ProtocolReferenceFinder<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type_alias_type(&self, db: &'db dyn Db, type_alias: TypeAliasType<'db>) { + self.visit_type(db, type_alias.value_type(db)); + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if self.found.get() { + return; + } + + if ty + .as_protocol_instance() + .and_then(ProtocolInstanceType::to_nominal_instance) + .is_some_and(|instance| instance.class_literal(db) == self.origin) + { + self.found.set(true); + return; + } + + walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard); + } + } + + interface.filter_members(db, |member| { + let visitor = ProtocolReferenceFinder { + origin: protocol.class_literal(db), + found: Cell::new(false), + recursion_guard: TypeCollector::default(), + }; + walk_protocol_instance_member(db, member, receiver_ty, &visitor); + !visitor.found.get() + }) +} + impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { /// Return `true` if this protocol type is disjoint from the protocol `other`. /// diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index a94a20174b..207f25e130 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -13,6 +13,7 @@ use crate::types::attribute_write::{ }; use crate::types::call::{CallArguments, CallDunderError}; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; +use crate::types::visitor::any_over_type; use crate::types::{TypeContext, UpcastPolicy}; use crate::{ Db, FxOrderSet, @@ -228,41 +229,51 @@ pub(super) fn walk_protocol_instance_interface< visitor: &V, ) { for member in interface.members(db) { - match member.data.kind { - ProtocolMemberKind::Method(method, _) => { - let Type::Callable(callable) = method.ty() else { - visitor.visit_type(db, method.ty()); - continue; - }; - for signature in callable.signatures(db) { - if signature.has_implicit_positional_receiver_annotation() { - let signature = signature.bind_self(db, Some(receiver_ty)); - walk_signature(db, &signature, visitor); - } else { - walk_signature(db, signature, visitor); - } - } - } - ProtocolMemberKind::Property { read, write } => { - for member_type in [ - read, - write.and_then(ProtocolMemberWrite::domain), - write.and_then(ProtocolMemberWrite::descriptor_type), - ] - .into_iter() - .flatten() - { - if let Some(ty) = member_type.bind_self(db, receiver_ty) { - visitor.visit_type(db, ty); - } + walk_protocol_instance_member(db, &member, receiver_ty, visitor); + } +} + +/// Walks the types of a protocol member after binding any implicit receiver to `receiver_ty`. +pub(super) fn walk_protocol_instance_member<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( + db: &'db dyn Db, + member: &ProtocolMember<'_, 'db>, + receiver_ty: Type<'db>, + visitor: &V, +) { + match member.data.kind { + ProtocolMemberKind::Method(method, _) => { + let Type::Callable(callable) = method.ty() else { + visitor.visit_type(db, method.ty()); + return; + }; + for signature in callable.signatures(db) { + if signature.has_implicit_positional_receiver_annotation() { + let signature = signature.bind_self(db, Some(receiver_ty)); + walk_signature(db, &signature, visitor); + } else { + walk_signature(db, signature, visitor); } } - ProtocolMemberKind::Attribute(attribute) => { - if let Some(ty) = attribute.bind_self(db, receiver_ty) { + } + ProtocolMemberKind::Property { read, write } => { + for member_type in [ + read, + write.and_then(ProtocolMemberWrite::domain), + write.and_then(ProtocolMemberWrite::descriptor_type), + ] + .into_iter() + .flatten() + { + if let Some(ty) = member_type.bind_self(db, receiver_ty) { visitor.visit_type(db, ty); } } } + ProtocolMemberKind::Attribute(attribute) => { + if let Some(ty) = attribute.bind_self(db, receiver_ty) { + visitor.visit_type(db, ty); + } + } } } @@ -338,6 +349,21 @@ impl<'db> ProtocolInterface<'db> { .map(|(name, data)| ProtocolMember { name, data }) } + pub(super) fn filter_members( + self, + db: &'db dyn Db, + mut predicate: impl FnMut(&ProtocolMember<'_, 'db>) -> bool, + ) -> Self { + Self::new( + db, + self.inner(db) + .iter() + .filter(|&(name, data)| predicate(&ProtocolMember { name, data })) + .map(|(name, data)| (name.clone(), data.clone())) + .collect::>(), + ) + } + fn member_count(self, db: &'db dyn Db) -> usize { self.inner(db).len() } @@ -1259,6 +1285,20 @@ pub(super) struct ProtocolMember<'a, 'db> { data: &'a ProtocolMemberData<'db>, } +/// Orders protocol members so that finite constraints are established before recursive relations. +/// +/// The declaration order is significant because the derived ordering is used when comparing +/// protocol interfaces. +#[derive(Eq, Ord, PartialEq, PartialOrd)] +enum StructuralMemberPriority { + /// A non-recursive member with at most one callable signature. + Simple, + /// A non-recursive callable member with multiple overloads. + FiniteOverload, + /// A member that may recurse through a protocol or type alias, or whose finiteness is unknown. + Recursive, +} + fn walk_protocol_member<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( db: &'db dyn Db, member: &ProtocolMember<'_, 'db>, @@ -1282,6 +1322,61 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { matches!(self.data.kind, ProtocolMemberKind::Method(..)) } + /// Returns the priority for structurally comparing this member. + /// + /// Simple finite members are cheapest, followed by finite overloads. Recursive and + /// alias-containing members are compared last because they can expand the same interface again. + fn structural_member_priority(&self, db: &'db dyn Db) -> StructuralMemberPriority { + let is_recursive_type = |ty| { + any_over_type(db, ty, false, |nested| { + matches!(nested, Type::ProtocolInstance(_) | Type::TypeAlias(_)) + }) + }; + + let ProtocolMemberKind::Method(member, _) = self.data.kind else { + let is_finite = self.data.kind.member_types().all(|member| { + member + .resolve(db) + .is_some_and(|member| !is_recursive_type(member.ty())) + }); + return if is_finite { + StructuralMemberPriority::Simple + } else { + StructuralMemberPriority::Recursive + }; + }; + let Type::Callable(callable) = member.ty() else { + return StructuralMemberPriority::Recursive; + }; + let signatures = callable.signatures(db); + let finite_priority = match signatures.iter().len() { + 0 => return StructuralMemberPriority::Recursive, + 1 => StructuralMemberPriority::Simple, + _ => StructuralMemberPriority::FiniteOverload, + }; + + let is_recursive = signatures.iter().any(|signature| { + signature + .receiver_constraint_types() + .chain( + signature + .parameters() + .iter() + .skip(usize::from( + signature.has_implicit_positional_receiver_annotation(), + )) + .map(Parameter::annotated_type), + ) + .chain(std::iter::once(signature.return_ty)) + .any(is_recursive_type) + }); + if is_recursive { + return StructuralMemberPriority::Recursive; + } + + finite_priority + } + fn is_instance_method(&self) -> bool { matches!( self.data.kind, @@ -2544,6 +2639,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { target .members(db) + .sorted_by_cached_key(|member| member.structural_member_priority(db)) .when_all(db, self.constraints, |target_member| { let source_member = source.member_by_name(db, target_member.name); diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 5eea757e1c..12f84b34fd 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -1341,7 +1341,7 @@ impl<'db> Signature<'db> { }) } - fn receiver_constraint_types(&self) -> impl Iterator> + '_ { + pub(super) fn receiver_constraint_types(&self) -> impl Iterator> + '_ { self.receiver_constraints .iter() .flat_map(OwnedConstraintSet::types) From 5055442b587534b1de91f1f87f51d0bf98490360 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 18 Jul 2026 08:02:45 -0400 Subject: [PATCH 141/222] [ty] Avoid editing ignore comments with trailing reasons (#26939) ## Summary When an add-ignore fix extended an existing suppression, we assumed that a final `]` in the comment closed the rule-code list. Add-ignore already avoids extending comments with trailing reasons, but a reason that itself ended in `]` accidentally passed this check, causing us to insert a rule into the reason instead of adding a valid suppression. We now identify the code list's actual closing bracket and preserve the existing behavior of treating comments with trailing reasons as uneditable. In that case, the CLI and IDE add a separate end-of-line suppression. ## Example ```python # Before value = missing # ty: ignore[] tracked by [123] # Previously value = missing # ty: ignore[] tracked by [123, unresolved-reference] # Now value = missing # ty: ignore[] tracked by [123] # ty:ignore[unresolved-reference] ``` --- crates/ty_python_semantic/src/fixes.rs | 28 +++++++++++++++++++ .../src/suppression/add_ignore.rs | 21 ++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/src/fixes.rs b/crates/ty_python_semantic/src/fixes.rs index 77982447e9..8c87573e13 100644 --- a/crates/ty_python_semantic/src/fixes.rs +++ b/crates/ty_python_semantic/src/fixes.rs @@ -1123,6 +1123,34 @@ class B(A): ); } + #[test] + fn add_ignore_does_not_append_to_trailing_reason() { + assert_snapshot!( + suppress_all_in(r#" + value = missing # ty: ignore[] tracked by [123] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + value = missing # ty: ignore[] tracked by [123] # ty:ignore[unresolved-reference] + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` without a code + --> test.py:1:18 + | + 1 | value = missing # ty: ignore[] tracked by [123] # ty:ignore[unresolved-reference] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + help: Remove the unused suppression comment + " + ); + } + #[test] fn add_ignore_groups_diagnostics_for_same_line_suppression() { assert_snapshot!( diff --git a/crates/ty_python_semantic/src/suppression/add_ignore.rs b/crates/ty_python_semantic/src/suppression/add_ignore.rs index a7df1670b9..8d31e4c744 100644 --- a/crates/ty_python_semantic/src/suppression/add_ignore.rs +++ b/crates/ty_python_semantic/src/suppression/add_ignore.rs @@ -282,9 +282,7 @@ fn add_to_existing_suppression( })?; let comment_text = &source[existing.comment_range]; - // Only add to the existing ignore comment if it has no reason. - let before_closing_bracket = comment_text.trim_end().strip_suffix(']')?; - let up_to_last_code = before_closing_bracket.trim_end(); + let up_to_last_code = editable_suppression_prefix(comment_text)?; let separator = if up_to_last_code.ends_with('[') { "" } else if up_to_last_code.ends_with(',') { @@ -302,6 +300,23 @@ fn add_to_existing_suppression( ))) } +/// Returns the portion of an ignore comment before its closing bracket if another code can be +/// appended to it. +/// +/// ```python +/// # ty: ignore[] # Editable +/// # ty: ignore[] reason # Not editable +/// ``` +fn editable_suppression_prefix(comment_text: &str) -> Option<&str> { + // The parser accepts a reason after the code list, but rule codes can't contain `]`, so the + // first `]` is the code list's closing bracket. Don't edit comments with trailing reasons. + let (before_closing_bracket, after_closing_bracket) = comment_text.split_once(']')?; + after_closing_bracket + .trim() + .is_empty() + .then(|| before_closing_bracket.trim_end()) +} + struct Codes<'a>(SuppressionKind, &'a [LintName]); impl std::fmt::Display for Codes<'_> { From 34940c056e31ecfbbbea35ec05e939124afd7f32 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Sat, 18 Jul 2026 11:29:36 -0700 Subject: [PATCH 142/222] [ty] Gate TypedDict PEP 728 parameters by Python version (#26968) --- .../resources/mdtest/call/type.md | 2 +- .../resources/mdtest/typed_dict.md | 83 ++++++++++++++++++- .../builder/post_inference/static_class.rs | 19 ++++- .../src/types/infer/builder/typed_dict.rs | 16 +++- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/type.md b/crates/ty_python_semantic/resources/mdtest/call/type.md index 4f5d36be35..bbd7518d44 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/type.md +++ b/crates/ty_python_semantic/resources/mdtest/call/type.md @@ -211,7 +211,7 @@ closed TypedDict namespace should NOT be marked as dynamic, and accessing unknow emit an error instead of returning `Unknown`. ```py -from typing import TypedDict +from typing_extensions import TypedDict class ClosedNamespace(TypedDict, closed=True): x: int diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 414b3afc88..125f387c8c 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -185,7 +185,9 @@ reveal_type(bob | carol_update) # revealed: Person Compatible `TypedDict` subset updates are also accepted for `|=`: ```py -class NameOnly(TypedDict, closed=True): +from typing_extensions import TypedDict as ExtensionsTypedDict + +class NameOnly(ExtensionsTypedDict, closed=True): name: str name_update: NameOnly = {"name": "Bobby"} @@ -5836,6 +5838,11 @@ reveal_type(Decorated) # revealed: ReplacesClass +```toml +[environment] +python-version = "3.15" +``` + A `TypedDict` may not inherit from a non-`TypedDict`: ```py @@ -5897,6 +5904,74 @@ class Quux(TypedDict, closed=1 == 1): ... # error: [invalid-argument-type] ## PEP 728 (`closed` and `extra_items`) +### Python-version support for `closed` and `extra_items` + +The PEP 728 keyword arguments are only available on the standard-library `TypedDict` starting in +Python 3.15. On older Python versions, they can be used with `typing_extensions.TypedDict` or in +stub files. + +```toml +[environment] +python-version = "3.14" +``` + +`runtime.py`: + +```py +from typing import TypedDict +from typing_extensions import TypedDict as ExtensionsTypedDict + +# error: [unknown-argument] "The `closed` parameter of `typing.TypedDict` was added in Python 3.15" +class Closed(TypedDict, closed=True): ... + +# error: [unknown-argument] "The `closed` parameter of `typing.TypedDict` was added in Python 3.15" +class Open(TypedDict, closed=False): ... + +# error: [unknown-argument] "The `extra_items` parameter of `typing.TypedDict` was added in Python 3.15" +class Extra(TypedDict, extra_items=int): ... + +# error: [unknown-argument] "The `closed` parameter of `typing.TypedDict` was added in Python 3.15" +FunctionalClosed = TypedDict("FunctionalClosed", {}, closed=True) + +# error: [unknown-argument] "The `extra_items` parameter of `typing.TypedDict` was added in Python 3.15" +FunctionalExtra = TypedDict("FunctionalExtra", {}, extra_items=int) + +class ExtensionsClosed(ExtensionsTypedDict, closed=True): ... +class ExtensionsExtra(ExtensionsTypedDict, extra_items=int): ... + +FunctionalExtensionsClosed = ExtensionsTypedDict("FunctionalExtensionsClosed", {}, closed=True) +FunctionalExtensionsExtra = ExtensionsTypedDict("FunctionalExtensionsExtra", {}, extra_items=int) +``` + +`stub.pyi`: + +```pyi +from typing import TypedDict + +class StubClosed(TypedDict, closed=True): ... +class StubExtra(TypedDict, extra_items=int): ... + +FunctionalStubClosed = TypedDict("FunctionalStubClosed", {}, closed=True) +FunctionalStubExtra = TypedDict("FunctionalStubExtra", {}, extra_items=int) +``` + +### Python 3.15 support for `closed` and `extra_items` + +```toml +[environment] +python-version = "3.15" +``` + +```py +from typing import TypedDict + +class Closed(TypedDict, closed=True): ... +class Extra(TypedDict, extra_items=int): ... + +FunctionalClosed = TypedDict("FunctionalClosed", {}, closed=True) +FunctionalExtra = TypedDict("FunctionalExtra", {}, extra_items=int) +``` + ### Iterating keys, values and items of a `closed=True` `TypedDict` Iterating over the keys produces a `Literal` type; iterating the values produces a union of all the @@ -6336,7 +6411,7 @@ Stringified forward references are understood: `a.py`: ```py -from typing import TypedDict +from typing_extensions import TypedDict class F(TypedDict, extra_items="F | None"): ... ``` @@ -6346,7 +6421,7 @@ While invalid syntax in forward annotations is rejected: `b.py`: ```py -from typing import TypedDict +from typing_extensions import TypedDict # error: [invalid-syntax-in-forward-annotation] class G(TypedDict, extra_items="not a type expression"): ... @@ -6357,7 +6432,7 @@ In non-stub files, forward references in `extra_items` must be stringified: `c.py`: ```py -from typing import TypedDict +from typing_extensions import TypedDict # error: [unresolved-reference] "Name `H` used when not defined" class H(TypedDict, extra_items=H | None): ... diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 7d942557f6..bea72b94c4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -4,18 +4,18 @@ use ruff_db::{ source::source_text, }; use ruff_diagnostics::{Edit, Fix}; -use ruff_python_ast::{self as ast, name::Name}; +use ruff_python_ast::{self as ast, PythonVersion, name::Name}; use ruff_text_size::{Ranged, TextRange, TextSize}; use rustc_hash::FxHashSet; use crate::{ - Db, TypeQualifiers, + Db, Program, TypeQualifiers, diagnostic::format_enumeration, place::{DefinedPlace, Place, TypeOrigin, place_from_bindings, place_from_declarations}, types::{ CallArguments, ClassBase, ClassLiteral, ClassType, DataclassFlags, KnownClass, KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, Parameters, Signature, - SpecialFormType, StaticClassLiteral, Type, TypeVarVariance, binding_type, + SpecialFormType, StaticClassLiteral, Type, TypeVarVariance, TypedDictModule, binding_type, call::Argument, class::{ AbstractMethod, CodeGeneratorKind, FieldKind, MetaclassErrorKind, @@ -639,7 +639,20 @@ pub(crate) fn check_static_class_definitions<'db>( // base class `__init_subclass__` method. if let Some(args) = class_node.arguments.as_deref() { if class_kind == Some(CodeGeneratorKind::TypedDict) { + let supports_pep_728 = context.in_stub() + || class.typed_dict_module(db) == Some(TypedDictModule::TypingExtensions) + || Program::get(db).python_version(db) >= PythonVersion::PY315; + for keyword in &args.keywords { + if !supports_pep_728 + && let Some(arg_name @ ("closed" | "extra_items")) = keyword.arg.as_deref() + && let Some(builder) = context.report_lint(&UNKNOWN_ARGUMENT, keyword) + { + builder.into_diagnostic(format_args!( + "The `{arg_name}` parameter of `typing.TypedDict` was added in Python 3.15" + )); + } + match keyword.arg.as_deref() { Some(arg_name @ ("total" | "closed")) => { let passed_type = file_expression_type(&keyword.value); diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index f182c78e22..e786503ad1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -1,11 +1,10 @@ use ruff_python_ast::name::Name; -use ruff_python_ast::{self as ast, AnyNodeRef, HasNodeIndex, NodeIndex}; +use ruff_python_ast::{self as ast, AnyNodeRef, HasNodeIndex, NodeIndex, PythonVersion}; use rustc_hash::FxHashMap; use smallvec::SmallVec; use strum::IntoEnumIterator; use super::TypeInferenceBuilder; -use crate::TypeQualifiers; use crate::types::class::{ClassLiteral, DynamicTypedDictAnchor, DynamicTypedDictLiteral}; use crate::types::diagnostic::{ INVALID_ARGUMENT_TYPE, INVALID_TYPE_FORM, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, @@ -22,6 +21,7 @@ use crate::types::{ IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext, TypedDictModule, TypedDictType, }; +use crate::{Program, TypeQualifiers}; use ty_python_core::definition::Definition; /// The shape of a `TypedDict` constructor call that affects how we prepare it for inference. @@ -146,12 +146,24 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut total = true; let mut closed = false; let mut extra_items = None; + let supports_pep_728 = self.in_stub() + || typed_dict_module == TypedDictModule::TypingExtensions + || Program::get(db).python_version(db) >= PythonVersion::PY315; for kw in keywords { let Some(arg) = &kw.arg else { continue; }; + if !supports_pep_728 + && matches!(&**arg, "closed" | "extra_items") + && let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) + { + builder.into_diagnostic(format_args!( + "The `{arg}` parameter of `typing.TypedDict` was added in Python 3.15" + )); + } + match &**arg { arg_name @ ("total" | "closed") => { let kw_type = self.infer_expression(&kw.value, TypeContext::default()); From 4d7d214d1bfdca78adcb729a1e7cf66cf75174f0 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 15:04:50 +0100 Subject: [PATCH 143/222] [ty] Remove redundant branch in `call/bind.rs` (#26977) --- crates/ty_python_semantic/src/types/call/bind.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index a64a53f8f6..0336088f10 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1802,20 +1802,6 @@ impl<'db> Bindings<'db> { } } - // TODO: This branch can be removed once https://github.com/astral-sh/ty/issues/501 is resolved - Type::BoundMethod(bound_method) - if bound_method.function(db).name(db) == "__iter__" - && is_enum_class(db, bound_method.self_instance(db)) => - { - if let Some(enum_instance) = - bound_method.self_instance(db).to_instance_approximation(db) - { - overload.set_return_type( - KnownClass::Iterator.to_specialized_instance(db, &[enum_instance]), - ); - } - } - function @ Type::FunctionLiteral(function_type) if dataclass_field_specifiers.contains(&function) => { From c5aa9063e286098f4c46980b1df4e4b91723a30f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 15:07:15 +0100 Subject: [PATCH 144/222] Prefer `let` guards and direct pattern matching (#26975) --- .../rules/pylint/rules/import_private_name.rs | 8 +-- .../rules/slice_to_remove_prefix_or_suffix.rs | 19 +++--- crates/ruff_python_parser/src/lexer.rs | 9 ++- .../ty_ide/src/docstring/markdown/general.rs | 11 ++-- crates/ty_python_semantic/src/types.rs | 42 ++++++------- .../ty_python_semantic/src/types/call/bind.rs | 62 ++++++++----------- .../ty_python_semantic/src/types/generics.rs | 16 +++-- .../src/types/match_pattern.rs | 5 +- .../types/property_tests/type_generation.rs | 12 +--- .../ty_python_semantic/src/types/relation.rs | 10 +-- 10 files changed, 86 insertions(+), 108 deletions(-) diff --git a/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs b/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs index 3241182541..82c4da9d05 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/import_private_name.rs @@ -4,7 +4,7 @@ use itertools::Itertools; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{self as ast, helpers::is_dunder, name::QualifiedName}; -use ruff_python_semantic::{FromImport, Import, Imported, ResolvedReference, Scope}; +use ruff_python_semantic::{AnyImport, FromImport, Import, Imported, ResolvedReference, Scope}; use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::Ranged; @@ -79,9 +79,9 @@ pub(crate) fn import_private_name(checker: &Checker, scope: &Scope) { }; let import_info = match import { - import if import.is_import() => ImportInfo::from(import.import().unwrap()), - import if import.is_from_import() => ImportInfo::from(import.from_import().unwrap()), - _ => continue, + AnyImport::Import(import) => ImportInfo::from(import), + AnyImport::FromImport(import) => ImportInfo::from(import), + AnyImport::SubmoduleImport(_) => continue, }; let Some(root_module) = import_info.module_name.first() else { diff --git a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs index 1bee43e782..fc5d03a337 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs @@ -386,15 +386,16 @@ fn affix_matches_slice_bound(data: &RemoveAffixData, semantic: &SemanticModel) - node_index: _, value: string_val, }), - ) if operand.is_number_literal_expr() => operand.as_number_literal_expr().is_some_and( - |ast::ExprNumberLiteral { value, .. }| { - // Only support prefix removal for size at most `u32::MAX` - value - .as_int() - .and_then(ast::Int::as_usize) - .is_some_and(|x| x == string_val.chars().count()) - }, - ), + ) if let ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(value), + .. + }) = &**operand => + { + // Only support prefix removal for size at most `u32::MAX` + value + .as_usize() + .is_some_and(|x| x == string_val.chars().count()) + } ( AffixKind::EndsWith, ast::Expr::UnaryOp(ast::ExprUnaryOp { diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index b0848d5352..868805bb6c 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -649,11 +649,14 @@ impl<'src> Lexer<'src> { self.cursor.bump(); Some(quote) } - second if is_quote(self.cursor.second()) => { + second + if let quote = self.cursor.second() + && is_quote(quote) => + { self.try_double_char_prefix([first, second]).then(|| { self.cursor.bump(); - // SAFETY: Safe because of the `is_quote` check in this match arm's guard - self.cursor.bump().unwrap() + self.cursor.bump(); + quote }) } _ => None, diff --git a/crates/ty_ide/src/docstring/markdown/general.rs b/crates/ty_ide/src/docstring/markdown/general.rs index d3225df7db..f7eaba5e11 100644 --- a/crates/ty_ide/src/docstring/markdown/general.rs +++ b/crates/ty_ide/src/docstring/markdown/general.rs @@ -191,17 +191,18 @@ fn render_with_indentation_mode( match directive { // Special directives that should be plaintext Some( - "attention" | "caution" | "danger" | "error" | "hint" | "important" | "note" - | "tip" | "warning" | "admonition" | "versionadded" | "version-added" - | "versionchanged" | "version-changed" | "version-deprecated" | "deprecated" - | "version-removed" | "versionremoved", + directive @ ("attention" | "caution" | "danger" | "error" | "hint" + | "important" | "note" | "tip" | "warning" | "admonition" + | "versionadded" | "version-added" | "versionchanged" + | "version-changed" | "version-deprecated" | "deprecated" + | "version-removed" | "versionremoved"), ) => { // A directive starts a new block and cannot continue a // pending hyperlink from the previous line. renderer.flush_pending_link(); // Map version directives to human-readable phrases (matching Sphinx output) - let pretty_directive = match directive.unwrap() { + let pretty_directive = match directive { "versionadded" | "version-added" => Cow::Borrowed("Added in version"), "versionchanged" | "version-changed" => Cow::Borrowed("Changed in version"), "deprecated" | "version-deprecated" => { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e710a25695..167ec01eaf 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3328,7 +3328,8 @@ impl<'db> Type<'db> { return Some((ty, AttributeKind::NormalOrNonDataDescriptor)); } Type::Callable(callable) - if callable.is_function_like(db) || callable.is_classmethod_like(db) => + if let is_function_like = callable.is_function_like(db) + && (is_function_like || callable.is_classmethod_like(db)) => { // For "function-like" or "classmethod-like" callables, model the behavior of // `FunctionType.__get__` or `classmethod.__get__`. @@ -3338,7 +3339,7 @@ impl<'db> Type<'db> { // method of these synthesized functions. The method-wrapper would then be returned from // `find_name_in_mro` when called on function-like `Callable`s. This would allow us to // correctly model the behavior of *explicit* `SomeDataclass.__init__.__get__` calls. - return if instance.is_none() && callable.is_function_like(db) { + return if instance.is_none() && is_function_like { Some((ty, AttributeKind::NormalOrNonDataDescriptor)) } else { let self_type = instance.unwrap_or_else(|| { @@ -4258,7 +4259,7 @@ impl<'db> Type<'db> { let is_enum_subclass = Type::ClassLiteral(enum_class.class_literal(db)) .is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)); - (match name_str { + let ty = match name_str { "name" if is_enum_subclass => { enum_class.name_type(db, enum_literal.name(db)) } @@ -4268,9 +4269,9 @@ impl<'db> Type<'db> { } "_value_" => enum_class.value_type(db, enum_literal.name(db)), _ => None, - }) - .map_or_else(|| Place::Undefined, Place::bound) - .into() + }; + + ty.map(Place::bound).unwrap_or_default().into() } Type::TypeVar(typevar) if name_str == "args" && typevar.is_paramspec(db) => { @@ -4308,27 +4309,22 @@ impl<'db> Type<'db> { Type::NominalInstance(instance) if matches!(name_str, "name" | "_name_" | "value" | "_value_") - && enum_metadata(db, instance.class_literal(db)).is_some() - && !enums::class_defines_property( - db, - instance.class_literal(db), - name_str, - ) => + && let class_literal = instance.class_literal(db) + && let Some(metadata) = enum_metadata(db, class_literal) + && !enums::class_defines_property(db, class_literal, name_str) => { - let class_literal = instance.class_literal(db); let is_enum_subclass = Type::ClassLiteral(class_literal) .is_subtype_of(db, KnownClass::Enum.to_subclass_of(db)); - enum_metadata(db, class_literal) - .and_then(|metadata| match name_str { - "name" if is_enum_subclass => metadata.instance_name_type(db), - "_name_" => metadata.instance_name_type(db), - "value" if is_enum_subclass => metadata.instance_value_type(db), - "_value_" => metadata.instance_value_type(db), - _ => None, - }) - .map_or_else(Place::default, Place::bound) - .into() + let ty = match name_str { + "name" if is_enum_subclass => metadata.instance_name_type(db), + "_name_" => metadata.instance_name_type(db), + "value" if is_enum_subclass => metadata.instance_value_type(db), + "_value_" => metadata.instance_value_type(db), + _ => None, + }; + + ty.map(Place::bound).unwrap_or_default().into() } Type::KnownInstance(KnownInstanceType::FunctoolsPartial(partial)) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 0336088f10..1c5fe66980 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1748,52 +1748,44 @@ impl<'db> Bindings<'db> { }, Type::BoundMethod(bound_method) - if bound_method.self_instance(db).is_property_instance() => + if let Type::PropertyInstance(property) = + bound_method.self_instance(db) => { match bound_method.function(db).name(db).as_str() { "setter" => { if let [Some(_), Some(setter)] = overload.parameter_types() { - let mut ty_property = bound_method.self_instance(db); - if let Type::PropertyInstance(property) = ty_property { - ty_property = - Type::PropertyInstance(property.with_accessors( - db, - property.getter(db), - Some(*setter), - property.deleter(db), - )); - } - overload.set_return_type(ty_property); + overload.set_return_type(Type::PropertyInstance( + property.with_accessors( + db, + property.getter(db), + Some(*setter), + property.deleter(db), + ), + )); } } "getter" => { if let [Some(_), Some(getter)] = overload.parameter_types() { - let mut ty_property = bound_method.self_instance(db); - if let Type::PropertyInstance(property) = ty_property { - ty_property = - Type::PropertyInstance(property.with_accessors( - db, - Some(*getter), - property.setter(db), - property.deleter(db), - )); - } - overload.set_return_type(ty_property); + overload.set_return_type(Type::PropertyInstance( + property.with_accessors( + db, + Some(*getter), + property.setter(db), + property.deleter(db), + ), + )); } } "deleter" => { if let [Some(_), Some(deleter)] = overload.parameter_types() { - let mut ty_property = bound_method.self_instance(db); - if let Type::PropertyInstance(property) = ty_property { - ty_property = - Type::PropertyInstance(property.with_accessors( - db, - property.getter(db), - property.setter(db), - Some(*deleter), - )); - } - overload.set_return_type(ty_property); + overload.set_return_type(Type::PropertyInstance( + property.with_accessors( + db, + property.getter(db), + property.setter(db), + Some(*deleter), + ), + )); } } _ => { @@ -5734,7 +5726,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { callable_binding.overload_call_return_type, Some(OverloadCallReturnType::ArgumentTypeExpansion(_)) ) { - extend_errors(&callable_binding.overloads().first().unwrap().errors); + extend_errors(&callable_binding.overloads()[0].errors); } } } diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index a9a4304e21..ef02367019 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2760,17 +2760,15 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } (Type::TypeForm(formal_typeform), Type::KnownInstance(actual_instance)) - if actual_instance.is_type_form_value() => + if let Some(actual_argument) = actual_instance.type_form_argument(self.db) => { let variance = TypeVarVariance::Covariant.compose(polarity); - if let Some(actual_argument) = actual_instance.type_form_argument(self.db) { - return self.infer_map_impl( - formal_typeform.type_argument(self.db), - actual_argument, - variance, - seen, - ); - } + return self.infer_map_impl( + formal_typeform.type_argument(self.db), + actual_argument, + variance, + seen, + ); } (Type::TypeForm(formal_typeform), Type::SpecialForm(actual_form)) => { diff --git a/crates/ty_python_semantic/src/types/match_pattern.rs b/crates/ty_python_semantic/src/types/match_pattern.rs index 1b4e47e2bf..ec25eb7c91 100644 --- a/crates/ty_python_semantic/src/types/match_pattern.rs +++ b/crates/ty_python_semantic/src/types/match_pattern.rs @@ -624,9 +624,10 @@ pub(crate) fn definite_match_pattern_type_for_subject<'db>( } Type::SpecialForm(SpecialFormType::CollectionsAbcCallable) if kind.is_empty() - && subject_ty.is_subtype_of(db, callable_pattern_type(db)) => + && let callable_pattern_ty = callable_pattern_type(db) + && subject_ty.is_subtype_of(db, callable_pattern_ty) => { - return callable_pattern_type(db); + return callable_pattern_ty; } _ => {} } diff --git a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs index eeadb93022..2fef4cc9c7 100644 --- a/crates/ty_python_semantic/src/types/property_tests/type_generation.rs +++ b/crates/ty_python_semantic/src/types/property_tests/type_generation.rs @@ -505,11 +505,7 @@ fn arbitrary_parameter_list(g: &mut Gen, size: u32, fully_static: bool) -> Vec

Option { - match u32::arbitrary(g) % 2 { - 0 => None, - 1 => Some(arbitrary_type(g, size, fully_static)), - _ => unreachable!(), - } + bool::arbitrary(g).then(|| arbitrary_type(g, size, fully_static)) } fn arbitrary_name(g: &mut Gen) -> Name { @@ -517,11 +513,7 @@ fn arbitrary_name(g: &mut Gen) -> Name { } fn arbitrary_optional_name(g: &mut Gen) -> Option { - match u32::arbitrary(g) % 2 { - 0 => None, - 1 => Some(arbitrary_name(g)), - _ => unreachable!(), - } + bool::arbitrary(g).then(|| arbitrary_name(g)) } impl Arbitrary for Ty { diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 6e644cbc2b..9532e9e3fd 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1194,15 +1194,9 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { ), (Type::KnownInstance(source_instance), Type::TypeForm(target_typeform)) - if source_instance.is_type_form_value() => + if let Some(source_argument) = source_instance.type_form_argument(db) => { - source_instance.type_form_argument(db).when_some_and( - db, - self.constraints, - |source_argument| { - self.check_type_pair(db, source_argument, target_typeform.type_argument(db)) - }, - ) + self.check_type_pair(db, source_argument, target_typeform.type_argument(db)) } (Type::SpecialForm(source_form), Type::TypeForm(target_typeform)) => source_form From fce6bc781be1bfd3b9be2898b53fc9bb8901fbf0 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 16:05:34 +0100 Subject: [PATCH 145/222] [ty] Fix TODO in `Type::try_call_dunder_get_on_attribute` (#26978) --- crates/ty_python_semantic/src/types.rs | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 167ec01eaf..245022c27d 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3494,31 +3494,40 @@ impl<'db> Type<'db> { provenance: attribute_provenance, }), qualifiers, - } => ( - union + } => { + let mut all_data_descriptors = true; + + let place = union .map_with_boundness(db, |elem| { + let ty = match elem.try_call_dunder_get(db, instance, owner) { + Some((ty, kind)) => { + all_data_descriptors &= kind.is_data(); + ty + } + None => { + all_data_descriptors = false; + *elem + } + }; + Place::Defined(DefinedPlace { - ty: elem - .try_call_dunder_get(db, instance, owner) - .map_or(*elem, |(ty, _)| ty), + ty, origin, definedness: boundness, public_type_policy, - provenance: Provenance::Unknown, + provenance: attribute_provenance, }) }) - .with_provenance(attribute_provenance) - .with_qualifiers(qualifiers), - // TODO: avoid the duplication here: - if union.elements(db).iter().all(|elem| { - elem.try_call_dunder_get(db, instance, owner) - .is_some_and(|(_, kind)| kind.is_data()) - }) { + .with_qualifiers(qualifiers); + + let kind = if all_data_descriptors { AttributeKind::DataDescriptor } else { AttributeKind::NormalOrNonDataDescriptor - }, - ), + }; + + (place, kind) + } attribute @ PlaceAndQualifiers { place: @@ -3543,10 +3552,9 @@ impl<'db> Type<'db> { origin, definedness, public_type_policy, - provenance: Provenance::Unknown, + provenance: attribute_provenance, }) }) - .with_provenance(attribute_provenance) .with_qualifiers(qualifiers) }, // TODO: Discover data descriptors in intersections. From 5a1f29919df7ad7aca16b5bc55b9a5ab5952d659 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Sun, 19 Jul 2026 08:34:37 -0700 Subject: [PATCH 146/222] [ty] Fix Callable isinstance reachability (#26970) ## Summary Closes https://github.com/astral-sh/ty/issues/4030. `isinstance(x, Callable)` correctly narrows a remaining callable value, but the call was still represented as an internally failed binding because typing special forms were only exempted while reporting `invalid-argument-type`. As a result, the known-function truthiness hook never ran and exhaustive branches could incorrectly produce `possibly-unresolved-reference` or `invalid-return-type`. This moves the existing runtime-valid `isinstance`/`issubclass` special-form exception into argument binding, removes the redundant diagnostic-layer suppression, and infers `Literal[True]` for both `typing.Callable` and `collections.abc.Callable` when the checked value is known to be callable. ## Test Plan Added direct truthiness and exhaustive-control-flow mdtests covering both Callable spellings and a previously narrowed union. --- .../resources/mdtest/call/builtins.md | 19 ++++++++ .../mdtest/exhaustiveness_checking.md | 38 ++++++++++++++++ .../ty_python_semantic/src/types/call/bind.rs | 43 ++++++++----------- .../ty_python_semantic/src/types/function.rs | 12 ++++++ 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 40a08e75e0..bbf49b9c09 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -161,6 +161,25 @@ def _( reveal_type(isinstance(x_constrained_sub_a, B)) # revealed: bool ``` +An `isinstance` check against `Callable` is always true when the checked value is known to be +callable. This applies to both `typing.Callable` and `collections.abc.Callable`, including after a +preceding `isinstance` check narrows a union. + +```py +from collections.abc import Callable +from typing import Callable as TypingCallable + +def reveal_callable_result(x: Callable[[int], int], y: Callable[[int], int] | dict[str, int]): + reveal_type(isinstance(x, Callable)) # revealed: Literal[True] + reveal_type(isinstance(x, TypingCallable)) # revealed: Literal[True] + + if isinstance(y, dict): + return + + reveal_type(isinstance(y, Callable)) # revealed: Literal[True] + reveal_type(isinstance(y, TypingCallable)) # revealed: Literal[True] +``` + An `isinstance` check against a tuple is always true when each possible type of the checked value is accepted by at least one class in the tuple. This avoids a false implicit-return error when the check is the only path that returns a value. The same applies when the tuple is assigned to a local diff --git a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md index 95a15a1b1a..c5c1883356 100644 --- a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md +++ b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md @@ -818,3 +818,41 @@ def i(x: ComplexN) -> bool: elif isinstance(x, complex): return False ``` + +## `isinstance` checks with `Callable` + +```toml +[environment] +python-version = "3.12" + +[rules] +possibly-unresolved-reference = "error" +``` + +The final `Callable` check is exhaustive. These examples deliberately omit an `else` branch and any +terminal-call assertions so that reachability is determined by the `isinstance` checks alone. + +```py +from collections.abc import Callable +from typing import Callable as TypingCallable + +def assigned(x: Callable[[int], int] | dict[str, int]) -> int: + if isinstance(x, dict): + result = 1 + elif isinstance(x, Callable): + result = 2 + return result + +def returns(x: Callable[[int], int] | dict[str, int]) -> int: + if isinstance(x, dict): + return 1 + elif isinstance(x, TypingCallable): + return 2 + +def match_exhaustive(x: Callable[[int], int] | dict[str, int]) -> int: + match x: + case dict(): + return 1 + case Callable(): + return 2 +``` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 1c5fe66980..8a0bd0d8f6 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -5378,6 +5378,23 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { argument_type = argument_type.apply_specialization(self.db, specialization); expected_ty = expected_ty.apply_specialization(self.db, specialization); } + + // Some typing special forms are valid class-info arguments at runtime but are not + // assignable to typeshed's `isinstance`/`issubclass` class-info annotation. + let is_valid_isinstance_target = || { + parameter_index == 1 + && adjusted_argument_index == Some(1) + && matches!( + self.signature_type + .as_function_literal() + .and_then(|function| function.known(self.db)), + Some(KnownFunction::IsInstance | KnownFunction::IsSubclass) + ) + && argument_type + .as_special_form() + .is_some_and(SpecialFormType::is_valid_isinstance_target) + }; + // This is one of the few places where we want to check if there's _any_ specialization // where assignability holds; normally we want to check that assignability holds for // _all_ specializations. @@ -5394,6 +5411,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // TODO: handle starred annotations, e.g. `*args: *Ts` or `*args: *tuple[int, *tuple[str, ...]]` if !self.constraint_set_errors[argument_index] && !parameter.has_starred_annotation() + && !is_valid_isinstance_target() && argument_type .when_assignable_to(self.db, expected_ty, constraints, self.inferable_typevars) .is_never_satisfied(self.db) @@ -7431,31 +7449,6 @@ impl<'db> BindingError<'db> { provided_ty, provenance, } => { - // Certain special forms in the typing module are aliases for classes - // elsewhere in the standard library. These special forms are not instances of `type`, - // and you cannot use them in place of their aliased classes in *all* situations: - // for example, `dict()` succeeds at runtime, but `typing.Dict()` fails. However, - // they *can* all be used as the second argument to `isinstance` and `issubclass`. - // We model that specific aspect of their behaviour here. - // - // This is implemented as a special case in call-binding machinery because overriding - // typeshed's signatures for `isinstance()` and `issubclass()` would be complex and - // error-prone, due to the fact that they are annotated with recursive type aliases. - if parameter.index == 1 - && *argument_index == Some(1) - && matches!( - callable_ty - .as_function_literal() - .and_then(|function| function.known(context.db())), - Some(KnownFunction::IsInstance | KnownFunction::IsSubclass) - ) - && provided_ty - .as_special_form() - .is_some_and(SpecialFormType::is_valid_isinstance_target) - { - return; - } - // TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments // here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have // silenced diagnostics during overload evaluation, and rely on the assignability diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 9d46f81496..dff9eb157f 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -2629,6 +2629,18 @@ impl KnownFunction { if self == KnownFunction::IsInstance { let truthiness = match second_argument { Type::ClassLiteral(class) => is_instance_truthiness(db, *first_arg, *class), + Type::SpecialForm( + SpecialFormType::TypingCallable + | SpecialFormType::CollectionsAbcCallable, + ) => { + let callable_top = + Type::Callable(CallableType::unknown(db)).top_materialization(db); + if first_arg.is_subtype_of(db, callable_top) { + Truthiness::AlwaysTrue + } else { + Truthiness::Ambiguous + } + } _ if is_instance_tuple_exhaustive(db, *first_arg, *second_argument) => { Truthiness::AlwaysTrue } From 775de0e6a15da769960d7df890737aaa8f03c54a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 17:11:01 +0100 Subject: [PATCH 147/222] [ty] Improve `match` reachability inference around value-pattern branches (#26979) --- .../resources/mdtest/conditional/match.md | 22 +++++++++++++++---- crates/ty_python_semantic/src/reachability.rs | 6 +---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/conditional/match.md b/crates/ty_python_semantic/resources/mdtest/conditional/match.md index 3747485417..80c975b663 100644 --- a/crates/ty_python_semantic/resources/mdtest/conditional/match.md +++ b/crates/ty_python_semantic/resources/mdtest/conditional/match.md @@ -129,19 +129,33 @@ type `Literal[2]`. This leads us to infer `Literal[1, 3]` as the type of `y` after the `match` statement, rather than `Literal[1]`: +```py +class C: ... + +def _(subject: C): + y = 1 + match subject: + case 2: + y = 3 + reveal_type(y) # revealed: Literal[1, 3] +``` + +However, in this variant, we can prove that `D` here does not have a custom `__eq__` implementation, +since it is `@final`. This means that we know it does not compare equal to `2`, allowing us to infer +`Literal[1]` after the `match` statement + ```py from typing import final @final -class C: - pass +class D: ... -def _(subject: C): +def _(subject: D): y = 1 match subject: case 2: y = 3 - reveal_type(y) # revealed: Literal[1, 3] + reveal_type(y) # revealed: Literal[1] ``` ## Class match diff --git a/crates/ty_python_semantic/src/reachability.rs b/crates/ty_python_semantic/src/reachability.rs index 0f2a50446c..f589947693 100644 --- a/crates/ty_python_semantic/src/reachability.rs +++ b/crates/ty_python_semantic/src/reachability.rs @@ -1120,11 +1120,7 @@ fn analyze_single_pattern_predicate_kind<'db>( PatternPredicateKind::Value(value) => { let value_ty = infer_same_file_expression_type(db, *value, TypeContext::default()); - if subject_ty.is_single_valued(db) { - equality_truthiness(db, subject_ty, value_ty) - } else { - Truthiness::Ambiguous - } + equality_truthiness(db, subject_ty, value_ty) } PatternPredicateKind::Singleton(singleton) => { let singleton_ty = singleton_pattern_type(db, *singleton); From 8bbdb8e392e13481db33abc731546eec5a827d36 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 17:27:51 +0100 Subject: [PATCH 148/222] [ty] Remove unnecessary call to `is_single_valued` in `match_pattern.rs` (#26980) --- crates/ty_python_semantic/src/types/match_pattern.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/src/types/match_pattern.rs b/crates/ty_python_semantic/src/types/match_pattern.rs index ec25eb7c91..66314b03bf 100644 --- a/crates/ty_python_semantic/src/types/match_pattern.rs +++ b/crates/ty_python_semantic/src/types/match_pattern.rs @@ -1047,10 +1047,9 @@ pub(crate) fn definite_match_pattern_type<'db>( PatternPredicateKind::Singleton(singleton) => singleton_pattern_type(db, *singleton), PatternPredicateKind::Value(value) => { let ty = infer_same_file_expression_type(db, *value, TypeContext::default()); - // Only return the type if it's single-valued and guaranteed to match itself. + // Only return the type if it's guaranteed to match itself. // Otherwise, we can't definitively exclude it from subsequent patterns. - if ty.is_single_valued(db) && equality_truthiness(db, ty, ty) == Truthiness::AlwaysTrue - { + if equality_truthiness(db, ty, ty) == Truthiness::AlwaysTrue { ty } else { Type::Never From 8276d63094341264f0f17820b6b32d2ad6a599ed Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:36:58 +0900 Subject: [PATCH 149/222] [ty] Prevent stack overflows in recursive type relation checks (#26503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes ~~astral-sh/ty#3452~~ I intended to fix it, but the fixes were separated into #26881, #26882 and #26898. This PR provides a foundation for fixes, but there are observable improvements on this PR alone. For example: ```py from ty_extensions import static_assert from ty_extensions._internal import is_subtype_of type Left[T] = tuple[Left[list[T]]] type Right[T] = tuple[Right[list[T]]] # TODO: Left[int] should be equivalent to (subtype of) Right[int] static_assert(not is_subtype_of(Left[int], Right[int])) ``` This will result in stack overflow in the current main. A recursive type like `type StableRecursiveList[T] = T | list[StableRecursiveList[T]]` can be checked without any problem even in the current main. This is because the specialization that appears in the recursive type on the right side is the same as the left side, so the recursion guard using simple type equality works. The problem here is that among recursive aliases, the specialization on the right-hand side grows with each expansion. Current recursion guards cannot notice such type alias reentrancy. Therefore, when considering generic recursive type aliases, two levels of protection must be applied, distinguishing between equivalence based on type definition identity and full type equivalence, including specialization. Applying a recursive guard that only considers the latter will fail to detect cases of growing specialization (this is exactly the problem exposed by MRE). On the other hand, if we guard by considering only the former, specialization will not be considered, so we will treat `GrowingList[int]` and `GrowingList[str]` as the same thing. With this PR, the `CycleDetector` will now return a `Cycle` state in addition to `Ready` and `Pending` as a result of a visit. If the type aliases, including specializations, are equivalent to types already seen during the visit, the detector will still return the fallback value as `Ready`. If the type aliases are the same but the specializations are different, return `Cycle` and ask the upstream relation checker to decide. In the case of `TypeRelationChecker`, when this is received, it executes `recursive_type_pair_fallback` to complete the determination. --- So, what should `recursive_type_pair_fallback` do? If we call `check_type_pair` during this process, we will enter recursion again, so we need to make it a finite process. As I thought about it, I realized that this problem was undecidable. There is no general subtyping algorithm for growing recursive type aliases. This is because they will have expressive power equal to or greater than that of context-free grammars [^1]. In other words, determining the subtype of two such recursive aliases is the same problem as determining the equivalence and inclusion of two CFGs that are known to be undecidable. ```py from typing import Literal, final from ty_extensions import static_assert from ty_extensions._internal import is_subtype_of @final class End: pass type AAnd[Rest] = tuple[Literal["a"], Rest] type BAnd[Rest] = tuple[Literal["b"], Rest] # S -> ε | aSb # {a^n b^n | n >= 0} type S[Rest] = Rest | AAnd[S[BAnd[Rest]]] # T -> ε | aTb | aaTbb # {a^n b^n | n >= 0} type T[Rest] = Rest | AAnd[T[BAnd[Rest]]] | AAnd[AAnd[T[BAnd[BAnd[Rest]]]]] # S and T produce exactly the same language, but the type checker cannot tell that. static_assert(not is_subtype_of(S[End], T[End])) ``` Therefore, we have to give up on this kind of recursive alias typing at some point (FYI, mypy makes growing recursive aliases like this illegal in the first place; pyright allows them, but seems to just have a recursion depth limit). In this PR, it simply returns a conservative solution immediately when a growing pattern of recursive type alias is detected. In reality, we can extend the support a little more, but we'll leave that as future work and focus on fixes first. --- #26881 strengthens the recursion guard in `TypeTransformer` under this PR change. This directly fixes #3452. #26882 strengthens the recursion guard in `UnionBuilder` under this PR change. This will properly stop the expansion of recursive union aliases that would result in stack overflow in the current main. #26898 adds `RecursionGuard` that wraps `TypeCollector`. [^1]: To be more specific, the type argument of type alias can be considered as the stack memory of a pushdown automaton. If the generic type alias itself appears on the right side with a different specialization than the left side, it corresponds to being able to push additional information onto the stack along with the state transition. If it has only trivial specializations like the left side, it cannot be used as a stack, and its abilities are equivalent to a finite automaton. ## Test Plan new mdtest cases --------- Co-authored-by: Micha Reiser --- crates/ty_ide/src/completion.rs | 4 +- .../resources/mdtest/pep695_type_aliases.md | 68 ++++ .../ty_python_semantic/src/semantic_model.rs | 3 +- crates/ty_python_semantic/src/types.rs | 41 +- crates/ty_python_semantic/src/types/bool.rs | 4 +- crates/ty_python_semantic/src/types/cyclic.rs | 381 ++++++++++++++---- .../types/infer/builder/binary_expressions.rs | 6 +- .../src/types/infer/builder/subscript.rs | 4 +- .../src/types/infer/builder/type_form.rs | 6 +- .../src/types/infer/comparisons.rs | 17 +- .../ty_python_semantic/src/types/relation.rs | 174 +++++--- .../src/types/type_alias.rs | 116 +++++- .../ty_python_semantic/src/types/type_form.rs | 6 +- .../ty_python_semantic/src/types/typevar.rs | 12 +- 14 files changed, 645 insertions(+), 197 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 5430c73370..6aa69779c9 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -3103,7 +3103,7 @@ fn is_name_like_token(token: &Token) -> bool { /// on `CompletionBuilder`. fn completion_kind_from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option { type CompletionKindVisitor<'db> = - CycleDetector, Option, 3>; + CycleDetector<'db, CompletionKind, Type<'db>, Option, 3>; fn imp<'db>( db: &'db dyn Db, @@ -3150,7 +3150,7 @@ fn completion_kind_from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option return None, Type::TypeAlias(alias) => { - visitor.visit(ty, || imp(db, alias.value_type(db), visitor))? + visitor.visit(db, ty, || imp(db, alias.value_type(db), visitor))? } }) } diff --git a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md index cbcf8f6485..79933eefbd 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md @@ -630,6 +630,74 @@ def _(x: C): reveal_type(x) # revealed: () -> C | None ``` +### Growing recursive alias relations without unions + +```py +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +type Left[T] = tuple[Left[list[T]]] +type Right[T] = tuple[Right[list[T]]] + +# TODO: Left[int] should be equivalent to (subtype of) Right[int] +static_assert(not is_subtype_of(Left[int], Right[int])) +``` + +### Non-recursive nested generic aliases + +A repeated use of the same generic alias can be a finite alias application instead of recursion. + +```py +from typing import Literal + +from ty_extensions import static_assert +from ty_extensions._internal import is_subtype_of + +type NonRecursiveId[T] = T + +static_assert(is_subtype_of(NonRecursiveId[NonRecursiveId[int]], int)) +static_assert(not is_subtype_of(NonRecursiveId[NonRecursiveId[int]], str)) + +truth: NonRecursiveId[NonRecursiveId[Literal[True]]] = True +static_assert(truth) + +one: NonRecursiveId[NonRecursiveId[Literal[1]]] = 1 +reveal_type(one + 1) # revealed: Literal[2] +reveal_type(one == 1) # revealed: Literal[True] + +def nested_union( + value: list[NonRecursiveId[NonRecursiveId[int]]] | list[int], +): + reveal_type(value) # revealed: list[NonRecursiveId[NonRecursiveId[int]]] + +# A finite nested application can also be hidden behind another named alias. +type Intermediate[T] = T +type LeftIntAlias = NonRecursiveId[int] +type RightIntAlias = NonRecursiveId[int] + +def finite_alias_chain(x: NonRecursiveId[Intermediate[NonRecursiveId[int]]]): + reveal_type(x + 1) # revealed: int + reveal_type(x == 1) # revealed: bool + # error: [invalid-assignment] + invalid: str = x + +def equivalent_finite_aliases(x: NonRecursiveId[LeftIntAlias]): + valid: NonRecursiveId[RightIntAlias] = x + +type IntAlias = int + +def unchanged_alias_pair(x: NonRecursiveId[NonRecursiveId[bool]]): + # `bool` is a subtype of `int`. The unchanged target alias must not cause the finite source + # expansion to be treated as recursive. + valid: IntAlias = x + +type NoneAlias = NonRecursiveId[None] +type NestedNoneAlias = NonRecursiveId[NoneAlias] + +def finite_alias_union(x: NonRecursiveId[NestedNoneAlias], condition: bool): + reveal_type(x if condition else 1) # revealed: None | Literal[1] +``` + ### Subtyping of materializations of cyclic aliases ```py diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 59b579c1a1..104ae4cde4 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -531,6 +531,7 @@ impl<'db> SemanticModel<'db> { ) -> Vec> { struct StringLiteralCandidates; type StringLiteralCandidatesVisitor<'db> = CycleDetector< + 'db, StringLiteralCandidates, Type<'db>, Vec>, @@ -564,7 +565,7 @@ impl<'db> SemanticModel<'db> { .flat_map(|element| collect(db, *element, visitor)) .collect(), Type::TypeAlias(alias) => { - visitor.visit(ty, || collect(db, alias.value_type(db), visitor)) + visitor.visit(db, ty, || collect(db, alias.value_type(db), visitor)) } _ => Vec::new(), } diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 245022c27d..1b8d1259b9 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -294,7 +294,7 @@ struct ApplyTypeMappingTag; struct ApplyMaterializationEquivalence; type MaterializationEquivalenceVisitor<'db> = - Rc, Type<'db>), bool, 1>>; + Rc, Type<'db>), bool, 1>>; /// A [`TypeTransformer`] that is used in `apply_type_mapping` methods. /// @@ -352,9 +352,10 @@ impl<'db> ApplyTypeMappingVisitor<'db> { left: Type<'db>, right: Type<'db>, ) -> bool { - self.materialization_equivalence().visit((left, right), || { - left.is_equivalent_to_with_materialization_visitor(db, right, self) - }) + self.materialization_equivalence() + .visit(db, (left, right), || { + left.is_equivalent_to_with_materialization_visitor(db, right, self) + }) } pub(crate) fn for_new_materialization_root(&self) -> Self { @@ -372,13 +373,14 @@ impl<'db> ApplyTypeMappingVisitor<'db> { /// A [`CycleDetector`] that is used in `find_legacy_typevars` methods. pub(crate) type FindLegacyTypeVarsVisitor<'db> = - CycleDetector, (), 3>; + CycleDetector<'db, FindLegacyTypeVars, Type<'db>, (), 3>; #[derive(Debug)] pub(crate) struct FindLegacyTypeVars; /// A [`CycleDetector`] that is used in `visit_specialization` methods. -pub(crate) type SpecializationVisitor<'db> = CycleDetector, (), 3>; +pub(crate) type SpecializationVisitor<'db> = + CycleDetector<'db, VisitSpecialization, Type<'db>, (), 3>; pub(crate) struct VisitSpecialization; /// How a generic type has been specialized. @@ -2365,7 +2367,7 @@ impl<'db> Type<'db> { element.visit_specialization_impl(db, polarity, f, visitor); } } - Type::TypeAlias(alias) => visitor.visit(self, || { + Type::TypeAlias(alias) => visitor.visit(db, self, || { alias .value_type(db) .visit_specialization_impl(db, polarity, f, visitor); @@ -2377,14 +2379,14 @@ impl<'db> Type<'db> { f(parameter.annotated_type(), variance); - visitor.visit(parameter.annotated_type(), || { + visitor.visit(db, parameter.annotated_type(), || { parameter .annotated_type() .visit_specialization_impl(db, variance, f, visitor); }); } - visitor.visit(signature.return_ty, || { + visitor.visit(db, signature.return_ty, || { signature .return_ty .visit_specialization_impl(db, polarity, f, visitor); @@ -2405,7 +2407,7 @@ impl<'db> Type<'db> { f(*ty, variance); - visitor.visit(*ty, || { + visitor.visit(db, *ty, || { ty.visit_specialization_impl(db, variance, f, visitor); }); } @@ -6800,16 +6802,11 @@ impl<'db> Type<'db> { )) } _ => { - // Do not call `value_type` here. `value_type` does the specialization internally, so `apply_type_mapping` is - // performed without `visitor` inheritance. In the case of recursive type aliases, this leads to infinite recursion. - // Instead, call `raw_value_type` and perform the specialization after the `visitor` cache has been created. - // // IMPORTANT: All processing must happen inside a single visitor.visit() call so that if we encounter // this same TypeAlias again (e.g., in `type RecursiveT = int | tuple[RecursiveT, ...]`), the visitor // will detect the cycle and return the fallback value. let mapped = visitor.visit(db, self, type_mapping, || { - let value_type = alias.raw_value_type(db).apply_type_mapping_impl(db, type_mapping, tcx, visitor); - alias.apply_function_specialization(db, value_type).apply_type_mapping_impl(db, type_mapping, tcx, visitor) + alias.value_type(db).apply_type_mapping_impl(db, type_mapping, tcx, visitor) }); // If the type mapping does not result in any change to this type alias, keep the @@ -6960,12 +6957,12 @@ impl<'db> Type<'db> { Type::Divergent(_) => {} Type::FunctionLiteral(function) => { - visitor.visit(self, || { + visitor.visit(db, self, || { function.find_legacy_typevars_impl(db, binding_context, typevars, visitor); }); } - Type::BoundMethod(method) => visitor.visit(self, || { + Type::BoundMethod(method) => visitor.visit(db, self, || { method.self_instance(db).find_legacy_typevars_impl( db, binding_context, @@ -6983,7 +6980,7 @@ impl<'db> Type<'db> { Type::KnownBoundMethod( KnownBoundMethodType::FunctionTypeDunderGet(function) | KnownBoundMethodType::FunctionTypeDunderCall(function), - ) => visitor.visit(self, || { + ) => visitor.visit(db, self, || { function.find_legacy_typevars_impl(db, binding_context, typevars, visitor); }), @@ -6991,7 +6988,7 @@ impl<'db> Type<'db> { KnownBoundMethodType::PropertyDunderGet(property) | KnownBoundMethodType::PropertyDunderSet(property) | KnownBoundMethodType::PropertyDunderDelete(property), - ) => visitor.visit(self, || { + ) => visitor.visit(db, self, || { property.find_legacy_typevars_impl(db, binding_context, typevars, visitor); }), @@ -6999,7 +6996,7 @@ impl<'db> Type<'db> { callable.find_legacy_typevars_impl(db, binding_context, typevars, visitor); } - Type::PropertyInstance(property) => visitor.visit(self, || { + Type::PropertyInstance(property) => visitor.visit(db, self, || { property.find_legacy_typevars_impl(db, binding_context, typevars, visitor); }), @@ -7072,7 +7069,7 @@ impl<'db> Type<'db> { } Type::TypeAlias(alias) => { - visitor.visit(self, || { + visitor.visit(db, self, || { alias.value_type(db).find_legacy_typevars_impl( db, binding_context, diff --git a/crates/ty_python_semantic/src/types/bool.rs b/crates/ty_python_semantic/src/types/bool.rs index b9be571ab5..03857f035c 100644 --- a/crates/ty_python_semantic/src/types/bool.rs +++ b/crates/ty_python_semantic/src/types/bool.rs @@ -321,7 +321,7 @@ impl<'db> Type<'db> { LiteralValueTypeKind::Bytes(bytes) => Truthiness::from(!bytes.value(db).is_empty()), }, - Type::TypeAlias(alias) => visitor.visit(*self, || { + Type::TypeAlias(alias) => visitor.visit(db, *self, || { alias .value_type(db) .try_bool_impl(db, allow_short_circuit, visitor) @@ -339,7 +339,7 @@ impl<'db> Type<'db> { /// A [`CycleDetector`] that is used in `try_bool` methods. pub(crate) type TryBoolVisitor<'db> = - CycleDetector, Result>, 3>; + CycleDetector<'db, TryBool, Type<'db>, Result>, 3>; pub(crate) struct TryBool; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/ty_python_semantic/src/types/cyclic.rs b/crates/ty_python_semantic/src/types/cyclic.rs index 25dfdab9d2..eec23fc486 100644 --- a/crates/ty_python_semantic/src/types/cyclic.rs +++ b/crates/ty_python_semantic/src/types/cyclic.rs @@ -22,41 +22,117 @@ use std::cell::RefCell; use std::cmp::Eq; +use std::fmt; use std::hash::Hash; use std::marker::PhantomData; use std::mem; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; +use ty_python_core::definition::Definition; use crate::Db; use crate::types::Type; +use crate::types::function::FunctionLiteral; + +/// The type identity used for recursive checks/transformations. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub enum TypeIdentity<'db> { + FunctionLiteral(FunctionLiteral<'db>), + NewTypeInstance(Definition<'db>), + RecursiveTypeAlias(Definition<'db>), + NonRecursive(Type<'db>), +} + +impl<'db> Type<'db> { + pub(crate) fn to_type_identity(self, db: &'db dyn Db) -> TypeIdentity<'db> { + self.recursive_identity(db) + .unwrap_or(TypeIdentity::NonRecursive(self)) + } + + #[allow(clippy::inline_always)] + #[inline(always)] + pub(crate) fn recursive_identity(self, db: &'db dyn Db) -> Option> { + match self { + // We can create a self-referential function type: e.g. `def f(x: "TypeOf[f]"): reveal_type(x)` + // To avoid the difficulty of equality checking for function types containing this, we simply use `literal` for equality checking. + Type::FunctionLiteral(function) => { + Some(TypeIdentity::FunctionLiteral(function.literal(db))) + } + // Similarly, we can create a self-referential NewType: e.g. `T = NewType("T", list["T"])` + Type::NewTypeInstance(newtype) => { + Some(TypeIdentity::NewTypeInstance(newtype.definition(db))) + } + // Type aliases can be self-referential: e.g. `type RecursiveT = int | tuple[RecursiveT, ...]` + Type::TypeAlias(alias) if alias.is_recursive(db) => { + Some(TypeIdentity::RecursiveTypeAlias(alias.definition(db))) + } + _ => None, + } + } +} + +/// An item that provides the identity used to detect active recursive cycles. +pub trait HasIdentity<'db> { + type Id: PartialEq; + + /// Returns an identity that remains stable while this item is active in a [`CycleDetector`]. + fn to_identity(&self, db: &'db dyn Db) -> Self::Id; +} + +impl<'db> HasIdentity<'db> for Type<'db> { + type Id = TypeIdentity<'db>; + + fn to_identity(&self, db: &'db dyn Db) -> Self::Id { + Type::to_type_identity(*self, db) + } +} + +pub(crate) type PairVisitor<'db, Tag, C> = CycleDetector<'db, Tag, (Type<'db>, Type<'db>), C, 1>; + +impl<'db> HasIdentity<'db> for (Type<'db>, Type<'db>) { + type Id = (TypeIdentity<'db>, TypeIdentity<'db>); + + fn to_identity(&self, db: &'db dyn Db) -> Self::Id { + (self.0.to_type_identity(db), self.1.to_type_identity(db)) + } +} -pub(crate) type PairVisitor<'db, Tag, C> = CycleDetector, Type<'db>), C, 1>; +impl<'db, Context> HasIdentity<'db> for (Type<'db>, Context, Type<'db>) +where + Context: Copy + PartialEq, +{ + type Id = (TypeIdentity<'db>, Context, TypeIdentity<'db>); + + fn to_identity(&self, db: &'db dyn Db) -> Self::Id { + ( + self.0.to_type_identity(db), + self.1, + self.2.to_type_identity(db), + ) + } +} /// `CycleDetector` is temporary, so callers should choose the capacity that keeps observed cycle /// paths inline even when that makes `seen` slightly larger than an `FxIndexSet`. #[derive(Debug)] -pub struct CycleDetector { - /// If the type we're visiting is present in `seen`, it indicates that we've hit a cycle (due - /// to a recursive type); we need to immediately short circuit the whole operation and return - /// the fallback value. That's why we pop items off the end of `seen` after we've visited them. - seen: RefCell>, - - /// Unlike `seen`, this field is a pure performance optimisation (and an essential one). If the - /// type we're trying to normalize is present in `cache`, it doesn't necessarily mean we've hit - /// a cycle: it just means that we've already visited this inner type as part of a bigger call - /// chain we're currently in. Since this cache is just a performance optimisation, it doesn't - /// make sense to pop items off the end of the cache after they've been visited (it would - /// sort-of defeat the point of a cache if we did!) +pub struct CycleDetector<'db, Tag, T: HasIdentity<'db>, R, const INLINE_CAPACITY: usize> { + /// The active recursion stack and the identity of each item. + /// Completed visits are removed from the end of the stack. + seen: RefCell; INLINE_CAPACITY]>>, + + /// Memoized results from earlier visits in the current recursive operation. cache: RefCell>, fallback: R, - _tag: PhantomData Tag>, + _tag: PhantomData &'db Tag>, } -impl CycleDetector { +impl<'db, Tag, T, R, const INLINE_CAPACITY: usize> CycleDetector<'db, Tag, T, R, INLINE_CAPACITY> +where + T: HasIdentity<'db>, +{ pub fn new(fallback: R) -> Self { CycleDetector { seen: RefCell::new(SmallVec::new()), @@ -67,44 +143,104 @@ impl CycleDetector - CycleDetector +impl<'db, Tag, T, R: Clone, const INLINE_CAPACITY: usize> + CycleDetector<'db, Tag, T, R, INLINE_CAPACITY> +where + T: Hash + Eq + Clone + HasIdentity<'db>, { #[inline] - pub fn visit(&self, item: T, compute: impl FnOnce() -> R) -> R { - match self.begin_visit(item) { - BeginVisit::Ready(result) => result, - BeginVisit::Pending(item) => { + pub fn visit(&self, db: &'db dyn Db, item: T, compute: impl FnOnce() -> R) -> R { + match self.begin_visit(db, item) { + CycleDetectorVisit::Ready(result) => result, + CycleDetectorVisit::Cycle(_) => self.fallback.clone(), + CycleDetectorVisit::Pending(item) => { let result = compute(); self.finish_visit(item, result) } } } - fn begin_visit(&self, item: T) -> BeginVisit { + /// Visits `item`, returning it in `Err` if another active item has the same identity. + /// + /// The caller must convert `Err(item)` into an operation-specific conservative result. An + /// exact recursive reentry uses the detector's configured fallback and is returned as `Ok`. + #[inline] + pub(super) fn try_visit( + &self, + db: &'db dyn Db, + item: T, + compute: impl FnOnce() -> R, + ) -> Result { + match self.begin_visit(db, item) { + CycleDetectorVisit::Ready(result) => Ok(result), + CycleDetectorVisit::Cycle(item) => Err(item), + CycleDetectorVisit::Pending(item) => { + let result = compute(); + Ok(self.finish_visit(item, result)) + } + } + } + + fn begin_visit(&self, db: &'db dyn Db, item: T) -> CycleDetectorVisit { if let Some(result) = self.cache.borrow().get(&item) { - return BeginVisit::Ready(result.clone()); + return CycleDetectorVisit::Ready(result.clone()); + } + + let seen = self.seen.borrow(); + if seen.iter().any(|active| active.item == item) { + return CycleDetectorVisit::Ready(self.fallback.clone()); } - if self.seen.borrow().contains(&item) { - return BeginVisit::Ready(self.fallback.clone()); + let identity = item.to_identity(db); + if seen.iter().any(|active| active.identity == identity) { + return CycleDetectorVisit::Cycle(item); } + drop(seen); - self.seen.borrow_mut().push(item.clone()); - BeginVisit::Pending(item) + self.seen.borrow_mut().push(ActiveCycleDetectorVisit { + item: item.clone(), + identity, + }); + CycleDetectorVisit::Pending(item) } + /// Finish a [`CycleDetectorVisit::Pending`] visit and cache its result. fn finish_visit(&self, item: T, result: R) -> R { - self.seen.borrow_mut().pop(); - self.cache.borrow_mut().insert_new(item, result.clone()); + let active = self.seen.borrow_mut().pop(); + debug_assert!(active.as_ref().is_some_and(|active| active.item == item)); + self.cache + .borrow_mut() + .insert_completed(item, result.clone()); result } } +struct ActiveCycleDetectorVisit<'db, T: HasIdentity<'db>> { + item: T, + identity: T::Id, +} + +impl<'db, T: fmt::Debug + HasIdentity<'db>> fmt::Debug for ActiveCycleDetectorVisit<'db, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.item.fmt(f) + } +} + +/// Result of starting a cycle-detector visit. +pub(super) enum CycleDetectorVisit { + /// The item already has a completed result or hit an exact recursive edge. + Ready(R), + /// A different item with the same abstract identity is already pending. + Cycle(T), + /// The caller should compute the result and finish the pending visit. + Pending(T), +} + +/// Guards recursive type transformations. pub(crate) struct TypeTransformer<'db, Tag> { - /// A type already present in `seen` forms a recursive cycle and is returned unchanged. + /// The active transformation stack and its recursive identities. /// Completed visits are removed from the end of the stack. - seen: RefCell; 3]>>, + seen: RefCell; 3]>>, /// Memoized transformations from earlier visits in the current recursive operation. cache: RefCell, Type<'db>>>, @@ -131,63 +267,58 @@ impl<'db, Tag> TypeTransformer<'db, Tag> { compute: impl FnOnce() -> Type<'db>, ) -> Type<'db> { match self.begin_visit(db, ty) { - BeginVisit::Ready(result) => result, - BeginVisit::Pending(ty) => { + TypeTransformerVisit::Ready(result) => result, + TypeTransformerVisit::Pending(ty) => { let result = compute(); self.finish_visit(ty, result) } } } - fn begin_visit(&self, db: &'db dyn Db, ty: Type<'db>) -> BeginVisit, Type<'db>> { + fn begin_visit(&self, db: &'db dyn Db, ty: Type<'db>) -> TypeTransformerVisit<'db> { if let Some(result) = self.cache.borrow().get(&ty) { - return BeginVisit::Ready(*result); + return TypeTransformerVisit::Ready(*result); } - if self - .seen - .borrow() + let identity = ty.to_type_identity(db); + let seen = self.seen.borrow(); + if seen .iter() - .any(|seen_type| *seen_type == ty || Self::same_type_identity(db, *seen_type, ty)) + .any(|active| active.ty == ty || active.identity == identity) { - // When a cycle is encountered, the type being visited is returned as a fallback - // (typically a recursive type alias). - return BeginVisit::Ready(ty); + return TypeTransformerVisit::Ready(ty); } + drop(seen); - self.seen.borrow_mut().push(ty); - BeginVisit::Pending(ty) + self.seen + .borrow_mut() + .push(ActiveTypeTransformation { ty, identity }); + TypeTransformerVisit::Pending(ty) } fn finish_visit(&self, ty: Type<'db>, result: Type<'db>) -> Type<'db> { - self.seen.borrow_mut().pop(); - self.cache.borrow_mut().insert_new(ty, result); + let active = self.seen.borrow_mut().pop(); + debug_assert_eq!(active.map(|active| active.ty), Some(ty)); + self.cache.borrow_mut().insert_completed(ty, result); result } +} - fn same_type_identity(db: &'db dyn Db, left: Type<'db>, right: Type<'db>) -> bool { - match (left, right) { - // We can create a self-referential function type: e.g. `def f(x: "TypeOf[f]"): reveal_type(x)` - // To avoid the difficulty of equality checking for function types containing this, we simply use `literal` for equality checking. - (Type::FunctionLiteral(left), Type::FunctionLiteral(right)) => { - left.literal(db) == right.literal(db) - } - // Similarly, we can create a self-referential NewType: e.g. `T = NewType("T", list["T"])` - (Type::NewTypeInstance(left), Type::NewTypeInstance(right)) => { - left.definition(db) == right.definition(db) - } - _ => false, - } - } +#[derive(Debug, Clone, Copy)] +struct ActiveTypeTransformation<'db> { + ty: Type<'db>, + identity: TypeIdentity<'db>, } -enum BeginVisit { - Ready(R), - Pending(T), +enum TypeTransformerVisit<'db> { + Ready(Type<'db>), + Pending(Type<'db>), } -impl Default - for CycleDetector +impl<'db, Tag, T, R: Default, const INLINE_CAPACITY: usize> Default + for CycleDetector<'db, Tag, T, R, INLINE_CAPACITY> +where + T: HasIdentity<'db>, { fn default() -> Self { CycleDetector::new(R::default()) @@ -226,12 +357,19 @@ impl CycleDetectorCache { } } - /// Inserts a result after the caller has checked that `item` is not already cached. - fn insert_new(&mut self, item: T, result: R) + /// Inserts a completed item after the caller has checked that `item` is not already cached. + fn insert_completed(&mut self, item: T, result: R) where T: Hash + Eq, { debug_assert!(self.get(&item).is_none()); + self.insert_new(item, result); + } + + fn insert_new(&mut self, item: T, result: R) + where + T: Hash + Eq, + { let entry = (item, result); *self = match mem::replace(self, Self::Empty) { Self::Empty => Self::One(entry), @@ -311,30 +449,119 @@ impl Drop for ActiveRecursionGuard<'_, T> { #[cfg(test)] mod tests { - use super::CycleDetector; + use super::{CycleDetector, CycleDetectorVisit, Db, HasIdentity}; + use crate::db::tests::setup_db; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TestVisit; + + type Detector<'db> = CycleDetector<'db, TestVisit, u8, u8, 1>; + + impl<'db> HasIdentity<'db> for u8 { + type Id = Self; - struct TestCycleDetector; - type Detector = CycleDetector; + fn to_identity(&self, _db: &'db dyn Db) -> Self::Id { + *self + } + } + + static IDENTITY_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[derive(Clone, Eq, Hash, PartialEq)] + struct CountingIdentityItem(u8); + + impl<'db> HasIdentity<'db> for CountingIdentityItem { + type Id = u8; + + fn to_identity(&self, _db: &'db dyn Db) -> Self::Id { + IDENTITY_CALLS.fetch_add(1, Ordering::Relaxed); + self.0 + } + } + + #[derive(Clone, Eq, Hash, PartialEq)] + struct ConstantIdentityItem(u8); + + impl<'db> HasIdentity<'db> for ConstantIdentityItem { + type Id = (); + + fn to_identity(&self, _db: &'db dyn Db) -> Self::Id {} + } #[test] fn caches_results_and_spills_after_two_entries() { + let db = setup_db(); let detector = Detector::new(0); - assert_eq!(detector.visit(1, || 10), 10); - assert_eq!(detector.visit(1, || 40), 10); - assert_eq!(detector.visit(2, || 20), 20); + assert_eq!(detector.visit(&db, 1, || 10), 10); + assert_eq!(detector.visit(&db, 1, || 40), 10); + assert_eq!(detector.visit(&db, 2, || 20), 20); assert!(!detector.cache.borrow().is_spilled()); - assert_eq!(detector.visit(3, || 30), 30); + assert_eq!(detector.visit(&db, 3, || 30), 30); assert!(detector.cache.borrow().is_spilled()); - assert_eq!(detector.visit(2, || 40), 20); - assert_eq!(detector.visit(3, || 40), 30); + assert_eq!(detector.visit(&db, 2, || 40), 20); + assert_eq!(detector.visit(&db, 3, || 40), 30); } #[test] fn nested_visit_short_circuits_on_cycle() { + let db = setup_db(); let detector = Detector::new(0); - assert_eq!(detector.visit(1, || detector.visit(1, || 20) + 10), 10); + assert_eq!( + detector.visit(&db, 1, || detector.visit(&db, 1, || 20) + 10), + 10 + ); + } + + #[test] + fn computes_each_active_identity_once() { + let db = setup_db(); + IDENTITY_CALLS.store(0, Ordering::Relaxed); + let detector = CycleDetector::::new(0); + + assert_eq!( + detector.visit(&db, CountingIdentityItem(1), || { + detector.visit(&db, CountingIdentityItem(2), || 1) + }), + 1 + ); + assert_eq!(IDENTITY_CALLS.load(Ordering::Relaxed), 2); + } + + #[test] + fn different_items_with_same_identity_form_cycle() { + let db = setup_db(); + let detector = CycleDetector::::new(0); + + let CycleDetectorVisit::Pending(pending) = + detector.begin_visit(&db, ConstantIdentityItem(1)) + else { + panic!("the first identity should be pending"); + }; + let CycleDetectorVisit::Cycle(item) = detector.begin_visit(&db, ConstantIdentityItem(2)) + else { + panic!("a different item with the same identity should form a cycle"); + }; + assert_eq!(item.0, 2); + detector.finish_visit(pending, 1); + + let CycleDetectorVisit::Ready(seen) = detector.begin_visit(&db, ConstantIdentityItem(1)) + else { + panic!("the first identity should be ready after the pending visit is finished"); + }; + assert_eq!(seen, 1); + let CycleDetectorVisit::Pending(pending) = + detector.begin_visit(&db, ConstantIdentityItem(2)) + else { + panic!("the second identity should be pending after the first is finished"); + }; + detector.finish_visit(pending, 2); + let CycleDetectorVisit::Ready(seen) = detector.begin_visit(&db, ConstantIdentityItem(2)) + else { + panic!("the second identity should be ready after the pending visit is finished"); + }; + assert_eq!(seen, 2); } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index 8c8489fd87..abb6c7df33 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs @@ -23,7 +23,7 @@ enum BinaryExpressionOperandTypes<'db> { } type BinaryExpressionVisitor<'db> = - CycleDetector, ast::Operator, Type<'db>), Option>, 1>; + CycleDetector<'db, ast::Operator, (Type<'db>, ast::Operator, Type<'db>), Option>, 1>; impl<'db> TypeInferenceBuilder<'db, '_> { pub(super) fn infer_binary_expression( @@ -343,7 +343,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ) }), - (Type::TypeAlias(alias), rhs, _) => visitor.visit((left_ty, op, right_ty), || { + (Type::TypeAlias(alias), rhs, _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, emitted_division_by_zero_diagnostic, @@ -354,7 +354,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ) }), - (lhs, Type::TypeAlias(alias), _) => visitor.visit((left_ty, op, right_ty), || { + (lhs, Type::TypeAlias(alias), _) => visitor.visit(db, (left_ty, op, right_ty), || { self.infer_binary_expression_type_impl( node, emitted_division_by_zero_diagnostic, diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 662edd2fab..cfc984a1e1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -64,7 +64,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { pub(super) fn typed_dict_key_expected_type(&self, ty: Type<'db>) -> Option> { struct TypedDictKeyExpectedType; type TypedDictKeyExpectedTypeVisitor<'db> = - CycleDetector, Option>, 3>; + CycleDetector<'db, TypedDictKeyExpectedType, Type<'db>, Option>, 3>; fn imp<'db>( db: &'db dyn Db, @@ -100,7 +100,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { (!keys.is_empty()).then(|| UnionType::from_elements(db, keys)) } Type::TypeAlias(alias) => { - visitor.visit(ty, || imp(db, alias.value_type(db), visitor)) + visitor.visit(db, ty, || imp(db, alias.value_type(db), visitor)) } _ => None, } diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs index a68089ffa1..57157e9ef7 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_form.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_form.rs @@ -70,7 +70,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { fn contains_type_form_value(&self, expression: &ast::Expr, ty: Type<'db>) -> bool { struct ContainsTypeFormValue; type ContainsTypeFormValueVisitor<'db> = - CycleDetector, bool, 3>; + CycleDetector<'db, ContainsTypeFormValue, Type<'db>, bool, 3>; fn imp<'db>( builder: &TypeInferenceBuilder<'db, '_>, @@ -99,10 +99,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { Type::Intersection(intersection) => intersection .iter_positive(builder.db()) .any(|element| imp(builder, expression, element, visitor)), - Type::TypeAlias(alias) => visitor.visit(ty, || { + Type::TypeAlias(alias) => visitor.visit(builder.db(), ty, || { imp(builder, expression, alias.value_type(builder.db()), visitor) }), - Type::TypeVar(typevar) => visitor.visit(ty, || { + Type::TypeVar(typevar) => visitor.visit(builder.db(), ty, || { typevar .typevar(builder.db()) .bound_or_constraints(builder.db()) diff --git a/crates/ty_python_semantic/src/types/infer/comparisons.rs b/crates/ty_python_semantic/src/types/infer/comparisons.rs index f507d2e00d..f899fb1681 100644 --- a/crates/ty_python_semantic/src/types/infer/comparisons.rs +++ b/crates/ty_python_semantic/src/types/infer/comparisons.rs @@ -25,6 +25,7 @@ enum IntersectionOn { /// A [`CycleDetector`] that is used in [`infer_binary_type_comparison`]. pub(super) type BinaryComparisonVisitor<'db> = CycleDetector< + 'db, ast::CmpOp, (Type<'db>, ast::CmpOp, Type<'db>), Result, UnsupportedComparisonError<'db>>, @@ -279,11 +280,11 @@ pub(super) fn infer_binary_type_comparison<'db>( ) } - (Type::TypeAlias(alias), right) => Some(visitor.visit((left, op, right), || { + (Type::TypeAlias(alias), right) => Some(visitor.visit(db, (left, op, right), || { infer_binary_type_comparison(context, alias.value_type(db), op, right, range, visitor) })), - (left, Type::TypeAlias(alias)) => Some(visitor.visit((left, op, right), || { + (left, Type::TypeAlias(alias)) => Some(visitor.visit(db, (left, op, right), || { infer_binary_type_comparison(context, left, op, alias.value_type(db), range, visitor) })), @@ -295,7 +296,7 @@ pub(super) fn infer_binary_type_comparison<'db>( // type, so that it hits the `Type::Union` branches above. (Type::NewTypeInstance(newtype), right) => Some( try_dunder(MemberLookupPolicy::default()).or_else(|_| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_binary_type_comparison( context, newtype.concrete_base_type(db), @@ -309,7 +310,7 @@ pub(super) fn infer_binary_type_comparison<'db>( ), (left, Type::NewTypeInstance(newtype)) => Some( try_dunder(MemberLookupPolicy::default()).or_else(|_| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_binary_type_comparison( context, left, @@ -333,7 +334,7 @@ pub(super) fn infer_binary_type_comparison<'db>( match left_tvar.typevar(db).bound_or_constraints(db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some( try_dunder(MemberLookupPolicy::default()).or_else(|_| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_binary_type_comparison( context, bound, op, bound, range, visitor, ) @@ -359,7 +360,7 @@ pub(super) fn infer_binary_type_comparison<'db>( match left_tvar.typevar(db).bound_or_constraints(db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some( try_dunder(MemberLookupPolicy::default()).or_else(|_| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_binary_type_comparison( context, bound, op, right, range, visitor, ) @@ -384,7 +385,7 @@ pub(super) fn infer_binary_type_comparison<'db>( match right_tvar.typevar(db).bound_or_constraints(db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some( try_dunder(MemberLookupPolicy::default()).or_else(|_| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_binary_type_comparison( context, left, op, bound, range, visitor, ) @@ -608,7 +609,7 @@ pub(super) fn infer_binary_type_comparison<'db>( .and_then(|lhs_tuple| Some((lhs_tuple, nominal2.tuple_spec(db)?))) .map(|(lhs_tuple, rhs_tuple)| { let tuple_rich_comparison = |rich_op| { - visitor.visit((left, op, right), || { + visitor.visit(db, (left, op, right), || { infer_tuple_rich_comparison( context, &lhs_tuple, rich_op, &rhs_tuple, range, visitor, ) diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 9532e9e3fd..1dff1e283e 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -9,7 +9,7 @@ use crate::types::constraints::{ ConstraintSetBuilder, IteratorConstraintsExtension, OptionConstraintsExtension, OwnedConstraintSet, }; -use crate::types::cyclic::PairVisitor; +use crate::types::cyclic::{HasIdentity, PairVisitor, TypeIdentity}; use crate::types::enums::is_single_member_enum; use crate::types::function::FunctionDecorators; use crate::types::set_theoretic::RecursivelyDefined; @@ -713,14 +713,33 @@ impl<'db> Type<'db> { } } -/// A [`PairVisitor`] that is used in `has_relation_to` methods. +/// A [`CycleDetector`] that is used in `has_relation_to` methods. pub(crate) type HasRelationToVisitor<'db, 'c> = CycleDetector< + 'db, TypeRelation, (Type<'db>, Type<'db>, TypeRelation, TypeVarEvaluation), ConstraintSet<'db, 'c>, 1, >; +impl<'db> HasIdentity<'db> for (Type<'db>, Type<'db>, TypeRelation, TypeVarEvaluation) { + type Id = ( + TypeIdentity<'db>, + TypeIdentity<'db>, + TypeRelation, + TypeVarEvaluation, + ); + + fn to_identity(&self, db: &'db dyn Db) -> Self::Id { + ( + self.0.to_type_identity(db), + self.1.to_type_identity(db), + self.2, + self.3, + ) + } +} + impl<'db, 'c> HasRelationToVisitor<'db, 'c> { pub(crate) fn default(constraints: &'c ConstraintSetBuilder<'db>) -> Self { HasRelationToVisitor::new(ConstraintSet::from_bool(constraints, true)) @@ -930,14 +949,36 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { fn with_recursion_guard( &self, + db: &'db dyn Db, source: Type<'db>, target: Type<'db>, work: impl FnOnce() -> ConstraintSet<'db, 'c>, ) -> ConstraintSet<'db, 'c> { - self.relation_visitor.visit( - (source, target, self.relation, self.typevar_evaluation), - work, - ) + self.relation_visitor + .try_visit( + db, + (source, target, self.relation, self.typevar_evaluation), + work, + ) + .unwrap_or_else(|item| self.recursive_type_pair_fallback(item.0, item.1)) + } + + fn recursive_type_pair_fallback( + &self, + source: Type<'db>, + target: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if matches!((source, target), (Type::TypeAlias(_), Type::TypeAlias(_))) { + // TODO: Recursive aliases can encode context-free languages, whose inclusion and + // equivalence are undecidable. No complete fallback exists, but more decidable cases + // can be recognized here before conservatively rejecting the pair. + return self.never(); + } + + // Mixed recursive cycles (for example, alias vs. protocol) keep the existing coinductive + // fallback. Alias pairs are rejected above instead of generating another recursive + // obligation. + self.always() } /// Is `target` a metaclass instance (a nominal instance of a subclass of `builtins.type`)? @@ -1140,25 +1181,29 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.always() } - (Type::TypeAlias(source_alias), _) => self.with_recursion_guard(source, target, || { - self.check_type_pair(db, source_alias.value_type(db), target) - }), + (Type::TypeAlias(source_alias), _) => { + self.with_recursion_guard(db, source, target, || { + self.check_type_pair(db, source_alias.value_type(db), target) + }) + } - (_, Type::TypeAlias(target_alias)) => self.with_recursion_guard(source, target, || { - self.check_type_pair(db, source, target_alias.value_type(db)) - }), + (_, Type::TypeAlias(target_alias)) => { + self.with_recursion_guard(db, source, target, || { + self.check_type_pair(db, source, target_alias.value_type(db)) + }) + } // Annotation unions retain type aliases so recursive aliases can be represented. // Normalize direct alias elements together before checking the union so reductions // that depend on multiple elements, such as all members of an enum, are visible. (_, Type::Union(union)) if union.has_aliases(db) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_type_pair(db, source, union.expand_aliases(db)) }) } (Type::TypeForm(source_typeform), Type::TypeForm(target_typeform)) => self - .with_recursion_guard(source, target, || { + .with_recursion_guard(db, source, target, || { self.check_type_pair( db, source_typeform.type_argument(db), @@ -1269,7 +1314,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { | ( Type::KnownInstance(KnownInstanceType::FunctoolsPartialCall(source_partial)), Type::KnownInstance(KnownInstanceType::FunctoolsPartialCall(target_partial)), - ) => self.with_recursion_guard(source, target, || { + ) => self.with_recursion_guard(db, source, target, || { self.check_callable_pair(db, source_partial.partial(db), target_partial.partial(db)) }), @@ -1780,7 +1825,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { } // Currently, the only supertype of `AlwaysFalsy` and `AlwaysTruthy` is the universal set (object instance). (Type::AlwaysFalsy | Type::AlwaysTruthy, _) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_type_pair(db, Type::object(), target) }) } @@ -1800,7 +1845,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { ), Type::FunctionLiteral(target_function), ) if matches!(self.relation, TypeRelation::Assignability) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_callable_signature_pair( db, source_partial.partial(db).signatures(db), @@ -1853,7 +1898,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { ) => self.never(), (Type::Callable(source_callable), Type::Callable(target_callable)) => self - .with_recursion_guard(source, target, || { + .with_recursion_guard(db, source, target, || { self.check_callable_pair(db, source_callable, target_callable) }), @@ -1861,13 +1906,13 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { Type::Callable(source_callable), Type::KnownInstance(KnownInstanceType::FunctoolsPartialCall(target_partial)), ) if self.relation.is_assignability() => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_callable_pair(db, source_callable, target_partial.partial(db)) }) } (_, Type::Callable(target_callable)) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { let Some(callables) = source .try_upcast_to_callable_with_policy(db, UpcastPolicy::from(self.relation)) else { @@ -1904,7 +1949,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { } (_, Type::ProtocolInstance(target_proto)) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_type_satisfies_protocol(db, source, target_proto) }) } @@ -1913,36 +1958,40 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { (Type::ProtocolInstance(_), _) => self.never(), (Type::TypedDict(source_td), Type::TypedDict(target_td)) => { - self.with_recursion_guard(source, target, || { + self.with_recursion_guard(db, source, target, || { self.check_typeddict_pair(db, source_td, target_td) }) } - (Type::TypedDict(typed_dict), _) => self.with_recursion_guard(source, target, || { - let dict_value_type = if self.relation.is_assignability() { - typed_dict.assignable_dict_value_type(db) - } else { - typed_dict.dict_value_type(db) - }; - let fallback = if let Some(value_ty) = dict_value_type { - KnownClass::Dict - .to_specialized_instance(db, &[KnownClass::Str.to_instance(db), value_ty]) - } else { - KnownClass::Mapping.to_specialized_instance( - db, - &[KnownClass::Str.to_instance(db), typed_dict.value_type(db)], - ) - }; - let result = self.check_type_pair(db, fallback, target); - if let Some(context) = self.report_context() - && result.is_never_satisfied(db) - && let Type::NominalInstance(instance) = target - && instance.class(db).is_known(db, KnownClass::Dict) - { - context.push(ErrorContext::TypedDictNotAssignableToDict(typed_dict)); - } - result - }), + (Type::TypedDict(typed_dict), _) => { + self.with_recursion_guard(db, source, target, || { + let dict_value_type = if self.relation.is_assignability() { + typed_dict.assignable_dict_value_type(db) + } else { + typed_dict.dict_value_type(db) + }; + let fallback = if let Some(value_ty) = dict_value_type { + KnownClass::Dict.to_specialized_instance( + db, + &[KnownClass::Str.to_instance(db), value_ty], + ) + } else { + KnownClass::Mapping.to_specialized_instance( + db, + &[KnownClass::Str.to_instance(db), typed_dict.value_type(db)], + ) + }; + let result = self.check_type_pair(db, fallback, target); + if let Some(context) = self.report_context() + && result.is_never_satisfied(db) + && let Type::NominalInstance(instance) = target + && instance.class(db).is_known(db, KnownClass::Dict) + { + context.push(ErrorContext::TypedDictNotAssignableToDict(typed_dict)); + } + result + }) + } // A non-`TypedDict` cannot subtype a `TypedDict` (_, Type::TypedDict(_)) => self.never(), @@ -2262,12 +2311,12 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { // `bool` is a subtype of `int`, because `bool` subclasses `int`, // which means that all instances of `bool` are also instances of `int` (Type::NominalInstance(source_i), Type::NominalInstance(target_i)) => self - .with_recursion_guard(source, target, || { + .with_recursion_guard(db, source, target, || { self.check_nominal_instance_pair(db, source_i, target_i) }), (Type::PropertyInstance(source_p), Type::PropertyInstance(target_p)) => self - .with_recursion_guard(source, target, || { + .with_recursion_guard(db, source, target, || { self.check_property_instance_pair(db, source_p, target_p) }), @@ -2499,11 +2548,12 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { fn with_recursion_guard( &self, + db: &'db dyn Db, source: Type<'db>, target: Type<'db>, work: impl FnOnce() -> ConstraintSet<'db, 'c>, ) -> ConstraintSet<'db, 'c> { - self.disjointness_visitor.visit((source, target), work) + self.disjointness_visitor.visit(db, (source, target), work) } fn any_protocol_members_absent_or_disjoint( @@ -2551,7 +2601,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { intersection: IntersectionType<'db>, other: Type<'db>, ) -> ConstraintSet<'db, 'c> { - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { intersection .positive(db) .iter() @@ -2593,14 +2643,14 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::TypeAlias(alias), _) => { let left_alias_ty = alias.value_type(db); - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { self.check_type_pair(db, left_alias_ty, right) }) } (_, Type::TypeAlias(alias)) => { let right_alias_ty = alias.value_type(db); - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { self.check_type_pair(db, left, right_alias_ty) }) } @@ -2697,7 +2747,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { } else if let Some(alternatives) = right_intersection.finite_alternative_union(db) { self.check_type_pair(db, left, alternatives) } else { - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { left_intersection .positive(db) .iter() @@ -2825,13 +2875,13 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { } (Type::ProtocolInstance(left_proto), Type::ProtocolInstance(right_proto)) => self - .with_recursion_guard(left, right, || { + .with_recursion_guard(db, left, right, || { self.check_protocol_instance_pair(db, left_proto, right_proto) }), (Type::ProtocolInstance(protocol), Type::SpecialForm(special_form)) | (Type::SpecialForm(special_form), Type::ProtocolInstance(protocol)) => self - .with_recursion_guard(left, right, || { + .with_recursion_guard(db, left, right, || { self.any_protocol_members_absent_or_disjoint( db, protocol, @@ -2841,7 +2891,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::ProtocolInstance(protocol), Type::KnownInstance(known_instance)) | (Type::KnownInstance(known_instance), Type::ProtocolInstance(protocol)) => self - .with_recursion_guard(left, right, || { + .with_recursion_guard(db, left, right, || { self.any_protocol_members_absent_or_disjoint( db, protocol, @@ -2891,7 +2941,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | Type::FunctionLiteral(..) | Type::ModuleLiteral(..) | Type::GenericAlias(..)), - ) => self.with_recursion_guard(left, right, || { + ) => self.with_recursion_guard(db, left, right, || { self.any_protocol_members_absent_or_disjoint(db, protocol, ty) }), @@ -2902,7 +2952,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | (Type::NominalInstance(nominal), Type::ProtocolInstance(protocol)) if nominal.class(db).is_final(db) => { - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { self.any_protocol_members_absent_or_disjoint( db, protocol, @@ -2913,7 +2963,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::ProtocolInstance(protocol), other) | (other, Type::ProtocolInstance(protocol)) => { - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { protocol .interface(db) .members(db) @@ -3224,7 +3274,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { } (Type::NominalInstance(left_i), Type::NominalInstance(right_i)) => self - .with_recursion_guard(left, right, || { + .with_recursion_guard(db, left, right, || { self.check_nominal_instance_pair(db, left_i, right_i) }), @@ -3254,7 +3304,7 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { (Type::GenericAlias(_), _) | (_, Type::GenericAlias(_)) => self.always(), (Type::TypedDict(left_td), Type::TypedDict(right_td)) => { - self.with_recursion_guard(left, right, || { + self.with_recursion_guard(db, left, right, || { self.check_typeddict_pair(db, left_td, right_td) }) } diff --git a/crates/ty_python_semantic/src/types/type_alias.rs b/crates/ty_python_semantic/src/types/type_alias.rs index b8bac8c35b..99009e459f 100644 --- a/crates/ty_python_semantic/src/types/type_alias.rs +++ b/crates/ty_python_semantic/src/types/type_alias.rs @@ -1,4 +1,4 @@ -use std::fmt::Write; +use std::{cell::Cell, fmt::Write}; use crate::{ Db, @@ -8,7 +8,7 @@ use crate::{ display::qualified_name_components_from_scope, generics::{ApplySpecialization, Specialization}, variance::VarianceInferable, - visitor, + visitor::{self, TypeKind, TypeVisitor, walk_non_atomic_type}, }, }; use ty_python_core::{ @@ -232,6 +232,7 @@ pub(super) fn walk_type_alias_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( } } +#[salsa::tracked] impl<'db> TypeAliasType<'db> { pub(crate) fn name(self, db: &'db dyn Db) -> &'db str { match self { @@ -261,6 +262,41 @@ impl<'db> TypeAliasType<'db> { } } + /// Returns whether this alias participates in a recursive alias definition. + pub(crate) fn is_recursive(self, db: &'db dyn Db) -> bool { + self.unspecialized(db) + .references_alias(db, self.definition(db)) + } + + fn unspecialized(self, db: &'db dyn Db) -> Self { + match self { + TypeAliasType::PEP695(alias) => TypeAliasType::PEP695(PEP695TypeAliasType::new( + db, + alias.name(db), + alias.rhs_scope(db), + None, + )), + TypeAliasType::ManualPEP695(_) => self, + } + } + + /// Returns whether this alias's value references `target` through named aliases. + /// + /// `false` seeds the least fixed point of alias reachability. + #[salsa::tracked( + returns(copy), + cycle_initial=|_, _, _, _| false, + heap_size=ruff_memory_usage::heap_size, + )] + fn references_alias(self, db: &'db dyn Db, target: Definition<'db>) -> bool { + let visitor = AliasReferenceVisitor { + target, + found: Cell::new(false), + }; + visitor.visit_type(db, self.raw_value_type(db)); + visitor.found.get() + } + pub(crate) fn as_pep_695_type_alias(self) -> Option> { match self { TypeAliasType::PEP695(type_alias) => Some(type_alias), @@ -283,13 +319,6 @@ impl<'db> TypeAliasType<'db> { } } - pub(super) fn apply_function_specialization(self, db: &'db dyn Db, ty: Type<'db>) -> Type<'db> { - match self { - TypeAliasType::PEP695(type_alias) => type_alias.apply_function_specialization(db, ty), - TypeAliasType::ManualPEP695(_) => ty, - } - } - pub(crate) fn apply_specialization( self, db: &'db dyn Db, @@ -309,6 +338,45 @@ impl<'db> TypeAliasType<'db> { } } +struct AliasReferenceVisitor<'db> { + target: Definition<'db>, + found: Cell, +} + +impl<'db> TypeVisitor<'db> for AliasReferenceVisitor<'db> { + fn should_visit_lazy_type_attributes(&self) -> bool { + false + } + + fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) { + if self.found.get() { + return; + } + + if let Type::TypeAlias(alias) = ty { + if alias.definition(db) == self.target { + self.found.set(true); + return; + } + + if let Some(specialization) = alias.specialization(db) { + for ty in specialization.types(db) { + self.visit_type(db, *ty); + } + } + + if !self.found.get() && alias.unspecialized(db).references_alias(db, self.target) { + self.found.set(true); + } + return; + } + + if let TypeKind::NonAtomic(non_atomic) = TypeKind::from(ty) { + walk_non_atomic_type(db, non_atomic, self); + } + } +} + #[salsa::tracked] impl<'db> VarianceInferable<'db> for TypeAliasType<'db> { #[salsa::tracked( @@ -317,7 +385,35 @@ impl<'db> VarianceInferable<'db> for TypeAliasType<'db> { heap_size=ruff_memory_usage::heap_size )] fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarIdentity<'db>) -> TypeVarVariance { - self.value_type(db).variance_of(db, typevar) + let Some(generic_context) = self.generic_context(db) else { + return self.value_type(db).variance_of(db, typevar); + }; + + // Infer an alias's own type-parameter variance from the raw RHS. Applying specialization + // here would recursively request the same `variance_of` query. + if generic_context + .variables(db) + .any(|alias_typevar| alias_typevar.identity(db) == typevar) + { + return self.raw_value_type(db).variance_of(db, typevar); + } + + let raw_value_type = self.raw_value_type(db); + let specialization = self + .specialization(db) + .unwrap_or_else(|| generic_context.default_specialization(db, None)); + + // For external typevars, variance flows through the specialization arguments. Expanding + // the specialized alias body here can create ever-larger recursive alias applications. + generic_context + .variables(db) + .zip(specialization.types(db)) + .map(|(alias_typevar, argument_ty)| { + raw_value_type + .variance_of(db, alias_typevar.identity(db)) + .compose_thunk(|| argument_ty.variance_of(db, typevar)) + }) + .collect() } } diff --git a/crates/ty_python_semantic/src/types/type_form.rs b/crates/ty_python_semantic/src/types/type_form.rs index 356f977d5b..6d817b329e 100644 --- a/crates/ty_python_semantic/src/types/type_form.rs +++ b/crates/ty_python_semantic/src/types/type_form.rs @@ -39,7 +39,7 @@ impl<'db> Type<'db> { pub(crate) fn project_type_form(self, db: &'db dyn Db) -> Type<'db> { struct TypeFormArgument; type TypeFormArgumentVisitor<'db> = - CycleDetector, Option>, 3>; + CycleDetector<'db, TypeFormArgument, Type<'db>, Option>, 3>; fn project<'db>( db: &'db dyn Db, @@ -49,7 +49,7 @@ impl<'db> Type<'db> { match ty { Type::TypeForm(type_form) => Some(type_form.type_argument(db)), Type::TypeAlias(alias) => { - visitor.visit(ty, || project(db, alias.value_type(db), visitor)) + visitor.visit(db, ty, || project(db, alias.value_type(db), visitor)) } Type::Union(union) => { let mut elements = union @@ -68,7 +68,7 @@ impl<'db> Type<'db> { elements.peek()?; Some(IntersectionType::from_elements(db, elements)) } - Type::TypeVar(typevar) => visitor.visit(ty, || { + Type::TypeVar(typevar) => visitor.visit(db, ty, || { typevar .typevar(db) .bound_or_constraints(db) diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 4006703e0b..1de2606bb1 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -282,7 +282,7 @@ impl<'db> TypeVarInstance<'db> { db: &'db dyn Db, visitor: &TypeVarDefaultVisitor<'db>, ) -> Option> { - visitor.visit(self, || { + visitor.visit(db, self, || { self._default(db).and_then(|default| match default { TypeVarDefaultEvaluation::Eager(ty) => Some(ty), TypeVarDefaultEvaluation::Lazy => self.lazy_default_impl(db, visitor), @@ -1812,5 +1812,13 @@ impl<'db> TypeVarBoundOrConstraints<'db> { /// A [`CycleDetector`] that is used in `TypeVarInstance::default_type`. pub(crate) type TypeVarDefaultVisitor<'db> = - CycleDetector, Option>, 6>; + CycleDetector<'db, VisitTypeVarDefault, TypeVarInstance<'db>, Option>, 6>; pub(crate) struct VisitTypeVarDefault; + +impl<'db> super::cyclic::HasIdentity<'db> for TypeVarInstance<'db> { + type Id = Self; + + fn to_identity(&self, _db: &'db dyn Db) -> Self::Id { + *self + } +} From d929a42e26fefc7fc10171372c421560d53205a6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 19 Jul 2026 16:01:34 -0400 Subject: [PATCH 150/222] [ty] Make membership and equality narrowing consistent (#26982) ## Summary Prior to this change, membership narrowing removed single-valued union members when the container's element type was broad, even though an element subclass could define custom equality. That made `in` and `==` inconsistent and could unsoundly remove `None`: ```py class Foo: ... def f(x: Foo | None, y: Foo, values: list[Foo]): if x == y: reveal_type(x) # Foo | None if x in values: reveal_type(x) # Previously: Foo ``` This removes the membership-only single-valued fallback and reuses the shared equality evaluator. Closes https://github.com/astral-sh/ty/issues/4039. --- .../mdtest/narrow/conditionals/in.md | 83 +++++++++++++------ crates/ty_python_semantic/src/types/narrow.rs | 39 +-------- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index c2c6fc1a52..191c6f0c97 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -331,6 +331,21 @@ def union_literal_haystack(x: Literal["a", "ab", "z"], flag: bool): else: reveal_type(x) # revealed: Literal["a", "ab", "z"] +def literal_union_haystack(x: Literal["abc", "def"]): + if "a" in x: + # `x` could also be validly narrowed to `Literal["abc"]` here: + reveal_type(x) # revealed: Literal["abc", "def"] + else: + # `x` could also be validly narrowed to `Literal["def"]` here: + reveal_type(x) # revealed: Literal["abc", "def"] + + if "a" not in x: + # `x` could also be validly narrowed to `Literal["def"]` here: + reveal_type(x) # revealed: Literal["abc", "def"] + else: + # `x` could also be validly narrowed to `Literal["abc"]` here: + reveal_type(x) # revealed: Literal["abc", "def"] + def mixed_literal_union_haystack( x: Literal["a", "z", "missing"], values: Literal["abc"] | tuple[Literal["z"]], @@ -441,14 +456,14 @@ def test(x: Literal["a", "b", "c"] | None | int = None): def broad_element_type(x: str | None, values: dict[str, int]): if x in values: - reveal_type(x) # revealed: str + reveal_type(x) # revealed: str | None else: reveal_type(x) # revealed: str | None def broad_element_type_with_unknown(values: dict[str, int]): x = [None][0] if x in values: - reveal_type(x) # revealed: Unknown + reveal_type(x) # revealed: None | Unknown else: reveal_type(x) # revealed: None | Unknown ``` @@ -517,7 +532,7 @@ def broad_dict_element(x: str | None, values: dict[str, int]) -> None: if x not in values: reveal_type(x) # revealed: str | None else: - reveal_type(x) # revealed: str + reveal_type(x) # revealed: str | None def union_tuple_slot(x: Literal[1, 2], values: tuple[Literal[1, 2]]) -> None: if x not in values: @@ -576,6 +591,25 @@ compare equal to any item in the container. A `TypedDict` cannot compare equal t final class with the default identity-based equality cannot compare equal to an integer. We retain types such as `int` and classes with custom equality when they might still match an item. +A non-final element type can have a subclass that compares equal to `None`, so membership must +preserve `None` just as an equality check does: + +```py +class Foo: ... + +def equality_and_membership(x: Foo | None, y: Foo, values: list[Foo]): + if x == y: + reveal_type(x) # revealed: Foo | None + if x in values: + reveal_type(x) # revealed: Foo | None + +class C: ... + +def broad_union_membership(origin: C | int): + if origin in ("x",): + reveal_type(origin) # revealed: C & ~int +``` + ```py from typing import Literal, TypedDict, final @@ -1061,46 +1095,43 @@ def custom_containment_component_prevents_narrowing( ## Range membership -A `range` contains integers, so a string literal can be removed from the type of the tested value: +A `range` contains integers, but equality against a broad `int` element type cannot rule out a +string literal: ```py from typing import Literal def range_membership(value: Literal["x", 1], values: range) -> None: if value in values: - reveal_type(value) # revealed: Literal[1] + reveal_type(value) # revealed: Literal["x", 1] ``` ## `TypedDict` key membership -Membership in a `TypedDict` checks its string keys, so the tested value can be narrowed to a -possible key. We do not apply key-based narrowing to arbitrary values, because `in` may test -substrings or elements instead: +Membership in a closed `TypedDict` checks its finite set of literal string keys, so the tested value +can be narrowed to a possible key. An open `TypedDict` can contain arbitrary additional string keys: ```py -from typing import Literal, TypedDict +from typing import Literal +from typing_extensions import TypedDict -class Values(TypedDict): +class ClosedValues(TypedDict, closed=True): present: int + other: str -def typed_dict_container(value: Literal["present", 1], values: Values) -> None: - if value in values: - reveal_type(value) # revealed: Literal["present"] +class OpenValues(TypedDict): + present: int + other: str -def f(x: Literal["abc", "def"]): - if "a" in x: - # `x` could also be validly narrowed to `Literal["abc"]` here: - reveal_type(x) # revealed: Literal["abc", "def"] - else: - # `x` could also be validly narrowed to `Literal["def"]` here: - reveal_type(x) # revealed: Literal["abc", "def"] +def closed_typed_dict_container(value: Literal["present", "other", "missing", 1], values: ClosedValues) -> None: + if value in values: + reveal_type(value) # revealed: Literal["other", "present"] - if "a" not in x: - # `x` could also be validly narrowed to `Literal["def"]` here: - reveal_type(x) # revealed: Literal["abc", "def"] - else: - # `x` could also be validly narrowed to `Literal["abc"]` here: - reveal_type(x) # revealed: Literal["abc", "def"] +def open_typed_dict_container(value: Literal["present", "other", "missing", 1], values: OpenValues) -> None: + if value in values: + # TODO: It would be safe to narrow `1` away if we could distinguish exact `str` from a + # subclass of `str` with custom equality. + reveal_type(value) # revealed: Literal["present", "other", "missing", 1] ``` ## bool diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 0bd6a49704..16cd913e3f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -2992,11 +2992,6 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { } /// Apply equality compatibility without losing the container's declared element type. - /// - /// Generic equality retains disjoint single-valued arms when an element subclass could define - /// custom equality. Membership narrowing instead removes those arms unless the equality - /// evaluator can establish a more specific compatibility, while retaining broader arms whose - /// runtime values may still match an element. fn evaluate_membership_equality( &self, lhs_ty: Type<'db>, @@ -3020,39 +3015,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { return Some(rhs_ty); } - let has_single_valued_component = match lhs_ty { - Type::Union(union) => union - .elements(self.db) - .iter() - .any(|element| element.is_single_valued(self.db)), - _ => lhs_ty.is_single_valued(self.db), - }; - if !has_single_valued_component { - return evaluate_type_equality(self.db, lhs_ty, rhs_ty, true, soundness_policy); - } - - let mut builder = UnionBuilder::new(self.db); - let add_lhs_element = |builder: UnionBuilder<'db>, element: Type<'db>| { - let element = element.resolve_type_alias(self.db); - match evaluate_type_equality(self.db, element, rhs_ty, true, soundness_policy) { - Some(Type::Never) => builder, - Some(constraint) => builder.add(constraint), - None if !element.is_single_valued(self.db) => builder.add(element), - None => builder, - } - }; - - if let Type::Union(union) = lhs_ty { - for element in union.elements(self.db).iter().copied() { - builder = add_lhs_element(builder, element); - } - } else { - builder = add_lhs_element(builder, lhs_ty); - } - - builder = builder.add(rhs_ty); - let narrowed = builder.build(); - (narrowed != lhs_ty).then_some(narrowed) + evaluate_type_equality(self.db, lhs_ty, rhs_ty, true, soundness_policy) } fn evaluate_expr_not_in(&self, lhs_ty: Type<'db>, rhs_ty: Type<'db>) -> Option> { From 1d3d784d182894ab2f442b7d833fbae75662e93b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 19 Jul 2026 16:13:34 -0400 Subject: [PATCH 151/222] [ty] Preserve constrained TypeVar equality narrowing (#26988) ## Summary Prior to this change, membership narrowing (`in`) preserved the shared specialization of a constrained TypeVar, while an equality narrowing (`==`) expanded the TypeVar and lost its correlation with the function's return type: ```python from enum import Enum from typing import Literal, TypeVar class E(Enum): A = 1 B = 2 T = TypeVar("T", Literal[E.A], Literal[E.B]) def correlated(value: E, other: T) -> T: if value == other: return value # Previously: invalid-return-type return other ``` This PR moves that-TypeVar handling into the shared evaluator. --- .../mdtest/narrow/conditionals/eq.md | 15 +++++- .../mdtest/narrow/conditionals/in.md | 4 +- .../ty_python_semantic/src/types/equality.rs | 16 +++++++ crates/ty_python_semantic/src/types/narrow.rs | 47 +++---------------- 4 files changed, 39 insertions(+), 43 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index f8c8a360d6..24d662cc6f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -1042,7 +1042,8 @@ Equality analysis expands the constraints of a constrained type variable in eith The resulting constraint is intersected with the type variable, preserving its identity: ```py -from typing import TypeVar, final +from enum import Enum +from typing import Literal, TypeVar, final @final class ConstraintA: ... @@ -1063,6 +1064,18 @@ def constrained_right(value: ConstraintA | None, other: T): pass else: reveal_type(value) # revealed: ConstraintA + +class E(Enum): + A = 1 + B = 2 + +EnumT = TypeVar("EnumT", Literal[E.A], Literal[E.B]) + +def correlated_typevar_eq(value: E, other: EnumT) -> EnumT: + if value == other: + reveal_type(value) # revealed: EnumT@correlated_typevar_eq + return value + return other ``` ## `LiteralString` and string-valued enums diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index 191c6f0c97..a4e07fcaa3 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -634,7 +634,7 @@ def default_equality(x: Token | Literal[1]): def overlapping_union_member(x: int | Literal["missing"]): if x in ("missing", 1): - reveal_type(x) # revealed: Literal["missing", 1, True] + reveal_type(x) # revealed: Literal[1, True, "missing"] def custom_equality(x: AlwaysEqual | Literal[1]): if x in (1,): @@ -1125,7 +1125,7 @@ class OpenValues(TypedDict): def closed_typed_dict_container(value: Literal["present", "other", "missing", 1], values: ClosedValues) -> None: if value in values: - reveal_type(value) # revealed: Literal["other", "present"] + reveal_type(value) # revealed: Literal["present", "other"] def open_typed_dict_container(value: Literal["present", "other", "missing", 1], values: OpenValues) -> None: if value in values: diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index 824c034db2..0bb6e58ab2 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -130,6 +130,22 @@ pub(super) fn evaluate_type_equality<'db>( is_positive: bool, soundness_policy: ComparisonSoundnessPolicy, ) -> Option> { + let right = right.resolve_type_alias(db); + + // Preserve the shared specialization of a constrained TypeVar. Expanding the TypeVar before + // comparing it with `left` would lose the correlation with other occurrences in the function. + if is_positive + && let Type::TypeVar(typevar) = right + && let Some(TypeVarBoundOrConstraints::Constraints(constraints)) = + typevar.typevar(db).bound_or_constraints(db) + && constraints.elements(db).iter().all(|constraint| { + evaluate_type_equality(db, left, *constraint, true, soundness_policy) + .is_some_and(|narrowed| narrowed.is_equivalent_to(db, *constraint)) + }) + { + return Some(right); + } + let branch = ComparisonBranch::from(is_positive); let condition_expects_equality = ComparisonOperator::Equality.condition_expects_equality(branch); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 16cd913e3f..0e7bcb11cb 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -2976,46 +2976,13 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { { return Some(Type::Never); } - let rhs_values = iterable - .homogeneous_element_type(self.db) - .resolve_type_alias(self.db); - - if let Type::Union(union) = rhs_values { - let mut builder = UnionBuilder::new(self.db); - for rhs_value in union.elements(self.db) { - builder = builder.add(self.evaluate_membership_equality(lhs_ty, *rhs_value)?); - } - Some(builder.build()) - } else { - self.evaluate_membership_equality(lhs_ty, rhs_values) - } - } - - /// Apply equality compatibility without losing the container's declared element type. - fn evaluate_membership_equality( - &self, - lhs_ty: Type<'db>, - rhs_ty: Type<'db>, - ) -> Option> { - let lhs_ty = lhs_ty.resolve_type_alias(self.db); - let rhs_ty = rhs_ty.resolve_type_alias(self.db); - let soundness_policy = self.comparison_soundness_policy(); - - // Preserve the shared specialization of a constrained TypeVar. Expanding the TypeVar - // before comparing it with `lhs_ty` would lose the correlation between this occurrence - // and other occurrences in the same function. - if let Type::TypeVar(typevar) = rhs_ty - && let Some(TypeVarBoundOrConstraints::Constraints(constraints)) = - typevar.typevar(self.db).bound_or_constraints(self.db) - && constraints.elements(self.db).iter().all(|constraint| { - evaluate_type_equality(self.db, lhs_ty, *constraint, true, soundness_policy) - .is_some_and(|narrowed| narrowed.is_equivalent_to(self.db, *constraint)) - }) - { - return Some(rhs_ty); - } - - evaluate_type_equality(self.db, lhs_ty, rhs_ty, true, soundness_policy) + evaluate_type_equality( + self.db, + lhs_ty, + iterable.homogeneous_element_type(self.db), + true, + self.comparison_soundness_policy(), + ) } fn evaluate_expr_not_in(&self, lhs_ty: Type<'db>, rhs_ty: Type<'db>) -> Option> { From 67ffd19e1f7f101c7f140fedc93a32f3c9800fe6 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 20 Jul 2026 01:17:58 +0500 Subject: [PATCH 152/222] ruff-ecosystem: bump bokeh/bokeh branch to `branch-3.10` (#26987) --- python/ruff-ecosystem/ruff_ecosystem/defaults.py | 2 +- scripts/check_ecosystem.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ruff-ecosystem/ruff_ecosystem/defaults.py b/python/ruff-ecosystem/ruff_ecosystem/defaults.py index a33798dd48..289073b2fc 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/defaults.py +++ b/python/ruff-ecosystem/ruff_ecosystem/defaults.py @@ -38,7 +38,7 @@ Project(repo=Repository(owner="binary-husky", name="gpt_academic", ref="master")), Project(repo=Repository(owner="bloomberg", name="pytest-memray", ref="main")), Project( - repo=Repository(owner="bokeh", name="bokeh", ref="branch-3.3"), + repo=Repository(owner="bokeh", name="bokeh", ref="branch-3.10"), check_options=CheckOptions(select="ALL"), ), # Disabled due to use of explicit `select` with `E999`, which has been removed. diff --git a/scripts/check_ecosystem.py b/scripts/check_ecosystem.py index a927646eb0..90b41e3b8a 100755 --- a/scripts/check_ecosystem.py +++ b/scripts/check_ecosystem.py @@ -126,7 +126,7 @@ async def _get_commit(self: Self, checkout_dir: Path) -> str: Repository("aws", "aws-sam-cli", "develop"), Repository("binary-husky", "gpt_academic", "master"), Repository("bloomberg", "pytest-memray", "main"), - Repository("bokeh", "bokeh", "branch-3.3", select="ALL"), + Repository("bokeh", "bokeh", "branch-3.10", select="ALL"), # Disabled due to use of explicit `select` with `E999`, which has been removed. # See: https://github.com/astral-sh/ruff/pull/12129 # Repository("demisto", "content", "master"), From 0e365180b6dc36ff903bfe2f3c54fac55bbf72ee Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 19 Jul 2026 16:46:54 -0400 Subject: [PATCH 153/222] [ty] Add membership narrowing ecosystem regression (#26991) ## Summary Adds a regression test for the [ibis ecosystem hit surfaced by #26988](https://github.com/astral-sh/ruff/pull/26988#issuecomment-5017218003). Membership narrowing previously replaced an explicitly annotated literal union with promotable literals from the tuple, causing an inferred instance attribute to widen to `str` when read from another method. The test ensures that the literal union is preserved across the assignment and subsequent return. --- .../resources/mdtest/narrow/conditionals/in.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index a4e07fcaa3..0e59a79cb1 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -44,6 +44,22 @@ def from_dict(value: str, valid_values: dict[MyType, int]) -> int | None: return None ``` +Explicitly annotated literals remain unpromotable through membership narrowing and inferred instance +attributes: + +```py +from typing import Literal + +class Backend: + def connect(self, mode: Literal["streaming", "batch"]) -> None: + if mode not in ("batch", "streaming"): + return + self._mode = mode + + def mode(self) -> Literal["streaming", "batch"]: + return self._mode +``` + ```py from typing import Literal From dc9c428b828cd50a8af74d2545030ec2a332e7dc Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 19 Jul 2026 22:16:50 +0100 Subject: [PATCH 154/222] [ty] Fix ecosystem metadata collection for split build jobs (#26993) --- .../skills/minimizing-ty-ecosystem-changes/SKILL.md | 2 ++ scripts/collect_ty_ecosystem_run_metadata.py | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md b/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md index 03d60493ec..a511397bb4 100644 --- a/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md +++ b/.agents/skills/minimizing-ty-ecosystem-changes/SKILL.md @@ -25,6 +25,8 @@ scripts/collect_ty_ecosystem_run_metadata.py \ The manifest contains the analyzed Ruff revisions, Actions `EXCLUDE_NEWER`, ecosystem-analyzer and mypy-primer revisions, and each project's CI Python version. Stop if the helper cannot determine a unique value; never substitute a comment timestamp or local default. +The current workflow splits compilation into `Build ty (base)` and `Build ty (pr)`. The helper reads the base job, which records both the merge base and PR merge revision, and still supports historical runs with a single `Build ty` job. + ## Prepare ty If a primary agent supplied freshly copied base and PR binaries plus the PR ecosystem config, verify the paths exist and reuse them. Do not rebuild, switch Ruff refs, or overwrite the shared artifacts. diff --git a/scripts/collect_ty_ecosystem_run_metadata.py b/scripts/collect_ty_ecosystem_run_metadata.py index 17c7d5ed84..aac9c4c52c 100755 --- a/scripts/collect_ty_ecosystem_run_metadata.py +++ b/scripts/collect_ty_ecosystem_run_metadata.py @@ -187,10 +187,12 @@ def parse_run_reference(value: str) -> tuple[int, int | None]: raise MetadataError(f"invalid Actions run ID or URL: {value}") -def job_by_name(jobs: Sequence[dict[str, Any]], name: str) -> dict[str, Any]: - matches = [job for job in jobs if job.get("name") == name] +def build_job(jobs: Sequence[dict[str, Any]]) -> dict[str, Any]: + matches = [ + job for job in jobs if job.get("name") in {"Build ty", "Build ty (base)"} + ] if len(matches) != 1: - raise MetadataError(f"expected exactly one {name!r} job") + raise MetadataError("expected exactly one 'Build ty' or 'Build ty (base)' job") return matches[0] @@ -241,7 +243,7 @@ def collect_metadata( jobs = run_data.get("jobs") if not isinstance(jobs, list): raise MetadataError("the Actions run did not include job metadata") - build_job = job_by_name(jobs, "Build ty") + selected_build_job = build_job(jobs) shard_job = first_shard_job(jobs) def job_log(job: dict[str, Any]) -> str: @@ -261,7 +263,7 @@ def job_log(job: dict[str, Any]) -> str: ] ) - merge_base, pr_revision = parse_build_log(job_log(build_job)) + merge_base, pr_revision = parse_build_log(job_log(selected_build_job)) exclude_newer, analyzer_revision, shard_merge_base = parse_shard_log( job_log(shard_job) ) From 228f3c36625864f44b360f2d45363d4423757852 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 19 Jul 2026 17:58:06 -0400 Subject: [PATCH 155/222] [ty] Preserve constrained TypeVar inequality narrowing (#26995) ## Summary Follow-up to [#26988](https://github.com/astral-sh/ruff/pull/26988#issuecomment-5017439215). Positive equality narrowing preserves the shared specialization of a constrained TypeVar, but the equivalent false branch of `!=` still expanded it and lost its correlation with the return type: ```py def correlated(value: E, other: T) -> T: if value != other: return other return value # Previously: invalid-return-type ``` --- .../resources/mdtest/narrow/conditionals/eq.md | 6 ++++++ crates/ty_python_semantic/src/types/equality.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md index 24d662cc6f..ee2f6105a7 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md @@ -1076,6 +1076,12 @@ def correlated_typevar_eq(value: E, other: EnumT) -> EnumT: reveal_type(value) # revealed: EnumT@correlated_typevar_eq return value return other + +def correlated_typevar_ne(value: E, other: EnumT) -> EnumT: + if value != other: + return other + reveal_type(value) # revealed: EnumT@correlated_typevar_ne + return value ``` ## `LiteralString` and string-valued enums diff --git a/crates/ty_python_semantic/src/types/equality.rs b/crates/ty_python_semantic/src/types/equality.rs index 0bb6e58ab2..e88dce404f 100644 --- a/crates/ty_python_semantic/src/types/equality.rs +++ b/crates/ty_python_semantic/src/types/equality.rs @@ -228,6 +228,21 @@ pub(super) fn evaluate_type_inequality<'db>( is_positive: bool, soundness_policy: ComparisonSoundnessPolicy, ) -> Option> { + let right = right.resolve_type_alias(db); + + // Preserve the shared specialization of a constrained TypeVar when `left != right` is false. + if !is_positive + && let Type::TypeVar(typevar) = right + && let Some(TypeVarBoundOrConstraints::Constraints(constraints)) = + typevar.typevar(db).bound_or_constraints(db) + && constraints.elements(db).iter().all(|constraint| { + evaluate_type_inequality(db, left, *constraint, false, soundness_policy) + .is_some_and(|narrowed| narrowed.is_equivalent_to(db, *constraint)) + }) + { + return Some(right); + } + let branch = ComparisonBranch::from(is_positive); let condition_expects_equality = ComparisonOperator::Inequality.condition_expects_equality(branch); From 8ee2d3cca3cfbbc8531f89d2f419b94ed3f42e4e Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 20 Jul 2026 11:45:55 +0100 Subject: [PATCH 156/222] Propagate invalid format type characters from `FormatSpec::parse()` in `ruff_python_literal` (#26976) --- .../rules/bad_string_format_character.rs | 25 +--- crates/ruff_python_literal/src/format.rs | 115 +++++++++--------- 2 files changed, 62 insertions(+), 78 deletions(-) diff --git a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs index 28a24babde..d853f603a5 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/bad_string_format_character.rs @@ -50,16 +50,8 @@ pub(crate) fn call(checker: &Checker, string: &str, range: TextRange) { }; match FormatSpec::parse(format_spec) { - Err(FormatSpecError::InvalidFormatType) => { - checker.report_diagnostic( - BadStringFormatCharacter { - // The format type character is always the last one. - // More info in the official spec: - // https://docs.python.org/3/library/string.html#format-specification-mini-language - format_char: format_spec.chars().last().unwrap(), - }, - range, - ); + Err(FormatSpecError::InvalidFormatType(format_char)) => { + checker.report_diagnostic(BadStringFormatCharacter { format_char }, range); } Err(_) => {} Ok(FormatSpec::Static(_)) => {} @@ -68,18 +60,11 @@ pub(crate) fn call(checker: &Checker, string: &str, range: TextRange) { let FormatPart::Field { format_spec, .. } = placeholder else { continue; }; - if let Err(FormatSpecError::InvalidFormatType) = + if let Err(FormatSpecError::InvalidFormatType(format_char)) = FormatSpec::parse(&format_spec) { - checker.report_diagnostic( - BadStringFormatCharacter { - // The format type character is always the last one. - // More info in the official spec: - // https://docs.python.org/3/library/string.html#format-specification-mini-language - format_char: format_spec.chars().last().unwrap(), - }, - range, - ); + checker + .report_diagnostic(BadStringFormatCharacter { format_char }, range); } } } diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index f1eba8e8f5..37653ab1de 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -5,12 +5,6 @@ use std::str::FromStr; use crate::Case; -trait FormatParse { - fn parse(text: &str) -> (Option, &str) - where - Self: Sized; -} - #[derive(Debug, Copy, Clone, PartialEq)] pub enum FormatConversion { Str, @@ -19,7 +13,7 @@ pub enum FormatConversion { Bytes, } -impl FormatParse for FormatConversion { +impl FormatConversion { fn parse(text: &str) -> (Option, &str) { let Some(conversion) = Self::from_string(text) else { return (None, text); @@ -72,7 +66,7 @@ impl FormatAlign { } } -impl FormatParse for FormatAlign { +impl FormatAlign { fn parse(text: &str) -> (Option, &str) { let mut chars = text.chars(); if let Some(maybe_align) = chars.next().and_then(Self::from_char) { @@ -90,15 +84,16 @@ pub enum FormatSign { MinusOrSpace, } -impl FormatParse for FormatSign { +impl FormatSign { fn parse(text: &str) -> (Option, &str) { let mut chars = text.chars(); - match chars.next() { - Some('-') => (Some(Self::Minus), chars.as_str()), - Some('+') => (Some(Self::Plus), chars.as_str()), - Some(' ') => (Some(Self::MinusOrSpace), chars.as_str()), - _ => (None, text), - } + let kind = match chars.next() { + Some('-') => Self::Minus, + Some('+') => Self::Plus, + Some(' ') => Self::MinusOrSpace, + Some(_) | None => return (None, text), + }; + (Some(kind), chars.as_str()) } } @@ -108,13 +103,13 @@ pub enum FormatGrouping { Underscore, } -impl FormatParse for FormatGrouping { +impl FormatGrouping { fn parse(text: &str) -> (Option, &str) { let mut chars = text.chars(); match chars.next() { Some('_') => (Some(Self::Underscore), chars.as_str()), Some(',') => (Some(Self::Comma), chars.as_str()), - _ => (None, text), + Some(_) | None => (None, text), } } } @@ -157,29 +152,40 @@ impl From<&FormatType> for char { } } -impl FormatParse for FormatType { - fn parse(text: &str) -> (Option, &str) { +impl FormatType { + /// Attempt to parse the [conversion type] of the f-string. + /// + /// A conversion type is optional in an f-string. + /// If it is present, it is always the last character of the format specifier. + /// + /// If a valid conversion type was encountered, this function returns `Ok((Some(FormatType), remaining_text))`. + /// If an invalid conversion type was encountered, this function returns `Err(invalid_char)`. + /// If no conversion type was encountered, this function returns `Ok((None, remaining_text))`. + /// + /// [conversion type]: https://docs.python.org/3/library/string.html#format-specification-mini-language + fn parse(text: &str) -> Result<(Option, &str), char> { let mut chars = text.chars(); - match chars.next() { - Some('s') => (Some(Self::String), chars.as_str()), - Some('b') => (Some(Self::Binary), chars.as_str()), - Some('c') => (Some(Self::Character), chars.as_str()), - Some('d') => (Some(Self::Decimal), chars.as_str()), - Some('o') => (Some(Self::Octal), chars.as_str()), - Some('n') => (Some(Self::Number(Case::Lower)), chars.as_str()), - Some('N') => (Some(Self::Number(Case::Upper)), chars.as_str()), - Some('x') => (Some(Self::Hex(Case::Lower)), chars.as_str()), - Some('X') => (Some(Self::Hex(Case::Upper)), chars.as_str()), - Some('e') => (Some(Self::Exponent(Case::Lower)), chars.as_str()), - Some('E') => (Some(Self::Exponent(Case::Upper)), chars.as_str()), - Some('f') => (Some(Self::FixedPoint(Case::Lower)), chars.as_str()), - Some('F') => (Some(Self::FixedPoint(Case::Upper)), chars.as_str()), - Some('g') => (Some(Self::GeneralFormat(Case::Lower)), chars.as_str()), - Some('G') => (Some(Self::GeneralFormat(Case::Upper)), chars.as_str()), - Some('%') => (Some(Self::Percentage), chars.as_str()), - Some(_) => (None, chars.as_str()), - _ => (None, text), - } + let kind = match chars.next() { + Some('s') => Self::String, + Some('b') => Self::Binary, + Some('c') => Self::Character, + Some('d') => Self::Decimal, + Some('o') => Self::Octal, + Some('n') => Self::Number(Case::Lower), + Some('N') => Self::Number(Case::Upper), + Some('x') => Self::Hex(Case::Lower), + Some('X') => Self::Hex(Case::Upper), + Some('e') => Self::Exponent(Case::Lower), + Some('E') => Self::Exponent(Case::Upper), + Some('f') => Self::FixedPoint(Case::Lower), + Some('F') => Self::FixedPoint(Case::Upper), + Some('g') => Self::GeneralFormat(Case::Lower), + Some('G') => Self::GeneralFormat(Case::Upper), + Some('%') => Self::Percentage, + Some(invalid) => return Err(invalid), + None => return Ok((None, text)), + }; + Ok((Some(kind), chars.as_str())) } } @@ -297,7 +303,7 @@ fn parse_alternate_form(text: &str) -> (bool, &str) { let mut chars = text.chars(); match chars.next() { Some('#') => (true, chars.as_str()), - _ => (false, text), + Some(_) | None => (false, text), } } @@ -323,7 +329,7 @@ fn parse_precision(text: &str) -> Result<(Option, &str), FormatSpecError> (None, text) } } - _ => (None, text), + Some(_) | None => (None, text), }) } @@ -368,20 +374,13 @@ impl FormatSpec { let (grouping_option, text) = FormatGrouping::parse(text); let (precision, text) = parse_precision(text)?; - let (format_type, _text) = if text.is_empty() { - (None, text) - } else { - // If there's any remaining text, we should yield a valid format type and consume it - // all. - let (format_type, text) = FormatType::parse(text); - if format_type.is_none() { - return Err(FormatSpecError::InvalidFormatType); - } - if !text.is_empty() { - return Err(FormatSpecError::InvalidFormatSpecifier); - } - (format_type, text) - }; + // If there's any remaining text, we should yield a valid format type and consume it + // all. + let (format_type, text) = + FormatType::parse(text).map_err(FormatSpecError::InvalidFormatType)?; + if !text.is_empty() { + return Err(FormatSpecError::InvalidFormatSpecifier); + } if zero && fill.is_none() { fill.replace('0'); @@ -407,7 +406,7 @@ pub enum FormatSpecError { DecimalDigitsTooMany, PrecisionTooBig, InvalidFormatSpecifier, - InvalidFormatType, + InvalidFormatType(char), InvalidPlaceholder(FormatParseError), PlaceholderRecursionExceeded, UnspecifiedFormat(char, char), @@ -969,7 +968,7 @@ mod tests { ); assert_eq!( FormatSpec::parse("}"), - Err(FormatSpecError::InvalidFormatType) + Err(FormatSpecError::InvalidFormatType('}')) ); assert_eq!( FormatSpec::parse("{}}"), @@ -995,7 +994,7 @@ mod tests { ); assert_eq!( FormatSpec::parse("z"), - Err(FormatSpecError::InvalidFormatType) + Err(FormatSpecError::InvalidFormatType('z')) ); } From 6ee9b86d20d9c8c711a7df1be70d13701d59d0b0 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 20 Jul 2026 18:41:40 +0500 Subject: [PATCH 157/222] invalid_index_type: remove redundant `is_slice_type` re-check (#26985) ## Summary A small change. Noticed in `invalid_index_type` rule code that `else if` branch was rechecking that `is_slice_type` is not `None` https://github.com/astral-sh/ruff/blob/8bbdb8e392e13481db33abc731546eec5a827d36/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs#L130 while there's a guard at the beginning of the loop body checking exactly this: https://github.com/astral-sh/ruff/blob/8bbdb8e392e13481db33abc731546eec5a827d36/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs#L110-L112 So `else if` is safe to replace with just `else`. ## Test Plan Reran the tests with `cargo test`, no snapshot changes and all tests passed. --- crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs index dff668d028..ca6f91ab05 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs @@ -127,7 +127,7 @@ pub(crate) fn invalid_index_type(checker: &Checker, expr: &ExprSubscript) { is_slice.range(), ); } - } else if let Some(is_slice_type) = CheckableExprType::try_from(is_slice.as_ref()) { + } else { checker.report_diagnostic( InvalidIndexType { value_type: value_type.to_string(), From 502937d23ce98451699815bcf2307b5dc3bc6b96 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 10:06:59 -0400 Subject: [PATCH 158/222] [ty] Prefer innermost inline suppressions (#26940) ## Summary When multiple same-line or own-line suppressions apply to the same diagnostic, prefer the innermost suppression when their ranges are nested. This keeps the closest directive marked as used and lets an outer directive continue to suppress diagnostics elsewhere in the logical statement. For a diagnostic spanning multiple lines, a suppression on its opening line takes precedence over a separate suppression on its closing line. This is not an innermost/outermost case because neither suppression contains the other. Keeping opening-line precedence also prevents a previously used opening-line suppression from becoming unused. The lookup is indexed by suppression target so innermost precedence does not require scanning unrelated nested suppressions for every diagnostic. The same precedence is used when checking `unused-ignore-comment` and `blanket-ignore-comment`. ## Rules ### Nested suppression ranges On main, applicable suppressions are checked in source order. The outer suppression appears first, so it suppresses both diagnostics and the inner suppression is reported as unused: ```python # ty: ignore[unresolved-reference] # Used for `missing` and `absent`. values = [ # ty: ignore[unresolved-reference] # Unused. missing, absent, ] ``` With this PR, the innermost suppression wins for `missing`. The outer suppression remains responsible for `absent`, so both suppressions are used: ```python # ty: ignore[unresolved-reference] # Used for `absent`. values = [ # ty: ignore[unresolved-reference] # Used for `missing`. missing, absent, ] ``` ### Separate opening-line and closing-line suppressions For a diagnostic that spans multiple lines, suppressions on its opening and closing lines can both apply even though neither suppression contains the other: ```python f( # ty: ignore[missing-argument] g(missing)) # ty: ignore[unresolved-reference, missing-argument] ``` On main, the opening-line suppression wins because it appears first. This PR deliberately preserves that behavior: innermost precedence applies only when the suppression ranges are nested. The closing-line suppression still handles `unresolved-reference`, but its `missing-argument` code is unused. --- crates/ty/docs/rules.md | 10 +- .../mdtest/suppressions/ty_ignore.md | 31 +++ crates/ty_python_semantic/src/suppression.rs | 245 ++++++++++++------ .../src/suppression/unused.rs | 23 +- 4 files changed, 216 insertions(+), 93 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 8c72575f28..4c929c659d 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -165,7 +165,7 @@ def _(x: int): Default level: ignore · Added in 0.0.57 · Related issues · -View source +View source @@ -832,7 +832,7 @@ INITIALIZED_CONSTANT: Final[int] = 1 Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1838,7 +1838,7 @@ x: G[int] Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5069,7 +5069,7 @@ async def main() -> None: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5110,7 +5110,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments. Default level: warn · Added in 0.0.14 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md index e91233f84d..7faafc6698 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md @@ -81,6 +81,37 @@ values = [ ] ``` +When nested same-line or own-line suppressions apply to the same diagnostic, the innermost +suppression takes precedence. The outer suppression can still suppress diagnostics elsewhere in the +logical line. + +```py +seen_code = True + +# ty: ignore[unresolved-reference] +values = [ + # ty: ignore[unresolved-reference] + missing, + absent, +] +``` + +If separate suppressions cover the opening and closing lines of a multiline diagnostic, the +opening-line suppression takes precedence. + +```py +# fmt: off +def f(a: int, b: int) -> None: + pass + +def g(a: int, b: int) -> int: + return 0 + +f( # ty: ignore[missing-argument] + g(missing)) # ty: ignore[unresolved-reference, missing-argument] +# fmt: on +``` + ## Unused suppression ```py diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 9e5682fea9..6b7b5d9918 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -4,6 +4,7 @@ mod unused; use smallvec::{SmallVec, smallvec}; use std::fmt; +use std::hash::{Hash, Hasher}; use ruff_db::diagnostic::{ Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, LintName, Severity, Span, @@ -12,6 +13,7 @@ use ruff_db::{files::File, parsed::parsed_module, source::source_text}; use ruff_python_ast::token::{TokenKind, Tokens}; use ruff_python_trivia::indentation_at_offset; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; +use rustc_hash::FxHasher; use crate::diagnostic::DiagnosticGuard; use crate::lint::{GetLintError, Level, LintMetadata, LintRegistry, LintStatus}; @@ -155,17 +157,19 @@ fn check_blanket_suppressions(context: &mut CheckSuppressionsContext) { for suppression in context.suppressions.iter().filter(|suppression| { suppression.kind == SuppressionKind::Ty && suppression.target == SuppressionTarget::All }) { - // A blanket suppression cannot suppress its own diagnostic, but a code-specific + // A blanket suppression cannot suppress its own diagnostic, but a lint-specific // suppression can. - if let Some(code_suppression) = context - .suppressions - .lint_suppressions(suppression.range, LintId::of(&BLANKET_IGNORE_COMMENT)) - .find(|candidate| candidate.target.is_lint()) - { + if let Some(lint_suppression) = select_preferred_suppression( + context + .suppressions + .lint_suppressions(suppression.range, LintId::of(&BLANKET_IGNORE_COMMENT)) + .filter(|candidate| candidate.target.is_lint()), + suppression.range, + ) { context .diagnostics .borrow_mut() - .mark_used(code_suppression.id()); + .mark_used(lint_suppression.id()); } else if let Some(diag) = context.report_unchecked(&BLANKET_IGNORE_COMMENT, suppression.range) { @@ -327,7 +331,7 @@ pub(crate) struct Suppressions { /// ``` /// /// The outer suppression starts before the inner suppression but ends after it. - inline: IntervalIndex, + inline: IntervalIndex, /// Suppressions with lint codes that are unknown. unknown: Vec, @@ -337,11 +341,16 @@ pub(crate) struct Suppressions { } impl Suppressions { + /// Returns the suppression that takes precedence for the diagnostic `range` and lint `id`. + /// + /// Nested suppression ranges prefer the innermost candidate. If a diagnostic spans multiple + /// physical lines and separate suppressions cover its opening and closing lines, the + /// opening-line suppression retains precedence. pub(crate) fn find_suppression(&self, range: TextRange, id: LintId) -> Option<&Suppression> { - self.lint_suppressions(range, id).next() + select_preferred_suppression(self.lint_suppressions(range, id), range) } - /// Returns all suppressions for the given lint + /// Returns applicable suppressions for `id`, with inline suppressions in reverse source order. fn lint_suppressions( &self, range: TextRange, @@ -349,22 +358,8 @@ impl Suppressions { ) -> impl Iterator + '_ { self.file .iter() - .chain(self.inline_suppressions(range)) - .filter(move |suppression| suppression.matches(id)) - } - - /// Returns the inline suppressions that apply for `range`. - /// - /// A suppression applies for the given range if it contains the range's start or end offset. - /// End-of-line suppressions cover the diagnostic's start or end line, while own-line - /// suppressions cover the following logical line. - fn inline_suppressions(&self, range: TextRange) -> impl Iterator + '_ { - self.inline.intersecting(range).filter(move |suppression| { - // Don't use intersect to avoid that suppressions on inner-expression - // ignore errors for outer expressions - suppression.suppressed_range.contains(range.start()) - || suppression.suppressed_range.contains_inclusive(range.end()) - }) + .chain(self.inline.intersecting_rev(range, id)) + .filter(move |suppression| suppression.matches(id) && suppression.applies_to(range)) } /// Returns the inline suppressions whose comments are on `line_range`. @@ -376,11 +371,11 @@ impl Suppressions { let start = self .inline .entries - .partition_point(|entry| entry.value.comment_range.start() < line_range.start()); + .partition_point(|entry| entry.suppression.comment_range.start() < line_range.start()); self.inline.entries[start..] .iter() - .map(|entry| &entry.value) + .map(|entry| &entry.suppression) .take_while(move |suppression| suppression.comment_range.start() < line_range.end()) } @@ -389,6 +384,40 @@ impl Suppressions { } } +/// Selects between applicable suppressions yielded in reverse source order. +/// +/// Candidates covering the same endpoint are nested, so the first (innermost) candidate wins. For +/// a diagnostic spanning multiple physical lines, however, a later candidate may cover only its +/// closing line while a separate earlier candidate covers its opening line. The closing-line +/// candidate wins only when its suppression range is nested within the opening-line candidate's +/// range; otherwise the opening-line candidate retains precedence. +fn select_preferred_suppression<'a>( + mut candidates: impl Iterator, + diagnostic_range: TextRange, +) -> Option<&'a Suppression> { + let end_candidate = candidates.next()?; + let diagnostic_start = diagnostic_range.start(); + + if end_candidate.suppressed_range.contains(diagnostic_start) { + return Some(end_candidate); + } + + let start_candidate = + candidates.find(|candidate| candidate.suppressed_range.contains(diagnostic_start)); + + match start_candidate { + Some(start_candidate) + if start_candidate + .suppressed_range + .contains_range(end_candidate.suppressed_range) => + { + Some(end_candidate) + } + Some(start_candidate) => Some(start_candidate), + None => Some(end_candidate), + } +} + /// A `type: ignore` or `ty: ignore` suppression. /// /// Suppression comments that suppress multiple codes @@ -428,6 +457,15 @@ pub(crate) struct Suppression { } impl Suppression { + /// Returns whether this suppression covers either endpoint of `range`. + /// + /// Requiring endpoint containment, rather than any intersection, prevents a suppression on an + /// inner expression from suppressing a diagnostic for an enclosing expression. + fn applies_to(&self, range: TextRange) -> bool { + self.suppressed_range.contains(range.start()) + || self.suppressed_range.contains_inclusive(range.end()) + } + fn matches(&self, tested_id: LintId) -> bool { match self.target { SuppressionTarget::All => true, @@ -441,12 +479,6 @@ impl Suppression { } } -impl Interval for Suppression { - fn interval(&self) -> TextRange { - self.suppressed_range - } -} - #[derive(Copy, Clone, Debug, Eq, PartialEq, get_size2::GetSize)] enum SuppressionKind { TypeIgnore, @@ -500,6 +532,23 @@ impl SuppressionTarget { const fn is_lint(self) -> bool { matches!(self, SuppressionTarget::Lint(_)) } + + /// Returns the conservative bit used to skip subtrees without this target. + /// + /// Lints are hashed into 63 buckets, with one bucket reserved for blanket suppressions. The + /// interval index only traverses subtrees whose buckets overlap the queried lint. + fn target_mask(self) -> u64 { + match self { + // Keep blanket suppressions separate so they are always considered. + SuppressionTarget::All => 1, + SuppressionTarget::Empty => 0, + SuppressionTarget::Lint(id) => { + let mut hasher = FxHasher::default(); + id.hash(&mut hasher); + 1 << (1 + hasher.finish() % 63) + } + } + } } struct SuppressionsBuilder<'a> { @@ -722,41 +771,37 @@ struct InvalidSuppression { error: ParseError, } -/// A value with a source range that can be stored in an [`IntervalIndex`]. -trait Interval { - /// Returns the range indexed for this value. - fn interval(&self) -> TextRange; -} - /// A start-sorted interval index. /// /// The entries form an implicit balanced binary tree. Each entry stores the maximum interval end -/// in its subtree, which allows intersection queries to skip subtrees that end before the query. -/// Intervals may overlap or nest, and queries return them in their original order. +/// and target mask for its subtree, allowing queries to skip unrelated subtrees. Intervals may +/// overlap or nest, and queries traverse them in reverse input order. #[derive(Debug, Eq, PartialEq, get_size2::GetSize)] -struct IntervalIndex { - entries: Box<[IntervalEntry]>, +struct IntervalIndex { + entries: Box<[IntervalEntry]>, } -/// An indexed value and the largest interval end in its implicit subtree. +/// An indexed value and the bounds of its implicit subtree. #[derive(Debug, Eq, PartialEq, get_size2::GetSize)] -struct IntervalEntry { - value: T, +struct IntervalEntry { + suppression: Suppression, subtree_max_end: TextSize, + subtree_target_mask: u64, } -impl IntervalIndex { +impl IntervalIndex { /// Builds an index from values sorted by interval start, retaining their input order. - /// - /// The caller must ensure that `values` is sorted by [`Interval::interval`] start. - fn from_sorted(values: Vec) -> Self { - debug_assert!(values.is_sorted_by_key(|value| value.interval().start())); + fn from_sorted(suppressions: Vec) -> Self { + debug_assert!( + suppressions.is_sorted_by_key(|suppression| suppression.suppressed_range.start()) + ); - let mut entries = values + let mut entries = suppressions .into_iter() - .map(|value| IntervalEntry { - subtree_max_end: value.interval().end(), - value, + .map(|suppression| IntervalEntry { + subtree_max_end: suppression.suppressed_range.end(), + subtree_target_mask: suppression.target.target_mask(), + suppression, }) .collect::>(); @@ -765,41 +810,46 @@ impl IntervalIndex { Self { entries } } - /// Populates each entry's subtree maximum and returns the maximum end in `entries`. - fn set_subtree_max_ends(entries: &mut [IntervalEntry]) -> TextSize { + /// Populates and returns the maximum end and target mask for `entries`. + fn set_subtree_max_ends(entries: &mut [IntervalEntry]) -> (TextSize, u64) { let mid = entries.len() / 2; let (left, root_and_right) = entries.split_at_mut(mid); let Some((root, right)) = root_and_right.split_first_mut() else { - return TextSize::default(); + return (TextSize::default(), 0); }; - let left_max_end = Self::set_subtree_max_ends(left); - let right_max_end = Self::set_subtree_max_ends(right); + let (left_max_end, left_mask) = Self::set_subtree_max_ends(left); + let (right_max_end, right_mask) = Self::set_subtree_max_ends(right); root.subtree_max_end = root - .value - .interval() + .suppression + .suppressed_range .end() .max(left_max_end) .max(right_max_end); - root.subtree_max_end + root.subtree_target_mask |= left_mask | right_mask; + (root.subtree_max_end, root.subtree_target_mask) } - /// Returns the indexed values that intersect `query`, in input order. + /// Returns the indexed values that intersect `query`, in reverse input order. /// /// Interval endpoints are treated as inclusive so that an empty diagnostic range at an /// interval boundary remains a candidate. Callers can apply stricter containment rules to the /// returned values. - fn intersecting(&self, query: TextRange) -> impl Iterator { - let mut pending: SmallVec<[&[IntervalEntry]; 16]> = smallvec![self.entries.as_ref()]; + fn intersecting_rev(&self, query: TextRange, id: LintId) -> impl Iterator { + let mut pending: SmallVec<[&[IntervalEntry]; 16]> = smallvec![self.entries.as_ref()]; + let wanted = + SuppressionTarget::All.target_mask() | SuppressionTarget::Lint(id).target_mask(); std::iter::from_fn(move || { while let Some(entries) = pending.pop() { match entries { [entry] => { - if entry.value.interval().start() <= query.end() - && entry.value.interval().end() >= query.start() + let suppressed_range = entry.suppression.suppressed_range; + if entry.subtree_target_mask & wanted != 0 + && suppressed_range.start() <= query.end() + && suppressed_range.end() >= query.start() { - return Some(&entry.value); + return Some(&entry.suppression); } } entries => { @@ -809,19 +859,22 @@ impl IntervalIndex { continue; }; - if root.subtree_max_end < query.start() { + if root.subtree_max_end < query.start() + || root.subtree_target_mask & wanted == 0 + { continue; } - if root.value.interval().start() > query.end() { + if root.suppression.suppressed_range.start() > query.end() { pending.push(left); continue; } - // Push in reverse source order so the left subtree is visited first. - pending.push(right); - pending.push(std::slice::from_ref(root)); + // The stack is last-in, first-out, so push in source order to visit the + // right subtree first. pending.push(left); + pending.push(std::slice::from_ref(root)); + pending.push(right); } } } @@ -830,11 +883,49 @@ impl IntervalIndex { }) } - fn iter(&self) -> impl Iterator { - self.entries.iter().map(|entry| &entry.value) + fn iter(&self) -> impl Iterator { + self.entries.iter().map(|entry| &entry.suppression) } fn len(&self) -> usize { self.entries.len() } } + +#[cfg(test)] +mod tests { + use ruff_db::files::system_path_to_file; + use ruff_text_size::{TextLen as _, TextRange}; + + use super::suppressions; + use crate::Db as _; + use crate::db::tests::TestDbBuilder; + + #[test] + fn nested_suppressions_for_other_lints_do_not_match() { + let source = r#"seen_code = True +# ty: ignore[unresolved-reference] +# ty: ignore[division-by-zero] +# ty: ignore[division-by-zero] +# ty: ignore[division-by-zero] +value = missing +"#; + let db = TestDbBuilder::new() + .with_file("test.py", source) + .build() + .unwrap(); + let file = system_path_to_file(&db, "test.py").unwrap(); + let unresolved_reference = db.lint_registry().get("unresolved-reference").unwrap(); + let missing_start = source.find("missing").unwrap().try_into().unwrap(); + let missing_range = TextRange::at(missing_start, "missing".text_len()); + + let suppressions = suppressions(&db, file); + assert_eq!(suppressions.inline.len(), 4); + assert_eq!( + suppressions + .lint_suppressions(missing_range, unresolved_reference) + .count(), + 1 + ); + } +} diff --git a/crates/ty_python_semantic/src/suppression/unused.rs b/crates/ty_python_semantic/src/suppression/unused.rs index 88e2228740..8e681b5e98 100644 --- a/crates/ty_python_semantic/src/suppression/unused.rs +++ b/crates/ty_python_semantic/src/suppression/unused.rs @@ -7,7 +7,7 @@ use std::fmt::Write as _; use crate::lint::LintId; use crate::suppression::{ CheckSuppressionsContext, Suppression, SuppressionKind, SuppressionTarget, - UNUSED_IGNORE_COMMENT, UNUSED_TYPE_IGNORE_COMMENT, + UNUSED_IGNORE_COMMENT, UNUSED_TYPE_IGNORE_COMMENT, select_preferred_suppression, }; /// Checks for unused suppression comments in `file` and @@ -40,16 +40,17 @@ pub(super) fn check_unused_suppressions(context: &mut CheckSuppressionsContext) // `unused-ignore-comment` diagnostics can only be suppressed by specifying a // code. This is necessary because every `type: ignore` would implicitly also // suppress its own unused-ignore-comment diagnostic. - if let Some(unused_suppression) = all - .lint_suppressions(suppression.range, LintId::of(&UNUSED_IGNORE_COMMENT)) - .find(|unused_ignore_suppression| unused_ignore_suppression.target.is_lint()) - { - // A `unused-ignore-comment` suppression can't ignore itself. - // It can only ignore other suppressions. - if unused_suppression.id() != suppression.id() { - diagnostics.mark_used(unused_suppression.id()); - continue; - } + // An `unused-ignore-comment` suppression can't ignore itself. It can only ignore other + // suppressions, so exclude the suppression whose diagnostic we're checking. + if let Some(unused_suppression) = select_preferred_suppression( + all.lint_suppressions(suppression.range, LintId::of(&UNUSED_IGNORE_COMMENT)) + .filter(|candidate| { + candidate.target.is_lint() && candidate.id() != suppression.id() + }), + suppression.range, + ) { + diagnostics.mark_used(unused_suppression.id()); + continue; } unused.push(suppression); From bd75eb1cc4b5e26285cfe1000dc6000a68722e54 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:09:22 -0400 Subject: [PATCH 159/222] Update branch for jrnl (#27007) Summary -- Ecosystem checks started failing this morning for a missing branch, for example: https://github.com/astral-sh/ruff/actions/runs/29745997326/job/88364078381?pr=26770 main now seems to be their default branch: https://github.com/jrnl-org/jrnl Test Plan -- Second commit with a small whitespace change to trigger an ecosystem run --- python/ruff-ecosystem/ruff_ecosystem/defaults.py | 2 +- scripts/check_ecosystem.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ruff-ecosystem/ruff_ecosystem/defaults.py b/python/ruff-ecosystem/ruff_ecosystem/defaults.py index 289073b2fc..a5dc724b19 100644 --- a/python/ruff-ecosystem/ruff_ecosystem/defaults.py +++ b/python/ruff-ecosystem/ruff_ecosystem/defaults.py @@ -56,7 +56,7 @@ Project(repo=Repository(owner="fronzbot", name="blinkpy", ref="dev")), Project(repo=Repository(owner="ibis-project", name="ibis", ref="main")), Project(repo=Repository(owner="ing-bank", name="probatus", ref="main")), - Project(repo=Repository(owner="jrnl-org", name="jrnl", ref="develop")), + Project(repo=Repository(owner="jrnl-org", name="jrnl", ref="main")), Project(repo=Repository(owner="langchain-ai", name="langchain", ref="master")), Project(repo=Repository(owner="latchbio", name="latch", ref="main")), Project(repo=Repository(owner="lnbits", name="lnbits", ref="main")), diff --git a/scripts/check_ecosystem.py b/scripts/check_ecosystem.py index 90b41e3b8a..739d60430a 100755 --- a/scripts/check_ecosystem.py +++ b/scripts/check_ecosystem.py @@ -136,7 +136,7 @@ async def _get_commit(self: Self, checkout_dir: Path) -> str: Repository("fronzbot", "blinkpy", "dev"), Repository("ibis-project", "ibis", "master"), Repository("ing-bank", "probatus", "main"), - Repository("jrnl-org", "jrnl", "develop"), + Repository("jrnl-org", "jrnl", "main"), Repository("langchain-ai", "langchain", "main"), Repository("latchbio", "latch", "main"), Repository("lnbits", "lnbits", "main"), From cc42899257d65cf8588fb427cd2fbfbf28d1ae00 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 20 Jul 2026 19:24:44 +0500 Subject: [PATCH 160/222] [`ruff`] Fix missing check on unrecognized early bound (`RUF016`) (#26986) ## Summary Noticed a bug in `invalid_index_type` - when index expression is a slice and one of the slice elements wasn't recognized, it was running `return` from `invalid_index_type` immediately. E.g. `[1, 2, 3][x:"y"]`, where `x` is a variable of unknown type, is a real bug - `"y"` cannot be used as slice upper bound, but since `x` is not recognized, it's a variable and we cannot get its type, code never reached `"y"`. This PR is just using `continue` for this case instead of `return`, so those issues are recognized too. ## Test Plan Ran `cargo test`, updated snapshots. Separated change in two commits - in first I just added a fixture and updated the snapshot (nothing is emitted - just new fixture appended to previous fixture context), in second I've made the change and updated snapshot. --- .../resources/test/fixtures/ruff/RUF016.py | 4 ++++ .../src/rules/ruff/rules/invalid_index_type.rs | 2 +- ..._linter__rules__ruff__tests__RUF016_RUF016.py.snap | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF016.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF016.py index 815456fc93..f115b43509 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF016.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF016.py @@ -128,3 +128,7 @@ def func(): # Should emit for invalid access using generator var = [1, 2, 3][(x for x in ())] + +# Should still emit for a later invalid bound, even if an earlier bound is unrecognized +x = "x" +var = [1, 2, 3][x:"y"] diff --git a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs index ca6f91ab05..f594af4f2e 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/invalid_index_type.rs @@ -108,7 +108,7 @@ pub(crate) fn invalid_index_type(checker: &Checker, expr: &ExprSubscript) { { for is_slice in [lower, upper, step].into_iter().flatten() { let Some(is_slice_type) = CheckableExprType::try_from(is_slice) else { - return; + continue; }; if is_slice_type.is_literal() { // If the index is a slice, require integer or null bounds diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap index 22917b601a..089bac11e0 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF016_RUF016.py.snap @@ -442,4 +442,15 @@ RUF016 Indexed access to type `list` uses type `generator` instead of an integer 129 | # Should emit for invalid access using generator 130 | var = [1, 2, 3][(x for x in ())] | ^^^^^^^^^^^^^^^ +131 | +132 | # Should still emit for a later invalid bound, even if an earlier bound is unrecognized + | + +RUF016 Slice in indexed access to type `list` uses type `str` instead of an integer + --> RUF016.py:134:19 + | +132 | # Should still emit for a later invalid bound, even if an earlier bound is unrecognized +133 | x = "x" +134 | var = [1, 2, 3][x:"y"] + | ^^^ | From 4fc9653b505dd7784f56afa574ccb11b602d675e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 12:19:08 -0400 Subject: [PATCH 161/222] [ty] Reuse applicable own-line suppressions in `--add-ignore` (#26925) ## Summary In #26808, we deliberately restricted `--add-ignore` to comments on the diagnostic's physical line, which meant that we ignored an existing own-line suppression even when it already applied to the diagnostic and added a second end-of-line suppression instead. So, in effect, we never added to own-line ignores. We now extend an editable suppression that already applies to the diagnostic. A same-line suppression wins when both same-line and own-line comments apply; otherwise, we prefer the innermost applicable own-line suppression. When diagnostics from multiple physical lines resolve to the same comment, we group their codes into one edit while retaining the number of diagnostics accounted for by that edit. ## Behavior ### 1. Reuse a same-line suppression ```python # Before value = missing # ty: ignore[division-by-zero] # After value = missing # ty: ignore[division-by-zero, unresolved-reference] ``` ### 2. Reuse an own-line suppression for the following physical line This applies to a comment inside an already-continued statement: ```python # Before values = [ # ty: ignore[] missing, ] # After values = [ # ty: ignore[unresolved-reference] missing, ] ``` ### 3. Reuse an own-line suppression for an entire logical statement A Python statement can span several physical lines. Because the preceding suppression applies throughout this statement, we add all three diagnostic codes to that one comment: ```python # Before # ty: ignore[invalid-assignment] values: tuple[int] = f( missing, "bad", ) # After # ty: ignore[invalid-assignment, invalid-argument-type, unresolved-reference] values: tuple[int] = f( missing, "bad", ) ``` ### 4. Add an end-of-line suppression when no editable suppression applies ```python # Before value = missing # After value = missing # ty:ignore[unresolved-reference] ``` ## Test plan Beyond our standard CI suite, reran the following against rebased head `788148ead4f8e85a29a480c92a1d4ca98e586dac`: - Ran `ty check --error all --add-ignore homeassistant` followed by `ty check --error all homeassistant` on [Home Assistant `1ce5ae3`](https://github.com/home-assistant/core/tree/1ce5ae3c0bea37dd3825ad57c8ba3e55266bed9b): 16,677 diagnostics suppressed and zero remaining. A second `--add-ignore` added zero comments and left the source unchanged. - Ran the same round trip with `--python-version 3.14` on [Airflow `d413861`](https://github.com/apache/airflow/tree/d413861ef7f080217ff70830461143cdce477e12): 31,513 diagnostics suppressed. The 152 pre-existing unused `type: ignore` comments were removed with `--fix`, leaving zero diagnostics; a second `--add-ignore` made no changes. - Cross-checked the open `ruff:ignore` issues with nested `# fmt: off`, trailing `# noqa`/`# pragma`, adjacent `# type: ignore`, trailing reasons, and multiline statements. - Ran the 49 focused add-ignore and suppression tests; all passed. - Confirmed the prior [ty ecosystem-analyzer run](https://github.com/astral-sh/ruff/actions/runs/29643524390) reports no diagnostic changes; the [rebased-head run](https://github.com/astral-sh/ruff/actions/runs/29701998795) is in progress. --- crates/ty/tests/cli/fixes.rs | 55 +++ crates/ty_ide/src/code_action.rs | 105 ++++- crates/ty_python_semantic/src/fixes.rs | 411 ++++++++++++++++-- crates/ty_python_semantic/src/suppression.rs | 89 +++- .../src/suppression/add_ignore.rs | 257 +++++------ 5 files changed, 737 insertions(+), 180 deletions(-) diff --git a/crates/ty/tests/cli/fixes.rs b/crates/ty/tests/cli/fixes.rs index b25816eb51..898369bc5d 100644 --- a/crates/ty/tests/cli/fixes.rs +++ b/crates/ty/tests/cli/fixes.rs @@ -46,6 +46,61 @@ fn add_ignore() -> anyhow::Result<()> { Ok(()) } +#[test] +fn add_ignore_keeps_nested_blanket_suppression_used() -> anyhow::Result<()> { + let case = CliTest::with_file( + "nested.py", + r#" + def f(value: int) -> int: + return value + + seen_code = True + # ty: ignore[] + values = [ + # ty: ignore[blanket-ignore-comment] + # ty: ignore + f("bad"), + # ty: ignore + missing, + ] + "#, + )?; + + assert_cmd_snapshot!( + case.command() + .arg("--add-ignore") + .arg("--warn") + .arg("blanket-ignore-comment"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + Added 1 ignore comment + + ----- stderr ----- + " + ); + + assert_snapshot!(fs::read_to_string(case.root().join("nested.py"))?, @r#" + + def f(value: int) -> int: + return value + + seen_code = True + # ty: ignore[blanket-ignore-comment] + values = [ + # ty: ignore[blanket-ignore-comment] + # ty: ignore + f("bad"), + # ty: ignore + missing, + ] + "#); + + Ok(()) +} + #[test] fn add_ignore_unfixable() -> anyhow::Result<()> { let case = CliTest::with_files([ diff --git a/crates/ty_ide/src/code_action.rs b/crates/ty_ide/src/code_action.rs index 582b254b34..908994a012 100644 --- a/crates/ty_ide/src/code_action.rs +++ b/crates/ty_ide/src/code_action.rs @@ -88,6 +88,7 @@ mod tests { use ruff_text_size::{TextRange, TextSize}; use ty_project::ProjectMetadata; use ty_python_semantic::{ + default_lint_registry, lint::LintMetadata, types::{UNDEFINED_REVEAL, UNRESOLVED_REFERENCE}, }; @@ -183,7 +184,7 @@ mod tests { } #[test] - fn add_ignore_does_not_update_preceding_own_line_suppression() { + fn add_ignore_updates_preceding_own_line_suppression() { let test = CodeActionTest::with_source( r#" seen_code = True @@ -200,9 +201,103 @@ mod tests { | ^ | | - 3 | # ty:ignore[] - - b = a / 10 - 4 + b = a / 10 # ty:ignore[unresolved-reference] + 2 | seen_code = True + - # ty:ignore[] + 3 + # ty:ignore[unresolved-reference] + 4 | b = a / 10 + | + "); + } + + #[test] + fn add_ignore_matches_existing_suppression_against_diagnostic_range() { + // The first suppression is intentional: `not-a-rule` has no indexed suppression, and + // repeatedly extending the final suppression can't suppress a diagnostic before it. + let test = CodeActionTest::with_source( + r#" + seen_code = True + # ty:ignore[] # ty:ignore[not-a-rule] # ty:ignore[division-by-zero] + value = 1 / 0 + "#, + ); + + let lint = default_lint_registry() + .get("ignore-comment-unknown-rule") + .unwrap(); + assert_snapshot!(test.code_actions(&lint), @" + info[code-action]: Ignore 'ignore-comment-unknown-rule' for this line + --> main.py:3:27 + | + 3 | # ty:ignore[] # ty:ignore[not-a-rule] # ty:ignore[division-by-zero] + | ^^^^^^^^^^ + | + | + 2 | seen_code = True + - # ty:ignore[] # ty:ignore[not-a-rule] # ty:ignore[division-by-zero] + 3 + # ty:ignore[ignore-comment-unknown-rule] # ty:ignore[not-a-rule] # ty:ignore[division-by-zero] + 4 | value = 1 / 0 + | + "); + } + + #[test] + fn add_ignore_does_not_make_nested_suppression_unused() { + let test = CodeActionTest::with_source( + r#" + seen_code = True + # ty:ignore[] + values = [ + # ty:ignore[unresolved-reference] + missing, + absent, + ] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:7:5 + | + 7 | absent, + | ^^^^^^ + | + | + 2 | seen_code = True + - # ty:ignore[] + 3 + # ty:ignore[unresolved-reference] + 4 | values = [ + | + "); + } + + #[test] + fn add_ignore_reuses_outer_suppression_with_nested_blanket() { + let test = CodeActionTest::with_source( + r#" + def f(value: int) -> int: return value + + seen_code = True + # ty:ignore[invalid-assignment] + values: tuple[int] = [ + # ty:ignore + f("bad"), + absent, + ] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:9:5 + | + 9 | absent, + | ^^^^^^ + | + | + 4 | seen_code = True + - # ty:ignore[invalid-assignment] + 5 + # ty:ignore[invalid-assignment, unresolved-reference] + 6 | values: tuple[int] = [ | "); } @@ -863,7 +958,7 @@ mod tests { } } - pub(super) fn code_actions(&self, lint: &'static LintMetadata) -> String { + pub(super) fn code_actions(&self, lint: &LintMetadata) -> String { use std::fmt::Write; let mut buf = String::new(); diff --git a/crates/ty_python_semantic/src/fixes.rs b/crates/ty_python_semantic/src/fixes.rs index 8c87573e13..3c967bddc3 100644 --- a/crates/ty_python_semantic/src/fixes.rs +++ b/crates/ty_python_semantic/src/fixes.rs @@ -1020,6 +1020,50 @@ mod tests { "#); } + #[test] + fn add_ignore_deduplicates_existing_edit_against_planned_start_line() { + assert_snapshot!( + suppress_all_in(r#" + from typing import TypeAlias + + JsonValue: TypeAlias = dict[str, "JsonValue"] | list["JsonValue"] | int + + + def get_data() -> dict[str, JsonValue]: + return {"home_assistant": {"entities": [{"entity_id": "sensor.test"}]}} + + + def f() -> None: + diag = get_data() + diag["home_assistant"]["entities"] = sorted( + diag["home_assistant"]["entities"], key=lambda ent: ent["entity_id"] + ); missing # ty: ignore[unresolved-reference] + "#), + @r#" + Added 4 suppressions + + ## Fixed source + + ```py + from typing import TypeAlias + + JsonValue: TypeAlias = dict[str, "JsonValue"] | list["JsonValue"] | int + + + def get_data() -> dict[str, JsonValue]: + return {"home_assistant": {"entities": [{"entity_id": "sensor.test"}]}} + + + def f() -> None: + diag = get_data() + diag["home_assistant"]["entities"] = sorted( # ty:ignore[invalid-assignment] + diag["home_assistant"]["entities"], key=lambda ent: ent["entity_id"] # ty:ignore[invalid-argument-type, not-subscriptable] + ); missing # ty: ignore[unresolved-reference] + ``` + "# + ); + } + #[test] fn return_type() { assert_snapshot!( @@ -1151,6 +1195,104 @@ class B(A): ); } + #[test] + fn add_ignore_prefers_editable_outer_suppression_over_inner_with_reason() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[] + values = [ + # ty: ignore[] tracked by [123] + missing, + ] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[unresolved-reference] + values = [ + # ty: ignore[] tracked by [123] + missing, + ] + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` without a code + --> test.py:4:5 + | + 2 | # ty: ignore[unresolved-reference] + 3 | values = [ + 4 | # ty: ignore[] tracked by [123] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 5 | missing, + 6 | ] + | + help: Remove the unused suppression comment + " + ); + } + + #[test] + fn add_ignore_matches_existing_suppression_against_diagnostic_range() { + // The first suppression is intentional: `not-a-rule` has no indexed suppression, and + // repeatedly extending the final suppression can't suppress a diagnostic before it. + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[] # ty: ignore[not-a-rule] # ty: ignore[division-by-zero] + value = 1 / 0 + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[ignore-comment-unknown-rule] # ty: ignore[not-a-rule] # ty: ignore[division-by-zero] + value = 1 / 0 + ``` + + ## Diagnostics after applying fixes + + warning[unused-ignore-comment]: Unused `ty: ignore` directive + --> test.py:2:68 + | + 1 | seen_code = True + 2 | # ty: ignore[ignore-comment-unknown-rule] # ty: ignore[not-a-rule] # ty: ignore[division-by-zero] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 3 | value = 1 / 0 + | + help: Remove the unused suppression comment + " + ); + } + + #[test] + fn add_ignore_prefers_same_line_suppression_over_outer_own_line() { + assert_snapshot!( + suppress_all_in(r#" + # ty: ignore[invalid-assignment] + values: tuple[int] = [missing] # ty: ignore[] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + # ty: ignore[invalid-assignment] + values: tuple[int] = [missing] # ty: ignore[unresolved-reference] + ``` + " + ); + } + #[test] fn add_ignore_groups_diagnostics_for_same_line_suppression() { assert_snapshot!( @@ -1186,7 +1328,37 @@ class B(A): } #[test] - fn add_ignore_does_not_update_inner_own_line_suppression() { + fn add_ignore_groups_suppression_matched_at_start_and_end() { + assert_snapshot!( + suppress_all_in(r#" + def f(a: int, b: int, c: int) -> None: ... + + f( + "a" + + # ty: ignore[] + "b", "c", missing + ) + "#), + @r#" + Added 3 suppressions + + ## Fixed source + + ```py + def f(a: int, b: int, c: int) -> None: ... + + f( + "a" + + # ty: ignore[invalid-argument-type, unresolved-reference] + "b", "c", missing + ) + ``` + "# + ); + } + + #[test] + fn add_ignore_updates_inner_own_line_suppression() { assert_snapshot!( suppress_all_in(r#" seen_code = True @@ -1203,30 +1375,16 @@ class B(A): ```py seen_code = True values = [ - # ty: ignore[] - missing, # ty:ignore[unresolved-reference] + # ty: ignore[unresolved-reference] + missing, ] ``` - - ## Diagnostics after applying fixes - - warning[unused-ignore-comment]: Unused `ty: ignore` without a code - --> test.py:3:5 - | - 1 | seen_code = True - 2 | values = [ - 3 | # ty: ignore[] - | ^^^^^^^^^^^^^^ - 4 | missing, # ty:ignore[unresolved-reference] - 5 | ] - | - help: Remove the unused suppression comment " ); } #[test] - fn add_ignore_does_not_update_preceding_own_line_suppressions() { + fn add_ignore_updates_preceding_own_line_suppressions() { assert_snapshot!( suppress_all_in(r#" seen_code = True @@ -1248,33 +1406,228 @@ class B(A): ```py seen_code = True - # ty: ignore[] - value = missing # ty:ignore[unresolved-reference] + # ty: ignore[unresolved-reference] + value = missing def f(a: int, b: int) -> list[int]: return [] - # ty: ignore[invalid-assignment] + # ty: ignore[invalid-assignment, invalid-argument-type, unresolved-reference] values: tuple[int] = f( - missing, # ty:ignore[unresolved-reference] - "bad", # ty:ignore[invalid-argument-type] + missing, + "bad", ) ``` + "# + ); + } + + #[test] + fn add_ignore_does_not_make_nested_suppression_unused() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[] + values = [ + # ty: ignore[unresolved-reference] + missing, + absent, + ] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[unresolved-reference] + values = [ + # ty: ignore[unresolved-reference] + missing, + absent, + ] + ``` + " + ); + } + + #[test] + fn add_ignore_keeps_disjoint_start_suppression_used() { + assert_snapshot!( + suppress_all_in(r#" + def f(a: int, b: int) -> None: pass + def g(a: int, b: int) -> int: return 0 + + f( # ty: ignore[missing-argument] + g(missing)) # ty: ignore[unresolved-reference] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + def f(a: int, b: int) -> None: pass + def g(a: int, b: int) -> int: return 0 + + f( # ty: ignore[missing-argument] + g(missing)) # ty: ignore[unresolved-reference, missing-argument] + ``` + " + ); + } + + #[test] + fn add_ignore_reconciles_nested_same_code_edits() { + assert_snapshot!( + suppress_all_in(r#" + def f(a: int, b: int, c: int) -> None: pass + def g(a: int, b: int) -> int: return 0 + + seen_code = True + # ty: ignore[unresolved-reference] + f( + missing, + g("bad")) # ty: ignore[invalid-argument-type] + "#), + @r#" + Added 2 suppressions + + ## Fixed source + + ```py + def f(a: int, b: int, c: int) -> None: pass + def g(a: int, b: int) -> int: return 0 + + seen_code = True + # ty: ignore[unresolved-reference] + f( + missing, + g("bad")) # ty: ignore[invalid-argument-type, missing-argument] + ``` + "# + ); + } + + #[test] + fn add_ignore_updates_nested_and_outer_suppressions() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[too-many-positional-arguments] + values = [ + # ty: ignore[invalid-argument-type] + missing, + absent, + ] + "#), + @" + Added 2 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[too-many-positional-arguments, unresolved-reference] + values = [ + # ty: ignore[invalid-argument-type, unresolved-reference] + missing, + absent, + ] + ``` ## Diagnostics after applying fixes - warning[unused-ignore-comment]: Unused `ty: ignore` without a code - --> test.py:2:1 + warning[unused-ignore-comment]: Unused `ty: ignore` directive: 'too-many-positional-arguments' + --> test.py:2:14 | 1 | seen_code = True - 2 | # ty: ignore[] - | ^^^^^^^^^^^^^^ - 3 | value = missing # ty:ignore[unresolved-reference] + 2 | # ty: ignore[too-many-positional-arguments, unresolved-reference] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 3 | values = [ + 4 | # ty: ignore[invalid-argument-type, unresolved-reference] | - help: Remove the unused suppression comment + help: Remove the unused suppression code + + warning[unused-ignore-comment]: Unused `ty: ignore` directive: 'invalid-argument-type' + --> test.py:4:18 + | + 2 | # ty: ignore[too-many-positional-arguments, unresolved-reference] + 3 | values = [ + 4 | # ty: ignore[invalid-argument-type, unresolved-reference] + | ^^^^^^^^^^^^^^^^^^^^^ + 5 | missing, + 6 | absent, + | + help: Remove the unused suppression code + " + ); + } + + #[test] + fn add_ignore_reuses_outer_suppression_with_nested_blanket() { + assert_snapshot!( + suppress_all_in(r#" + def f(value: int) -> int: return value + + seen_code = True + # ty: ignore[invalid-assignment] + values: tuple[int] = [ + # ty: ignore + f("bad"), + absent, + ] + "#), + @r#" + Added 1 suppressions + + ## Fixed source + + ```py + def f(value: int) -> int: return value + + seen_code = True + # ty: ignore[invalid-assignment, unresolved-reference] + values: tuple[int] = [ + # ty: ignore + f("bad"), + absent, + ] + ``` "# ); } + #[test] + fn add_ignore_keeps_nested_blanket_used_for_same_code() { + assert_snapshot!( + suppress_all_in(r#" + seen_code = True + # ty: ignore[] + values = [ + # ty: ignore + missing, + absent, + ] + "#), + @" + Added 1 suppressions + + ## Fixed source + + ```py + seen_code = True + # ty: ignore[unresolved-reference] + values = [ + # ty: ignore + missing, + absent, + ] + ``` + " + ); + } + /// Tests that the `fix_all` doesn't end up in an infinite loop /// if the fixes never converge and that it emits a diagnostic in that case. #[test] diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 6b7b5d9918..e9ebac6d57 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -358,25 +358,30 @@ impl Suppressions { ) -> impl Iterator + '_ { self.file .iter() - .chain(self.inline.intersecting_rev(range, id)) + .chain(self.inline.intersecting_rev( + range, + SuppressionTarget::All.target_mask() | SuppressionTarget::Lint(id).target_mask(), + )) .filter(move |suppression| suppression.matches(id) && suppression.applies_to(range)) } - /// Returns the inline suppressions whose comments are on `line_range`. - fn inline_suppressions_on_line( + /// Returns applicable comments whose targets allow `--add-ignore` to append a code, in + /// reverse source order. + /// + /// The interval index filters out blanket suppressions. Comments with multiple codes can + /// appear more than once, and callers must still exclude comments with trailing reasons. + fn editable_inline_suppressions_rev( &self, - line_range: TextRange, + range: TextRange, ) -> impl Iterator + '_ { - // The interval index retains source order, so comment ranges are also ordered by start. - let start = self - .inline - .entries - .partition_point(|entry| entry.suppression.comment_range.start() < line_range.start()); - - self.inline.entries[start..] - .iter() - .map(|entry| &entry.suppression) - .take_while(move |suppression| suppression.comment_range.start() < line_range.end()) + self.inline + .intersecting_rev(range, IntervalIndex::EDITABLE_MASK) + .filter(move |suppression| { + matches!( + suppression.target, + SuppressionTarget::Lint(_) | SuppressionTarget::Empty + ) && suppression.applies_to(range) + }) } fn iter(&self) -> impl Iterator { @@ -535,8 +540,9 @@ impl SuppressionTarget { /// Returns the conservative bit used to skip subtrees without this target. /// - /// Lints are hashed into 63 buckets, with one bucket reserved for blanket suppressions. The - /// interval index only traverses subtrees whose buckets overlap the queried lint. + /// Lints are hashed into 62 buckets, with one bucket reserved for blanket suppressions and + /// one for editable suppressions. The interval index only traverses subtrees whose buckets + /// overlap the queried lint. fn target_mask(self) -> u64 { match self { // Keep blanket suppressions separate so they are always considered. @@ -545,7 +551,8 @@ impl SuppressionTarget { SuppressionTarget::Lint(id) => { let mut hasher = FxHasher::default(); id.hash(&mut hasher); - 1 << (1 + hasher.finish() % 63) + // The top bit is reserved for editable suppressions. + 1 << (1 + hasher.finish() % 62) } } } @@ -790,6 +797,9 @@ struct IntervalEntry { } impl IntervalIndex { + /// Marks lint-specific and empty suppressions that `--add-ignore` can consider extending. + const EDITABLE_MASK: u64 = 1 << 63; + /// Builds an index from values sorted by interval start, retaining their input order. fn from_sorted(suppressions: Vec) -> Self { debug_assert!( @@ -800,7 +810,15 @@ impl IntervalIndex { .into_iter() .map(|suppression| IntervalEntry { subtree_max_end: suppression.suppressed_range.end(), - subtree_target_mask: suppression.target.target_mask(), + subtree_target_mask: suppression.target.target_mask() + | if matches!( + suppression.target, + SuppressionTarget::Lint(_) | SuppressionTarget::Empty + ) { + Self::EDITABLE_MASK + } else { + 0 + }, suppression, }) .collect::>(); @@ -835,10 +853,12 @@ impl IntervalIndex { /// Interval endpoints are treated as inclusive so that an empty diagnostic range at an /// interval boundary remains a candidate. Callers can apply stricter containment rules to the /// returned values. - fn intersecting_rev(&self, query: TextRange, id: LintId) -> impl Iterator { + fn intersecting_rev( + &self, + query: TextRange, + wanted: u64, + ) -> impl Iterator { let mut pending: SmallVec<[&[IntervalEntry]; 16]> = smallvec![self.entries.as_ref()]; - let wanted = - SuppressionTarget::All.target_mask() | SuppressionTarget::Lint(id).target_mask(); std::iter::from_fn(move || { while let Some(entries) = pending.pop() { @@ -928,4 +948,31 @@ value = missing 1 ); } + + #[test] + fn editable_index_skips_nested_blanket_suppressions() { + let source = r#"seen_code = True +# ty: ignore +# ty: ignore +# ty: ignore +# ty: ignore[] +value = missing +"#; + let db = TestDbBuilder::new() + .with_file("test.py", source) + .build() + .unwrap(); + let file = system_path_to_file(&db, "test.py").unwrap(); + let missing_start = source.find("missing").unwrap().try_into().unwrap(); + let missing_range = TextRange::at(missing_start, "missing".text_len()); + + let suppressions = suppressions(&db, file); + assert_eq!(suppressions.inline.len(), 4); + assert_eq!( + suppressions + .editable_inline_suppressions_rev(missing_range) + .count(), + 1 + ); + } } diff --git a/crates/ty_python_semantic/src/suppression/add_ignore.rs b/crates/ty_python_semantic/src/suppression/add_ignore.rs index 8d31e4c744..f2b672e57c 100644 --- a/crates/ty_python_semantic/src/suppression/add_ignore.rs +++ b/crates/ty_python_semantic/src/suppression/add_ignore.rs @@ -1,3 +1,11 @@ +//! Helpers for adding suppression comments without changing which existing suppression is used. +//! +//! An applicable same-line or nested own-line suppression is extended first because it has the +//! narrowest scope. For diagnostics spanning multiple lines, an opening-line suppression takes +//! precedence over a separate closing-line suppression, matching normal suppression resolution. +//! Comments with trailing reasons are never extended: preserving the reason requires adding a +//! separate suppression instead. + use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Formatter; @@ -8,19 +16,21 @@ use ruff_db::parsed::parsed_module; use ruff_db::source::source_text; use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::token::TokenKind; -use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use smallvec::SmallVec; use crate::Db; use crate::lint::LintId; -use crate::suppression::{SuppressionKind, SuppressionTarget, Suppressions, suppressions}; +use crate::suppression::{ + SuppressionKind, Suppressions, select_preferred_suppression, suppressions, +}; /// Creates fixes to suppress all violations in `ids_with_range`. /// -/// This is different from calling `suppress_single` for every item in `ids_with_range` -/// in that errors on the same line are grouped together and ty will only insert a single -/// suppression with possibly multiple codes instead of adding multiple suppression comments. +/// Unlike calling [`suppress_single`] for each diagnostic, this groups diagnostics that can share +/// an edit. It appends codes once to each applicable existing suppression and otherwise inserts at +/// most one end-of-line suppression at each destination. Every returned [`SuppressFix`] records +/// how many diagnostics its edit accounts for. pub fn suppress_all( db: &dyn Db, file: File, @@ -31,10 +41,17 @@ pub fn suppress_all( let parsed = parsed_module(db, file).load(db); let tokens = parsed.tokens(); - // Compute the full suppression ranges for each diagnostic. - let mut ids_full_range: Vec<_> = ids_with_range + // Compute the full suppression ranges for each diagnostic, while retaining the original + // diagnostic ranges for matching existing suppressions. + let mut ids_with_suppression_range: Vec<_> = ids_with_range .iter() - .map(|&(id, range)| (id, suppression_range(db, file, range))) + .map(|&(id, diagnostic_range)| { + ( + id, + diagnostic_range, + suppression_range(db, file, diagnostic_range), + ) + }) .collect(); // Sort the suppression ranges by their start position and length (end position). @@ -54,92 +71,86 @@ pub fn suppress_all( // can result in a start-line suppression for a wider range. In the example above, // inserting a `ty:ignore` after `sorted(` suppresses the diagnostic with the narrower range // but also the diagnostic with the wider range (because the suppression is on its start line). - ids_full_range.sort_unstable_by_key(|(_, range)| (range.start(), range.end())); - - // 1. Group the diagnostics by their line-start position and try to add - // the suppression to an existing `ty: ignore` comment on that line. - let mut by_start: BTreeMap<_, (BTreeSet, usize)> = BTreeMap::new(); - - for &(id, range) in &ids_full_range { - let (lints, suppressed_diagnostics) = by_start.entry(range.start()).or_default(); - lints.insert(id); - *suppressed_diagnostics += 1; + ids_with_suppression_range.sort_unstable_by_key(|(_, _, range)| (range.start(), range.end())); + + let mut fixes = Vec::with_capacity(ids_with_suppression_range.len()); + let mut with_existing = Vec::new(); + let mut without_existing = Vec::new(); + + // Choose the final existing suppression for every diagnostic before grouping any edits. + for (id, diagnostic_range, suppression_range) in ids_with_suppression_range { + if let Some(existing) = find_existing_suppression(suppressions, &source, diagnostic_range) { + with_existing.push((id, suppression_range, existing)); + } else { + without_existing.push((id, suppression_range)); + } } - let mut fixes = Vec::with_capacity(ids_full_range.len()); + // Tracks newly inserted end-of-line suppressions by the physical line where they become start + // suppressions. This avoids inserting another suppression for a wider same-code diagnostic + // that starts on that line (see the example above). + let mut by_line = BTreeMap::>::new(); + let mut by_end: BTreeMap, usize)> = BTreeMap::new(); - // Tracks which lints get inserted by line. The offset is the line's start offset. - // This is necessary to avoid inserting an end of line suppression if the diagnostic - // was suppressed by inserting a suppression on its start line. - // This also allows deduplicating suppressions for diagnostics with different ranges - // where an end-suppression of one diagnostic becomes a start-suppression for another - // (see the example with the wider range above). - let mut by_line = BTreeMap::>::new(); + for (id, range) in without_existing { + let existing_end = by_line + .get(&range.start()) + .and_then(|planned| planned.get(&id)) + .copied(); - for (start_offset, (lints, suppressed_diagnostics)) in by_start { - let codes: SmallVec<[LintName; 2]> = lints.into_iter().collect(); - if let Some(add_to_start) = - add_to_existing_suppression(suppressions, &source, &codes, start_offset) - { - by_line.entry(start_offset).or_default().extend( - codes - .iter() - .copied() - .map(|code| (code, SuppressionPosition::StartLine)), - ); - fixes.push(SuppressFix { - fix: add_to_start, - suppressed_diagnostics, - }); + if let Some(end_offset) = existing_end { + let (_, suppressed_diagnostics) = by_end.entry(end_offset).or_default(); + *suppressed_diagnostics += 1; + continue; } + + let (lints, suppressed_diagnostics) = by_end.entry(range.end()).or_default(); + lints.insert(id); + *suppressed_diagnostics += 1; + + by_line + .entry(line_start(tokens, range.end())) + .or_default() + .entry(id) + .or_insert(range.end()); } - // 2. Group the diagnostics by their end position and try to add the code to an - // existing `ty: ignore` comment or insert a new `ty: ignore` comment. - let mut by_end: BTreeMap, usize)> = BTreeMap::new(); + let mut by_suppression = + BTreeMap::, usize)>::new(); - for (id, range) in ids_full_range { - let suppression_position = by_line + // Reconcile existing-comment edits after planning new suppressions. A new suppression inserted + // at the end of a narrower range can cover the start of a wider diagnostic and make an edit to + // the wider diagnostic's existing end-line suppression immediately unused. + for (id, range, existing) in with_existing { + if let Some(end_offset) = by_line .get(&range.start()) .and_then(|planned| planned.get(&id)) - .copied(); - - match suppression_position { - // Start-line suppressions already include all diagnostics that start on the same line. - Some(SuppressionPosition::StartLine) => {} - - // If coverage comes from an other end-line suppression, count this diagnostic on that fix. - Some(SuppressionPosition::EndLine(end_offset)) => { - let (_, suppressed_diagnostics) = by_end.entry(end_offset).or_default(); - *suppressed_diagnostics += 1; - } - - None => { - let (lints, suppressed_diagnostics) = by_end.entry(range.end()).or_default(); - lints.insert(id); - *suppressed_diagnostics += 1; - - // Record the physical line where this end-line suppression will be inserted so wider - // same-code ranges starting there can be recognized as already covered. - by_line - .entry(line_start(tokens, range.end())) - .or_default() - .entry(id) - .or_insert(SuppressionPosition::EndLine(range.end())); - } + { + let (_, suppressed_diagnostics) = by_end.entry(*end_offset).or_default(); + *suppressed_diagnostics += 1; + continue; } + + let insertion_offset = existing.insertion_offset; + let (_, grouped_codes, grouped_diagnostics) = by_suppression + .entry(insertion_offset) + .or_insert_with(|| (existing, BTreeSet::new(), 0)); + grouped_codes.insert(id); + *grouped_diagnostics += 1; } for (end_offset, (lints, suppressed_diagnostics)) in by_end { let codes: SmallVec<[LintName; 2]> = lints.into_iter().collect(); + fixes.push(SuppressFix { + fix: add_end_of_line_suppression(&source, &codes, end_offset), + suppressed_diagnostics, + }); + } + for (existing, codes, suppressed_diagnostics) in by_suppression.into_values() { + let codes: SmallVec<[LintName; 2]> = codes.into_iter().collect(); fixes.push(SuppressFix { - fix: append_to_existing_or_add_end_of_line_suppression( - suppressions, - &source, - &codes, - end_offset, - ), + fix: add_to_existing_suppression(existing, &codes), suppressed_diagnostics, }); } @@ -147,12 +158,6 @@ pub fn suppress_all( fixes } -#[derive(Copy, Clone)] -enum SuppressionPosition { - StartLine, - EndLine(TextSize), -} - /// Fix to suppress one or more diagnostics. pub struct SuppressFix { pub fix: Fix, @@ -168,18 +173,11 @@ pub fn suppress_single(db: &dyn Db, file: File, id: LintId, range: TextRange) -> let source = source_text(db, file); let codes = &[id.name()]; - if let Some(add_fix) = - add_to_existing_suppression(suppressions, &source, codes, suppression_range.start()) - { - return add_fix; + if let Some(existing) = find_existing_suppression(suppressions, &source, range) { + return add_to_existing_suppression(existing, codes); } - append_to_existing_or_add_end_of_line_suppression( - suppressions, - &source, - codes, - suppression_range.end(), - ) + add_end_of_line_suppression(&source, codes, suppression_range.end()) } /// Returns the suppression range for the given `range`. @@ -235,16 +233,7 @@ fn line_start(tokens: &ruff_python_ast::token::Tokens, offset: TextSize) -> Text .unwrap_or_default() } -fn append_to_existing_or_add_end_of_line_suppression( - suppressions: &Suppressions, - source: &str, - codes: &[LintName], - line_end: TextSize, -) -> Fix { - if let Some(add_fix) = add_to_existing_suppression(suppressions, source, codes, line_end) { - return add_fix; - } - +fn add_end_of_line_suppression(source: &str, codes: &[LintName], line_end: TextSize) -> Fix { let up_to_line_end = &source[..line_end.to_usize()]; // Don't use `trim_end` in case the previous line ends with a `\` followed by a newline. We don't want to eat // into that newline! @@ -266,38 +255,49 @@ fn append_to_existing_or_add_end_of_line_suppression( }) } -fn add_to_existing_suppression( +/// Returns insertion metadata for the preferred editable suppression covering `range`. +/// +/// When multiple comments apply, a same-line or otherwise nested comment takes precedence over an +/// outer own-line suppression. +/// +/// ```python +/// # ty: ignore[invalid-assignment] +/// values: tuple[int] = [missing] # ty: ignore[] +/// ``` +fn find_existing_suppression( suppressions: &Suppressions, source: &str, - codes: &[LintName], - offset: TextSize, -) -> Option { - let existing = suppressions - .inline_suppressions_on_line(source.line_range(offset)) - .find(|suppression| { - matches!( - suppression.target, - SuppressionTarget::Lint(_) | SuppressionTarget::Empty, - ) - })?; - let comment_text = &source[existing.comment_range]; - - let up_to_last_code = editable_suppression_prefix(comment_text)?; - let separator = if up_to_last_code.ends_with('[') { + range: TextRange, +) -> Option { + let suppression = select_preferred_suppression( + suppressions + .editable_inline_suppressions_rev(range) + .filter(|suppression| { + editable_suppression_prefix(&source[suppression.comment_range]).is_some() + }), + range, + )?; + let prefix = editable_suppression_prefix(&source[suppression.comment_range])?; + let separator = if prefix.ends_with('[') { "" - } else if up_to_last_code.ends_with(',') { + } else if prefix.ends_with(',') { " " } else { ", " }; - let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len(); + Some(ExistingSuppression { + insertion_offset: suppression.comment_range.start() + prefix.text_len(), + kind: suppression.kind, + separator, + }) +} + +fn add_to_existing_suppression(existing: ExistingSuppression, codes: &[LintName]) -> Fix { + let separator = existing.separator; let insertion = format!("{separator}{codes}", codes = Codes(existing.kind, codes)); - Some(Fix::safe_edit(Edit::insertion( - insertion, - existing.comment_range.end() - relative_offset_from_end, - ))) + Fix::safe_edit(Edit::insertion(insertion, existing.insertion_offset)) } /// Returns the portion of an ignore comment before its closing bracket if another code can be @@ -317,6 +317,13 @@ fn editable_suppression_prefix(comment_text: &str) -> Option<&str> { .then(|| before_closing_bracket.trim_end()) } +#[derive(Copy, Clone)] +struct ExistingSuppression { + insertion_offset: TextSize, + kind: SuppressionKind, + separator: &'static str, +} + struct Codes<'a>(SuppressionKind, &'a [LintName]); impl std::fmt::Display for Codes<'_> { From b466ee96d875f87d14efb6dcfa01e0e05a3c7377 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 13:18:32 -0400 Subject: [PATCH 162/222] [ty] Respect metaclass `__setattr__` for class attributes (#27000) ## Summary Prior to this change, we honored instance-level `__setattr__` methods for otherwise-undefined attributes, but ignored the corresponding method on a metaclass when assigning a class attribute. Valid assignments through either a class literal or `type[C]` therefore produced `unresolved-attribute`: ```py class Meta(type): def __setattr__(cls, name: str, value: int) -> None: ... class C(metaclass=Meta): ... def f(cls: type[C]) -> None: cls.anything = 5 # Previously: unresolved-attribute ``` This checks the metaclass `__setattr__` when class-attribute lookup is unresolved, validates the assigned value against its signature, and preserves the existing error for classes without a custom metaclass. (We're still not respecting this everywhere we should be, but I erred on the side of keeping this PR small and documenting lack of support with TODO tests.) Closes https://github.com/astral-sh/ty/issues/3845. --- .../resources/mdtest/attributes.md | 108 ++++++++++++++++++ .../resources/mdtest/protocols.md | 99 +++++++++++++++- .../src/types/infer/builder.rs | 5 +- .../infer/builder/attribute_assignment.rs | 76 ++++++++++-- .../src/types/infer/builder/subscript.rs | 1 + 5 files changed, 274 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 867a6a7373..e91949a968 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -3125,6 +3125,114 @@ def _(obj: Immutable) -> None: obj.x = 42 ``` +## Metaclasses with custom `__setattr__` methods + +A class is an instance of its metaclass. If an attribute is not defined on the class, the +metaclass's `__setattr__` method determines which values can be assigned: + +```py +class Meta(type): + def __setattr__(cls, name: str, value: int) -> None: ... + +class Foo(metaclass=Meta): ... + +Foo.whatever = 42 +Foo.whatever = "invalid" # error: [unresolved-attribute] "with custom `__setattr__` method" +``` + +If both the metaclass and class define `__setattr__`, class-object assignments use the metaclass +method and instance assignments use the class method: + +```py +class WithSetAttr(metaclass=Meta): + def __setattr__(self, name: str, value: str) -> None: ... + +WithSetAttr.class_attribute = 42 +WithSetAttr.class_attribute = "invalid" # error: [unresolved-attribute] "with custom `__setattr__` method" + +instance = WithSetAttr() +instance.instance_attribute = "valid" +instance.instance_attribute = 42 # error: [unresolved-attribute] "with custom `__setattr__` method" +``` + +The same applies when the class object is annotated as `type[Foo]`: + +```py +def set_on_subclass(cls: type[Foo]) -> None: + cls.whatever = 42 + cls.whatever = "invalid" # error: [unresolved-attribute] "with custom `__setattr__` method" +``` + +The setter also provides the expected type when inferring the assigned value: + +```py +from typing import Callable, TypedDict + +class Payload(TypedDict): + value: int + +class ContextMeta(type): + def __setattr__(cls, name: str, value: Callable[[int], int] | Payload) -> None: ... + +class ContextClass(metaclass=ContextMeta): ... + +ContextClass.callback = lambda number: ( + # error: [unresolved-attribute] "Object of type `int` has no attribute `missing`" + number.missing +) +ContextClass.payload = {"value": 1} +``` + +Name-specific overloads select the expected type based on the assigned attribute: + +```py +from typing import Any, Literal, overload + +class OverloadedMeta(type): + @overload + def __setattr__(cls, name: Literal["callback"], value: Callable[[int], int]) -> None: ... + @overload + def __setattr__(cls, name: Literal["payload"], value: Payload) -> None: ... + @overload + def __setattr__(cls, name: str, value: int, /) -> None: ... + def __setattr__(cls, name: str, value: Any) -> None: ... + +class OverloadedClass(metaclass=OverloadedMeta): ... + +OverloadedClass.callback = lambda number: ( + # error: [unresolved-attribute] "Object of type `int` has no attribute `missing`" + number.missing +) +OverloadedClass.payload = {"value": 1} +OverloadedClass.callback = {"value": 1} # error: [unresolved-attribute] "with custom `__setattr__` method" +``` + +A metaclass `__setattr__` method returning `Never` prevents writes to undefined attributes: + +```py +from typing_extensions import Never + +class FrozenMeta(type): + def __setattr__(cls, name: str, value: object) -> Never: + raise AttributeError("immutable") + +class Frozen(metaclass=FrozenMeta): + existing: int = 1 + +Frozen.new = 1 # error: [invalid-assignment] "Cannot assign to unresolved attribute `new`" + +# TODO: terminal setters should also prevent writes to declared attributes. +Frozen.existing = 2 +``` + +A class without a custom metaclass still produces an error for an unknown attribute: + +```py +class Regular: ... + +Regular.whatever = 42 # error: [unresolved-attribute] +``` + ## Objects of all types have a `__class__` method The type of `x.__class__` is the same as `x`'s meta-type. `x.__class__` is always the same value as diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 9853202d8a..1adea03e6f 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2448,10 +2448,103 @@ class HasSetAttrWithUnsuitableInput: static_assert(not is_subtype_of(HasSetAttrWithUnsuitableInput, HasMutableXProperty)) static_assert(not is_assignable_to(HasSetAttrWithUnsuitableInput, HasMutableXProperty)) +``` + +A class object should also satisfy a writable property protocol using `__getattr__` and +`__setattr__` on its metaclass: + +```py +class MetaWithGetAttrAndSetAttr(type): + def __getattr__(cls, attr: str) -> int: + return 1 + + def __setattr__(cls, attr: str, value: int) -> None: ... + +class ClassWithDynamicX(metaclass=MetaWithGetAttrAndSetAttr): ... + +ClassWithDynamicX.x = 1 +# TODO: this should pass once metaclass setters are considered for protocol writes. +dynamic_x: HasMutableXProperty = ClassWithDynamicX # error: [invalid-assignment] +``` + +Once metaclass setters are considered, one with an incompatible value type must still be rejected: + +```py +class MetaWithUnsuitableSetAttr(type): + def __getattr__(cls, attr: str) -> int: + return 1 -# For static checking, an explicit attribute declaration takes precedence over `__setattr__`. -# This matches other type checkers and likely user intent, even though a custom `__setattr__` -# intercepts every assignment at runtime. + def __setattr__(cls, attr: str, value: str) -> None: ... + +class ClassWithUnsuitableSetAttr(metaclass=MetaWithUnsuitableSetAttr): ... + +unsuitable_x: HasMutableXProperty = ClassWithUnsuitableSetAttr # error: [invalid-assignment] +``` + +A terminal metaclass setter should prevent a class object from satisfying a writable property +protocol even when the attribute is declared: + +```py +class MetaWithTerminalSetAttr(type): + def __setattr__(cls, attr: str, value: int) -> Never: + raise AttributeError("immutable") + +class ClassWithTerminalSetAttr(metaclass=MetaWithTerminalSetAttr): + x: int = 1 + +# TODO: terminal setters should prevent all writes. +terminal_x: HasMutableXProperty = ClassWithTerminalSetAttr +``` + +Once metaclass setters are considered, an overload for a different attribute must not make the class +satisfy the protocol: + +```py +from typing import Literal +from ty_extensions._internal import TypeOf + +class MetaWithOverloadedSetAttr(type): + def __getattr__(cls, attr: str) -> int: + return 1 + + @overload + def __setattr__(cls, attr: Literal["x"], value: Any) -> None: ... + @overload + def __setattr__(cls, attr: Literal["y"], value: int) -> None: ... + # error: [invalid-method-override] + def __setattr__(cls, attr: str, value: object) -> None: ... + +class ClassWithOverloadedSetAttr(metaclass=MetaWithOverloadedSetAttr): ... + +ClassWithOverloadedSetAttr.x = 1 +static_assert(not is_subtype_of(TypeOf[ClassWithOverloadedSetAttr], HasMutableXProperty)) +``` + +A generic metaclass setter can satisfy a writable property protocol: + +```py +from typing import TypeVar + +T = TypeVar("T") + +class MetaWithGenericSetAttr(type): + def __getattr__(cls, attr: str) -> int: + return 1 + + def __setattr__(cls, attr: str, value: T) -> None: ... + +class ClassWithGenericSetAttr(metaclass=MetaWithGenericSetAttr): ... + +ClassWithGenericSetAttr.x = 1 +# TODO: this should pass once metaclass setters are considered for protocol writes. +generic_x: HasMutableXProperty = ClassWithGenericSetAttr # error: [invalid-assignment] +``` + +For static checking, an explicit attribute declaration takes precedence over `__setattr__`. This +matches other type checkers and likely user intent, even though a custom `__setattr__` intercepts +every assignment at runtime: + +```py class ExplicitXWithBroadSetAttr: x: int diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1b61def515..c8e7219dfd 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3104,6 +3104,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.validate_attribute_assignment( attr_expr, + value, object_ty, attr.id(), infer_assigned_ty, @@ -4441,6 +4442,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { db, target_type, op.in_place_dunder(), + MemberLookupPolicy::NO_INSTANCE_FALLBACK, ArgumentsIter::synthesized(&ast_arguments), &mut call_arguments, &mut |builder, (_, _, tcx)| infer_value_ty(builder, tcx), @@ -4982,13 +4984,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { db: &'db dyn Db, object: Type<'db>, name: &str, + lookup_policy: MemberLookupPolicy, ast_arguments: ArgumentsIter<'_>, argument_types: &mut CallArguments<'_, 'db>, infer_argument_ty: &mut dyn FnMut(&mut Self, ArgExpr<'db, '_>) -> Type<'db>, call_expression_tcx: TypeContext<'db>, ) -> Result, CallDunderError<'db>> { match object - .member_lookup_with_policy(db, name, MemberLookupPolicy::NO_INSTANCE_FALLBACK) + .member_lookup_with_policy(db, name, lookup_policy) .place { Place::Defined(DefinedPlace { diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs index acef3233af..050df0463e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -1,7 +1,7 @@ use ruff_python_ast as ast; use ruff_text_size::Ranged; -use super::{MultiInferenceGuard, TypeInferenceBuilder}; +use super::{ArgumentsIter, MultiInferenceGuard, TypeInferenceBuilder}; use crate::place::{DefinedPlace, Place, PlaceAndQualifiers}; use crate::types::attribute_write::{ AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, @@ -19,11 +19,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// Make sure that the attribute assignment `obj.attribute = value` is valid. /// /// `target` is the node for the left-hand side, `object_ty` is the type of `obj`, `attribute` is - /// the name of the attribute being assigned, and `value_ty` is the type of the right-hand side of - /// the assignment. If the assignment is invalid, emit diagnostics. + /// the name of the attribute being assigned, `value` is the right-hand side, and `infer_value_ty` + /// infers its type with the supplied context. If the assignment is invalid, emit diagnostics. pub(super) fn validate_attribute_assignment( &mut self, target: &ast::ExprAttribute, + value: &ast::Expr, object_ty: Type<'db>, attribute: &str, infer_value_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, @@ -33,6 +34,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let mut evaluator = AssignmentAttributeWriteEvaluator { builder: self, target, + value, object_ty, attribute, infer_value_ty: MultiInferenceGuard::new(infer_value_ty), @@ -73,6 +75,7 @@ enum ContextualInference { struct AssignmentAttributeWriteEvaluator<'a, 'db, 'ast, 'infer> { builder: &'a mut TypeInferenceBuilder<'db, 'ast>, target: &'a ast::ExprAttribute, + value: &'a ast::Expr, object_ty: Type<'db>, attribute: &'a str, infer_value_ty: MultiInferenceGuard<'db, 'ast, 'infer>, @@ -409,15 +412,66 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { ClassAttributeWriteMember::Unresolved { has_instance_attribute, } => { - self.infer_value(TypeContext::default(), emit_diagnostics); - if emit_diagnostics { - self.report(if *has_instance_attribute { - AssignmentAttributeWriteDiagnostic::CannotAssignToInstanceAttribute - } else { - AssignmentAttributeWriteDiagnostic::Unresolved { with_period: true } - }); + let db = self.builder.db(); + let name_ty = Type::string_literal(db, self.attribute); + let ast_arguments = [ + ast::ArgOrKeyword::Arg(self.target.value.as_ref()), + ast::ArgOrKeyword::Arg(self.value), + ]; + let mut call_arguments = CallArguments::positional([name_ty, Type::unknown()]); + let setattr_result = self.builder.infer_and_try_call_dunder( + db, + object_ty, + "__setattr__", + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK + | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ArgumentsIter::synthesized(&ast_arguments), + &mut call_arguments, + &mut |builder, (argument_index, _, tcx)| { + if argument_index == 0 { + name_ty + } else { + self.infer_value_ty.infer_silent(builder, tcx) + } + }, + TypeContext::default(), + ); + let value_ty = self.infer_with_last_context(emit_diagnostics); + let setattr_returns_never = match &setattr_result { + Ok(bindings) => bindings.return_type(db).is_never(), + Err(error) => error.return_type(db).is_some_and(|ty| ty.is_never()), + }; + if setattr_returns_never { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::TerminalSetAttr { + member_exists: false, + is_setattr_synthesized: false, + }); + } + return false; + } + + match setattr_result { + Ok(_) | Err(CallDunderError::PossiblyUnbound { .. }) => true, + Err(CallDunderError::CallError(..)) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::BadSetAttr { + value_ty, + }); + } + false + } + Err(CallDunderError::MethodNotAvailable) => { + if emit_diagnostics { + self.report(if *has_instance_attribute { + AssignmentAttributeWriteDiagnostic::CannotAssignToInstanceAttribute + } else { + AssignmentAttributeWriteDiagnostic::Unresolved { with_period: true } + }); + } + false + } } - false } } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index cfc984a1e1..514cd3c7f9 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1840,6 +1840,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { db, object_ty, "__setitem__", + MemberLookupPolicy::NO_INSTANCE_FALLBACK, ArgumentsIter::synthesized(&ast_arguments), &mut call_arguments, &mut infer_argument_ty, From 5101fab87799f6beace99ab578fe5f008d475664 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 14:24:43 -0400 Subject: [PATCH 163/222] [ty] Remove unused own-line ignore comments (#27013) ## Summary Follow-up to [the review discussion in #27006](https://github.com/astral-sh/ruff/pull/27006#discussion_r3615478685). Prior to this change, removing an unused `ty: ignore` on its own line deleted the directive and its indentation but preserved the newline, leaving an empty line behind. We now delete the full line, matching Ruff's behavior while preserving the existing handling for inline ignores and directives followed by another pragma. ```python # Before values = [ # ty: ignore[] 1, ] # After values = [ 1, ] ``` The CLI-fix regression and the relevant suppression tests pass. --- crates/ty/tests/cli/fixes.rs | 14 +++++++++++--- .../ty_python_semantic/src/suppression/unused.rs | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ty/tests/cli/fixes.rs b/crates/ty/tests/cli/fixes.rs index 898369bc5d..6f5f3233fd 100644 --- a/crates/ty/tests/cli/fixes.rs +++ b/crates/ty/tests/cli/fixes.rs @@ -172,16 +172,20 @@ fn fix() -> anyhow::Result<()> { "unused_ignore.py", r#" x = 1 # ty: ignore[unresolved-reference] + values = [ + # ty: ignore[] + 1, + ] "#, )?; assert_cmd_snapshot!( case.command().arg("--fix").arg("--warn").arg("unused-ignore-comment"), - @r" + @" success: true exit_code: 0 ----- stdout ----- - Found 1 diagnostic (1 fixed, 0 remaining). + Found 2 diagnostics (2 fixed, 0 remaining). ----- stderr ----- " @@ -189,8 +193,12 @@ fn fix() -> anyhow::Result<()> { assert_snapshot!( fs::read_to_string(case.root().join("unused_ignore.py"))?, - @r" + @" + x = 1 + values = [ + 1, + ] " ); diff --git a/crates/ty_python_semantic/src/suppression/unused.rs b/crates/ty_python_semantic/src/suppression/unused.rs index 8e681b5e98..5dece2b6bc 100644 --- a/crates/ty_python_semantic/src/suppression/unused.rs +++ b/crates/ty_python_semantic/src/suppression/unused.rs @@ -1,6 +1,7 @@ use ruff_db::source::source_text; use ruff_diagnostics::{Edit, Fix}; use ruff_python_trivia::indentation_at_offset; +use ruff_source_file::LineRanges; use ruff_text_size::{TextLen, TextRange, TextSize}; use std::fmt::Write as _; @@ -224,6 +225,10 @@ fn remove_comment_fix(suppression: &Suppression, source: &str) -> Fix { return Fix::safe_edit(edit); } + if indentation_at_offset(comment_start, source).is_some() { + return Fix::safe_edit(Edit::range_deletion(source.full_line_range(comment_start))); + } + // Remove any leading whitespace before the comment // to avoid unnecessary trailing whitespace once the comment is removed let before_comment = &source[..comment_start.to_usize()]; From c4b8749168cac93dcc228d3efb714838b75af784 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 20 Jul 2026 12:11:01 -0700 Subject: [PATCH 164/222] [ty] Fix nested short-circuit flow snapshots (#26956) ## Summary Fixes astral-sh/ty#4025. Nested boolean expressions were split from their already-merged flow state, so an impossible unbound path from an inner short circuit could reach an outer condition body. Compose the inner truthy/falsy snapshots when evaluating each operand, including the final operand, and restore the merged post-expression state afterward. This removes false `possibly-unresolved-reference` diagnostics for mutually exclusive walrus assignments while preserving genuine short-circuit warnings. ## Test plan Added mdtests. --- crates/ty_python_core/src/builder.rs | 56 +++++++++++++++++-- .../resources/mdtest/boolean/short_circuit.md | 42 ++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 9e42c7e762..36a8e264fd 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -206,6 +206,15 @@ struct ConditionFlowSnapshots { falsy: FlowSnapshot, } +impl ConditionFlowSnapshots { + fn into_short_circuit_and_continuation(self, op: ast::BoolOp) -> (FlowSnapshot, FlowSnapshot) { + match op { + ast::BoolOp::And => (self.falsy, self.truthy), + ast::BoolOp::Or => (self.truthy, self.falsy), + } + } +} + enum ConditionFlowSnapshot { Fallback, Branches(ConditionFlowSnapshots), @@ -4572,6 +4581,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { }) => { let mut snapshots = vec![]; let mut reachability_constraints = vec![]; + let mut last_condition_flow_snapshots = None; for (index, value) in values.iter().enumerate() { for id in &reachability_constraints { @@ -4584,9 +4594,10 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { .record_range_reachability(value.range(), in_type_checking_block); self.visit_expr(value); - // For the last value, we don't need to model control flow. There is no short-circuiting - // anymore. + // Only non-final values can short-circuit this boolean operation. The final + // value can still have its own outcome-specific flow if it is nested. if index < values.len() - 1 { + let condition_flow_snapshots = self.take_condition_flow_snapshots(value); let predicate = self.build_predicate(value); let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); let predicate_id = match op { @@ -4597,7 +4608,15 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { .current_reachability_constraints_mut() .add_atom(predicate_id); - let after_expr = self.flow_snapshot(); + let continuation = + if let Some(condition_flow_snapshots) = condition_flow_snapshots { + let (short_circuit, continuation) = condition_flow_snapshots + .into_short_circuit_and_continuation(*op); + self.flow_restore(short_circuit); + continuation + } else { + self.flow_snapshot() + }; // We first model the short-circuiting behavior. We take the short-circuit // path here if all of the previous short-circuit paths were not taken, so @@ -4610,17 +4629,34 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { // Then we model the non-short-circuiting behavior. Here, we need to delay // the application of the reachability constraint until after the expression // has been evaluated, so we only push it onto the stack here. - self.flow_restore(after_expr); + self.flow_restore(continuation); self.record_narrowing_constraint_id_for_places( predicate_id, &possibly_narrowed, ); reachability_constraints.push(reachability_constraint); + } else { + last_condition_flow_snapshots = self.take_condition_flow_snapshots(value); } } - let no_short_circuit = - any_over_expr(expr, &ast::Expr::is_named_expr).then(|| self.flow_snapshot()); + let has_specialized_last = last_condition_flow_snapshots.is_some(); + let (last_short_circuit, no_short_circuit) = + if let Some(condition_flow_snapshots) = last_condition_flow_snapshots { + let (short_circuit, no_short_circuit) = + condition_flow_snapshots.into_short_circuit_and_continuation(*op); + (Some(short_circuit), Some(no_short_circuit)) + } else { + ( + None, + any_over_expr(expr, &ast::Expr::is_named_expr) + .then(|| self.flow_snapshot()), + ) + }; + + if let Some(last_short_circuit) = last_short_circuit { + self.flow_restore(last_short_circuit); + } for snapshot in snapshots { self.flow_merge(snapshot); @@ -4629,10 +4665,18 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { if let Some(no_short_circuit) = no_short_circuit { let bool_op_key = ExpressionNodeKey::from(expr); let maybe_short_circuit = self.flow_snapshot(); + + if has_specialized_last { + // Restore the merged post-expression flow after constructing the two + // outcome-specific snapshots. + self.flow_merge(no_short_circuit.clone()); + } + let (truthy, falsy) = match op { ast::BoolOp::And => (no_short_circuit, maybe_short_circuit), ast::BoolOp::Or => (maybe_short_circuit, no_short_circuit), }; + self.condition_flow_snapshots_by_node .insert(bool_op_key, ConditionFlowSnapshots { truthy, falsy }); } diff --git a/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md b/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md index 3f8a036702..45b120627c 100644 --- a/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md +++ b/crates/ty_python_semantic/resources/mdtest/boolean/short_circuit.md @@ -122,6 +122,48 @@ def _(flag1: bool, flag2: bool): reveal_type(z) # revealed: Literal[1] ``` +## Nested short-circuit assignments + +Assignments in mutually exclusive short-circuit paths can still leave a name definitely bound. + +```py +def _(flag: bool): + if (flag and (x := 54)) or (x := 32): + reveal_type(x) # revealed: Literal[54, 32] + +def _(flag: bool): + (flag and (x := 1)) or (x := 2) + reveal_type(x) # revealed: Literal[1, 2] + +def _(flag: bool, possibly_falsy_int: int, possibly_falsy_str: str): + (flag and (x := possibly_falsy_int)) or (x := possibly_falsy_str) + reveal_type(x) # revealed: int | str + +def _(flag: bool): + (flag or (x := 0)) and (x := 2) + reveal_type(x) # revealed: Literal[0, 2] + +def _(flag1: bool, flag2: bool): + if (flag1 and (x := 1)) or (flag2 and (x := 2)): + reveal_type(x) # revealed: Literal[1, 2] + + if (flag1 or (y := 0)) and (flag2 or (y := 0)): + pass + else: + reveal_type(y) # revealed: Literal[0] + +def _(flag1: bool, flag2: bool): + (flag1 and (x := 1)) or (flag2 and (x := 2)) or (x := 3) + reveal_type(x) # revealed: Literal[1, 2, 3] + +def _(flag1: bool): + if (flag1 and (y := 1)) or (z := 2): + # error: [possibly-unresolved-reference] + reveal_type(y) # revealed: Literal[1] + # error: [possibly-unresolved-reference] + reveal_type(z) # revealed: Literal[2] +``` + ## Negated expressions ```py From 4efb2646eca09d6ff6de83e61edca71ab58b45e8 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 15:17:49 -0400 Subject: [PATCH 165/222] [ty] Contextually infer custom `__setattr__` assignments (#27015) ## Summary Prior to this change, we inferred the right-hand side of an unresolved instance-attribute assignment before checking a custom `__setattr__`. That discarded the setter's expected type, leaving lambda parameters as `Unknown` and rejecting otherwise-valid `TypedDict` literals: ```py class Payload(TypedDict): value: int class Custom: def __setattr__(self, name: str, value: Callable[[int], int] | Payload) -> None: ... instance = Custom() instance.callback = lambda value: value.missing # Previously: no diagnostic instance.payload = {"value": 1} # Previously: unresolved-attribute ``` We now contextually infer unresolved instance assignments against the custom setter, so the lambda parameter is correctly inferred as `int` and the `TypedDict` literal is accepted. Explicit attributes and terminal setters retain their existing behavior. --- .../resources/mdtest/attributes.md | 21 ++++ .../infer/builder/attribute_assignment.rs | 97 ++++++++++++------- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index e91949a968..539995bba0 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2959,6 +2959,27 @@ c = CustomSetAttr() c.whatever = 42 ``` +### Contextual inference + +The `__setattr__` value parameter provides the expected type when inferring the assigned value: + +```py +from typing import Callable, TypedDict + +class Payload(TypedDict): + value: int + +class ContextSetAttr: + def __setattr__(self, name: str, value: Callable[[int], int] | Payload) -> None: ... + +instance = ContextSetAttr() +instance.callback = lambda number: ( + # error: [unresolved-attribute] "Object of type `int` has no attribute `missing`" + number.missing +) +instance.payload = {"value": 1} +``` + ### Type of the `name` parameter If the `name` parameter of the `__setattr__` method is annotated with a (union of) literal type(s), diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs index 050df0463e..a42942127f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -8,7 +8,7 @@ use crate::types::attribute_write::{ FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, ProtocolMemberWriteRequirement, attribute_write_requirement, property_setter_returns_never, }; -use crate::types::call::{CallArguments, CallError}; +use crate::types::call::{Bindings, CallArguments, CallError}; use crate::types::diagnostic::{ INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, UNRESOLVED_ATTRIBUTE, report_bad_dunder_set_call, report_invalid_attribute_assignment, report_possibly_missing_attribute, @@ -97,6 +97,50 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { self.infer_value(self.infer_value_ty.last_tcx(), emit_diagnostics) } + /// Infer an attribute-assignment value using the context provided by `__setattr__`, then + /// validate the synthesized setter call and return both its result and the inferred value type. + /// + /// ```python + /// from collections.abc import Callable + /// + /// class Custom: + /// def __setattr__(self, name: str, value: Callable[[int], int]) -> None: ... + /// + /// instance = Custom() + /// instance.callback = lambda value: value + 1 # `value` is inferred as `int`. + /// ``` + fn infer_and_try_call_setattr( + &mut self, + object_ty: Type<'db>, + emit_diagnostics: bool, + ) -> (Result, CallDunderError<'db>>, Type<'db>) { + let db = self.builder.db(); + let name_ty = Type::string_literal(db, self.attribute); + let ast_arguments = [ + ast::ArgOrKeyword::Arg(self.target.value.as_ref()), + ast::ArgOrKeyword::Arg(self.value), + ]; + let mut call_arguments = CallArguments::positional([name_ty, Type::unknown()]); + let setattr_result = self.builder.infer_and_try_call_dunder( + db, + object_ty, + "__setattr__", + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ArgumentsIter::synthesized(&ast_arguments), + &mut call_arguments, + &mut |builder, (argument_index, _, tcx)| { + if argument_index == 0 { + name_ty + } else { + self.infer_value_ty.infer_silent(builder, tcx) + } + }, + TypeContext::default(), + ); + let value_ty = self.infer_with_last_context(emit_diagnostics); + (setattr_result, value_ty) + } + fn evaluate( &mut self, requirement: &AttributeWriteRequirement<'db>, @@ -294,16 +338,25 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { emit_diagnostics: bool, ) -> bool { let db = self.builder.db(); - let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + let (setattr_result, value_ty) = if matches!(member, InstanceAttributeWriteMember::SetAttr) + { + self.infer_and_try_call_setattr(object_ty, emit_diagnostics) + } else { + let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + let setattr_result = object_ty.try_call_dunder_with_policy( + db, + "__setattr__", + &mut CallArguments::positional([ + Type::string_literal(db, self.attribute), + value_ty, + ]), + TypeContext::default(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); + (setattr_result, value_ty) + }; // A terminal `__setattr__` blocks even explicitly declared attributes. - let setattr_result = object_ty.try_call_dunder_with_policy( - db, - "__setattr__", - &mut CallArguments::positional([Type::string_literal(db, self.attribute), value_ty]), - TypeContext::default(), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); let setattr_returns_never = match &setattr_result { Ok(bindings) => bindings.return_type(db).is_never(), Err(error) => error.return_type(db).is_some_and(|ty| ty.is_never()), @@ -413,30 +466,8 @@ impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { has_instance_attribute, } => { let db = self.builder.db(); - let name_ty = Type::string_literal(db, self.attribute); - let ast_arguments = [ - ast::ArgOrKeyword::Arg(self.target.value.as_ref()), - ast::ArgOrKeyword::Arg(self.value), - ]; - let mut call_arguments = CallArguments::positional([name_ty, Type::unknown()]); - let setattr_result = self.builder.infer_and_try_call_dunder( - db, - object_ty, - "__setattr__", - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK - | MemberLookupPolicy::NO_INSTANCE_FALLBACK, - ArgumentsIter::synthesized(&ast_arguments), - &mut call_arguments, - &mut |builder, (argument_index, _, tcx)| { - if argument_index == 0 { - name_ty - } else { - self.infer_value_ty.infer_silent(builder, tcx) - } - }, - TypeContext::default(), - ); - let value_ty = self.infer_with_last_context(emit_diagnostics); + let (setattr_result, value_ty) = + self.infer_and_try_call_setattr(object_ty, emit_diagnostics); let setattr_returns_never = match &setattr_result { Ok(bindings) => bindings.return_type(db).is_never(), Err(error) => error.return_type(db).is_some_and(|ty| ty.is_never()), From 38a96fc9950a04b788f8fdbfc6a3f572ecb44485 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 20 Jul 2026 14:06:34 -0700 Subject: [PATCH 166/222] [ty] Respect bounded typevars in union inference (#27023) ## Summary Fixes https://github.com/astral-sh/ty/issues/4036. When both the parameter and argument types are unions, the legacy generic solver removes argument union elements already covered by the parameter and directly adds the remainder as a type-variable mapping. That bypasses the type variable's upper bound, allowing an invalid element such as `None` to widen `T: MyClass` and consequently mask the call error and pollute the inferred return type. Route the remaining argument type through normal type-variable inference so bounds and constraints are enforced. There's a duplicate diagnostic which would require non-trivial machinery to remove; defer addressing it until union inference moves to the new constraint solver. ## Test plan Add focused PEP 695 and legacy-TypeVar regressions covering the invalid argument, precise return type, and a valid-union control. Ecosystem changes are correct/expected. --- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- .../mdtest/generics/legacy/functions.md | 20 +++++++++++++++++++ .../mdtest/generics/pep695/functions.md | 18 +++++++++++++++++ .../ty_python_semantic/src/types/generics.rs | 8 +++++++- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index a926dd1c40..a61dafdad3 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -211,7 +211,7 @@ static TANJUN: Benchmark = Benchmark::new( max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 110, + 120, ); static STATIC_FRAME: Benchmark = Benchmark::new( diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md index bbf9176658..e7753f765f 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md @@ -510,6 +510,26 @@ def _(list_ofstr: list[str], list_of_int: list[int]): reveal_type(accepts_t_or_list_of_t(list_of_int)) # revealed: Unknown ``` +A union argument must not widen a bounded type variable with an incompatible union element: + +```py +class MyClass: ... + +T_bounded = TypeVar("T_bounded", bound=MyClass) + +def accepts_instance_or_int(instance: T_bounded, x: T_bounded | int) -> T_bounded: + return instance + +def _(x: int | None, valid: MyClass | int) -> MyClass: + # TODO: avoid the duplicate diagnostic when we move to new constraint solver for unions + # error: [invalid-argument-type] "Argument type `None` does not satisfy upper bound `MyClass` of type variable `T_bounded`" + # error: [invalid-argument-type] "Expected `MyClass | int`, found `int | None`" + result = accepts_instance_or_int(MyClass(), x) + reveal_type(result) # revealed: MyClass + reveal_type(accepts_instance_or_int(MyClass(), valid)) # revealed: MyClass + return result +``` + Here, we make sure that `S` is solved as `Literal[1]` instead of a union of the two literals, which would also be a valid solution: diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md index 0adfea16cc..e453b122f3 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md @@ -456,6 +456,24 @@ def _(list_ofstr: list[str], list_of_int: list[int]): reveal_type(accepts_t_or_list_of_t(list_of_int)) # revealed: Unknown ``` +A union argument must not widen a bounded type variable with an incompatible union element: + +```py +class MyClass: ... + +def accepts_instance_or_int[T: MyClass](instance: T, x: T | int) -> T: + return instance + +def _(x: int | None, valid: MyClass | int) -> MyClass: + # TODO: avoid the duplicate diagnostic when we move to new constraint solver for unions + # error: [invalid-argument-type] "Argument type `None` does not satisfy upper bound `MyClass` of type variable `T`" + # error: [invalid-argument-type] "Expected `MyClass | int`, found `int | None`" + result = accepts_instance_or_int(MyClass(), x) + reveal_type(result) # revealed: MyClass + reveal_type(accepts_instance_or_int(MyClass(), valid)) # revealed: MyClass + return result +``` + Here, we make sure that `S` is solved as `Literal[1]` instead of a union of the two literals, which would also be a valid solution: diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index ef02367019..30e12b6a14 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2830,7 +2830,13 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { if remaining_actual.is_never() { return Ok(()); } - self.add_type_mapping(*formal_bound_typevar, remaining_actual, polarity); + // Infer through the TypeVar arm so its bound or constraints are still enforced. + return self.infer_map_impl( + Type::TypeVar(*formal_bound_typevar), + remaining_actual, + polarity, + seen, + ); } (Type::Union(union_formal), _) => { // If the formal is a union and the actual is a bare inferable TypeVar in an From 150377d5180df2568ec219c561e98bea135fca57 Mon Sep 17 00:00:00 2001 From: Douglas Creager Date: Mon, 20 Jul 2026 17:48:50 -0400 Subject: [PATCH 167/222] [ty] Audit remaining sources of non-determinism in constraint set solutions (#26896) We are considering moving back to using salsa, instead of hand-rolled caches, to memoize the BDD structure of the constraint sets that we produce. We orignally moved to hand-rolled caches in https://github.com/astral-sh/ruff/pull/23538 to remove non-determinism in our tests and diagnostic output. That means that if we want to go back to salsa caching, we need to properly address that non-determinism. I have had Codex [perform an audit](https://gist.github.com/dcreager/b97decb3698cfcd553c316de5423a872) of those sources of non-determinism. This PR adds some additional testing infrastructure to exercise those sources: - The new `regression/constraint_set_ordering.md` mdtest includes tests of everything identified in the audit (including places where we are already correctly robust to variable ordering). This required adding a couple of additional `ty_extension` methods for interrogating constraint sets. - The new `TY_CONSTRAINT_SET_ORDER` environment variable lets you arbitrarily permute (or "wobble") the variable ordering that we choose for the constraint sets we create in a particular run of `ty`. Setting this is very likely to cause the new mdtest file to fail! Once we fix all of the non-determinism "for real", that file should pass regardless of which variable ordering permutation is chosen. - The new agent skill makes it easier to automate this process in an agent loop. Note that this does not fix any of the open sources of non-determinism! It just lets us more easily see what they are. --- .../wobbling-ty-constraint-order/SKILL.md | 75 +++ Cargo.lock | 1 + crates/ty_python_semantic/Cargo.toml | 1 + .../regression/constraint_set_ordering.md | 484 ++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 38 ++ .../ty_python_semantic/src/types/call/bind.rs | 74 ++- .../src/types/class/known.rs | 13 + .../src/types/class_base.rs | 1 + .../src/types/constraints.rs | 279 +++++++++- .../ty_python_semantic/src/types/display.rs | 25 +- .../types/infer/builder/type_expression.rs | 12 + .../src/types/known_instance.rs | 24 +- crates/ty_python_semantic/src/types/method.rs | 61 +++ .../ty_python_semantic/src/types/relation.rs | 2 + crates/ty_static/src/env_vars.rs | 7 + .../ty_vendored/ty_extensions/_internal.pyi | 28 + 16 files changed, 1111 insertions(+), 14 deletions(-) create mode 100644 .agents/skills/wobbling-ty-constraint-order/SKILL.md create mode 100644 crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md diff --git a/.agents/skills/wobbling-ty-constraint-order/SKILL.md b/.agents/skills/wobbling-ty-constraint-order/SKILL.md new file mode 100644 index 0000000000..3b6b3588fe --- /dev/null +++ b/.agents/skills/wobbling-ty-constraint-order/SKILL.md @@ -0,0 +1,75 @@ +--- +name: wobbling-ty-constraint-order +description: > + Use when a user asks to wobble ty constraint ordering, check constraint-set or TDD ordering + determinism, test reversed constraint/typevar IDs, or investigate nondeterministic ty inference + and mdtest results. +compatibility: > + Requires Cargo, mktemp, and a POSIX-compatible shell; uses cargo-nextest when available and + otherwise falls back to cargo test. +--- + +# Wobbling ty constraint order + +`TY_CONSTRAINT_SET_ORDER` perturbs both the builder-local TDD-variable order and the local typevar order used to orient typevar-to-typevar constraints. The setting is fixed for the lifetime of each test process. + +- unset/`0`: normal ordering; +- `reverse`: reverse both orderings; +- an integer: XOR each local ID with that mask. Small masks immediately perturb dense arena IDs: `1` swaps adjacent IDs, `3` reverses blocks of four, and powers of two exchange neighboring blocks. + +This deliberately changes internal TDD shape. Run **mdtests only**: graph-structure unit snapshots are expected to differ. Never enable snapshot updates for a wobble run, since updating would hide the failures being sought. + +## Run + +From the Ruff root, first establish the normal baseline, then run the reversed and XOR-masked orders sequentially. Set `TY_CONSTRAINT_ORDER_LOG_DIR` to retain logs in a particular writable directory; otherwise `mktemp` chooses an appropriate temporary directory (respecting the environment's temporary-directory configuration). + +```bash +set -u + +if test -n "${TY_CONSTRAINT_ORDER_LOG_DIR:-}"; then + log_dir="$TY_CONSTRAINT_ORDER_LOG_DIR" + mkdir -p "$log_dir" +else + log_dir="$(mktemp -d -t ty-constraint-order.XXXXXXXX)" +fi +printf '%s\n' "logs: $log_dir" + +if cargo nextest --version >/dev/null 2>&1; then + runner=nextest +else + runner=test +fi +printf '%s\n' "runner: cargo $runner" + +export CARGO_PROFILE_DEV_OPT_LEVEL=1 +export CARGO_PROFILE_DEV_DEBUG=line-tables-only +export INSTA_UPDATE=no +export MDTEST_UPDATE_SNAPSHOTS=0 +unset INSTA_FORCE_PASS || true + +for order in normal reverse 1 2 3 4 7 8 15; do + if test "$order" = normal; then + unset TY_CONSTRAINT_SET_ORDER || true + else + export TY_CONSTRAINT_SET_ORDER="$order" + fi + + log="$log_dir/ty-constraint-order-${order}.log" + if test "$runner" = nextest; then + cargo nextest run -p ty_python_semantic --test mdtest \ + --no-fail-fast --status-level fail --failure-output immediate-final \ + >"$log" 2>&1 + else + cargo test -p ty_python_semantic --test mdtest >"$log" 2>&1 + fi + status=$? + + printf '%-7s exit=%s\n' "$order" "$status" + grep -E 'Summary \[|test result:' "$log" | tail -1 || true + printf '%s\n' " log: $log" +done +``` + +Read each failing log and report the mdtest file, section, line, expected result, and actual diagnostic/revealed type. A wobble failure is evidence that inference semantics or displayed solution types still depend on ordering; do not update the mdtest expectations merely to make the wobble run green. + +The knob does **not** perturb hashing of Salsa-backed values. The `Solution binding order follows constraint source order` section of `regression/constraint_set_ordering.md` separately varies typevar declaration order to catch binding-order changes caused by draining an `FxHashMap`. diff --git a/Cargo.lock b/Cargo.lock index dd474670ae..09db75b096 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4905,6 +4905,7 @@ dependencies = [ "ty_module_resolver", "ty_python_core", "ty_site_packages", + "ty_static", "ty_test", "ty_vendored", ] diff --git a/crates/ty_python_semantic/Cargo.toml b/crates/ty_python_semantic/Cargo.toml index 7bf6b48a57..15df264183 100644 --- a/crates/ty_python_semantic/Cargo.toml +++ b/crates/ty_python_semantic/Cargo.toml @@ -26,6 +26,7 @@ ruff_text_size = { workspace = true } ty_module_resolver = { workspace = true } ty_site_packages = { workspace = true } ty_python_core = { workspace = true } +ty_static = { workspace = true } bitflags = { workspace = true } char_str = { workspace = true } diff --git a/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md b/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md new file mode 100644 index 0000000000..1b402f6a1d --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md @@ -0,0 +1,484 @@ +# Constraint set ordering + +This file verifies that constraint set solutions are deterministic, even in the presence of +different variable orderings in their underlying BDDs. + +The current implementation is _stable_ in the sense that multiple runs of `ty` against the same +source will produce the same result. But there are still some lingering places where the output +depends on the particular BDD variable ordering that is chosen. + +The diagnostic expectations show the output that is produced by the default stable ordering, so that +if you are not explicitly testing constraint set stability, mdtests should pass. We also include +TODO comments showing the other potential outputs that might be produced, under different variable +orderings. (`ConstraintSet.solutions_for` exposes each explicit per-typevar solution; +`ConstraintSet.solutions` additionally preserves path and binding order. This keeps duplicate and +`Never` solutions visible when they would otherwise disappear as paths are unioned.) + +To test this, you can set the `TY_CONSTRAINT_SET_ORDER` environment variable to either `reverse` or +to an integer. This lets you choose a different permutation for each run of `ty`. You can also use +the `wobbling-ty-constraint-order` agent skill to automate this process. + +```toml +[environment] +python-version = "3.13" +``` + +## Solution binding order follows constraint source order + +The order of bindings within a path must follow the first constraint that introduced each typevar. +Reversing either the typevar declaration order or the constraint source order exercises both sides +of this requirement. + +```py +from ty_extensions._internal import ConstraintSet + +def bindings_tuv[T, U, V]() -> None: + # (T = int) ∧ (U = str) ∧ (V = bytes) + constraints = ConstraintSet.range(int, T, int) & ConstraintSet.range(str, U, str) & ConstraintSet.range(bytes, V, bytes) + # revealed: tuple[Solution[T=int, U=str, V=bytes]] + reveal_type(constraints.solutions(inferable=tuple[T, U, V])) + +def bindings_vtu[V, T, U]() -> None: + # (T = int) ∧ (U = str) ∧ (V = bytes) + constraints = ConstraintSet.range(int, T, int) & ConstraintSet.range(str, U, str) & ConstraintSet.range(bytes, V, bytes) + # revealed: tuple[Solution[T=int, U=str, V=bytes]] + reveal_type(constraints.solutions(inferable=tuple[T, U, V])) + +def bindings_reverse_source[T, U, V]() -> None: + # (V = bytes) ∧ (U = str) ∧ (T = int) + constraints = ConstraintSet.range(bytes, V, bytes) & ConstraintSet.range(str, U, str) & ConstraintSet.range(int, T, int) + # revealed: tuple[Solution[V=bytes, U=str, T=int]] + reveal_type(constraints.solutions(inferable=tuple[T, U, V])) +``` + +## Nested transitive constraints and an unrelated alternative + +In `((T ≤ list[U]) ∧ (U ≤ int) ∧ (list[int] ≤ T)) | (bytes ≤ V)`, the two sides of the union are +completely independent: the solutions for `T` and `U` should not influence the solutions for `V`, +and vice versa. Because we combine them with union, we are allowed to _either_ find a solution for +`T` and `U`, _or_ find a solution for `V`. We are not _obligated_ to find a solution for all three. + +```py +from typing import Never +from ty_extensions._internal import ConstraintSet + +def nested_transitive[T, U, V]() -> None: + # ((T ≤ list[U]) ∧ (U ≤ int) ∧ (list[int] ≤ T)) | (bytes ≤ V) + constraints = ( + ConstraintSet.range(Never, T, list[U]) & ConstraintSet.range(Never, U, int) & ConstraintSet.range(list[int], T, object) + ) | ConstraintSet.range(bytes, V, object) + + # TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=Never], Solution[]] + # TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=list[int]], Solution[]] + # TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[], Solution[]] + # revealed: tuple[Solution[T=list[int]], Solution[]] + reveal_type(constraints.solutions_for(T, inferable=tuple[T, U, V])) + + # TODO: sometimes: revealed tuple[Solution[U=int], Solution[U=Never], Solution[]] + # TODO: sometimes: revealed tuple[Solution[U=int], Solution[], Solution[]] + # revealed: tuple[Solution[U=int], Solution[]] + reveal_type(constraints.solutions_for(U, inferable=tuple[T, U, V])) + + # TODO: sometimes: revealed tuple[Solution[], Solution[V=bytes], Solution[V=bytes]] + # revealed: tuple[Solution[], Solution[V=bytes]] + reveal_type(constraints.solutions_for(V, inferable=tuple[T, U, V])) + + # TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=Never, V=bytes], Solution[V=bytes]] + # TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=list[int], V=bytes], Solution[V=bytes]] + # TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[U=Never, V=bytes], Solution[V=bytes]] + # revealed: tuple[Solution[T=list[int], U=int], Solution[V=bytes]] + reveal_type(constraints.solutions(inferable=tuple[T, U, V])) +``` + +## Negated alternatives do not infer positive evidence + +In `¬((T ≤ int) ∨ (T ≤ str)) | (bytes ≤ U)`, the lhs of the union is a negation, and should not +place any positive restriction on `T`. Like above, we are not obligated to produce a solution that +includes both sides of the union, so any solution that includes `bytes ≤ U` should not include a +solution for `T`. + +```py +from typing import Never +from ty_extensions._internal import ConstraintSet + +def negated_alternative[T, U]() -> None: + # ¬((T ≤ int) ∨ (T ≤ str)) | (bytes ≤ U) + constraints = ~(ConstraintSet.range(Never, T, int) | ConstraintSet.range(Never, T, str)) | ConstraintSet.range( + bytes, U, object + ) + + # TODO: sometimes: revealed tuple[Solution[], Solution[T=Never], Solution[]] + # revealed: tuple[Solution[], Solution[]] + reveal_type(constraints.solutions_for(T, inferable=tuple[T, U])) + + # TODO: sometimes: revealed tuple[Solution[], Solution[U=bytes], Solution[U=bytes]] + # revealed: tuple[Solution[], Solution[U=bytes]] + reveal_type(constraints.solutions_for(U, inferable=tuple[T, U])) + + # TODO: sometimes: revealed tuple[Solution[], Solution[T=Never, U=bytes], Solution[U=bytes]] + # revealed: tuple[Solution[], Solution[U=bytes]] + reveal_type(constraints.solutions(inferable=tuple[T, U])) +``` + +## Derived solution element order + +Constructing the constraints in the opposite source order makes the derived union observable. Its +elements should not be reordered merely because the TDD-variable order changes. + +```py +from typing import Never +from ty_extensions._internal import ConstraintSet + +def derived_solution[U, T]() -> None: + # (U ≤ int) ∧ (int ≤ T) ∧ ((T ≤ int) | (T ≤ str)) + constraints = ( + ConstraintSet.range(Never, U, int) + & ConstraintSet.range(int, T, object) + & (ConstraintSet.range(Never, T, int) | ConstraintSet.range(Never, T, str)) + ) + + # TODO: The derived relationship should not leave an inferable `U` in the solution for `T`. + # TODO: revealed: tuple[Solution[T=int]] + # TODO: sometimes: revealed tuple[Solution[T=int | U@derived_solution]] + # revealed: tuple[Solution[T=U@derived_solution | int]] + reveal_type(constraints.solutions_for(T, inferable=tuple[T, U])) + + # TODO: The derived relationship should not leave an inferable `T` in the solution for `U`. + # TODO: revealed: tuple[Solution[U=int]] + # revealed: tuple[Solution[U=Never]] + reveal_type(constraints.solutions_for(U, inferable=tuple[T, U])) +``` + +## Bare-typevar orientation and tied source order + +`S ≤ T` can be represented as a constraint on either typevar, and `S ≤ T ≤ U` can be either one +range or two linked constraints. Logical equivalence and solution-element order must remain stable +in both declaration orders. + +```py +from typing import Never +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def orientation_st[S, T]() -> None: + lower = ConstraintSet.range(Never, S, T) + upper = ConstraintSet.range(S, T, object) + # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" + static_assert(lower == upper) + + equality_st = ConstraintSet.range(T, S, T) + equality_ts = ConstraintSet.range(S, T, S) + static_assert(equality_st == equality_ts) + +def orientation_ts[T, S]() -> None: + lower = ConstraintSet.range(Never, S, T) + upper = ConstraintSet.range(S, T, object) + # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" + static_assert(lower == upper) + + equality_st = ConstraintSet.range(T, S, T) + equality_ts = ConstraintSet.range(S, T, S) + static_assert(equality_st == equality_ts) + +def chain_stu[S, T, U]() -> None: + chain = ConstraintSet.range(S, T, U) + linked = ConstraintSet.range(Never, S, T) & ConstraintSet.range(Never, T, U) + # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" + static_assert(chain == linked) + + constraints = chain & ConstraintSet.range(int, S, object) & ConstraintSet.range(Never, U, int) + # TODO: inferable typevars should not remain in these concrete solutions. + # TODO: sometimes: revealed tuple[Solution[S=int | U@chain_stu | T@chain_stu]] + # revealed: tuple[Solution[S=T@chain_stu | int | U@chain_stu]] + reveal_type(constraints.solutions_for(S, inferable=tuple[S, T, U])) + # revealed: tuple[Solution[T=S@chain_stu | int | U@chain_stu]] + reveal_type(constraints.solutions_for(T, inferable=tuple[S, T, U])) + # revealed: tuple[Solution[U=T@chain_stu | S@chain_stu | int]] + reveal_type(constraints.solutions_for(U, inferable=tuple[S, T, U])) + +def chain_uts[U, T, S]() -> None: + chain = ConstraintSet.range(S, T, U) + linked = ConstraintSet.range(Never, S, T) & ConstraintSet.range(Never, T, U) + # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" + static_assert(chain == linked) + + constraints = chain & ConstraintSet.range(int, S, object) & ConstraintSet.range(Never, U, int) + # TODO: inferable typevars should not remain in these concrete solutions. + # TODO: sometimes: revealed tuple[Solution[S=int | U@chain_uts | T@chain_uts]] + # revealed: tuple[Solution[S=T@chain_uts | int | U@chain_uts]] + reveal_type(constraints.solutions_for(S, inferable=tuple[S, T, U])) + # revealed: tuple[Solution[T=S@chain_uts | int | U@chain_uts]] + reveal_type(constraints.solutions_for(T, inferable=tuple[S, T, U])) + # revealed: tuple[Solution[U=T@chain_uts | S@chain_uts | int]] + reveal_type(constraints.solutions_for(U, inferable=tuple[S, T, U])) +``` + +## Abstraction and non-inferable typevars + +Removing non-inferable typevars rebuilds the TDD with `ite`; irrelevant positive decisions must not +leak onto the surviving paths. Universal abstraction of an alternative must likewise leave only the +unrelated branch. + +```py +from typing import Never +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def noninferable_nested[T, U, V]() -> None: + constraints = ( + ConstraintSet.range(Never, T, list[U]) & ConstraintSet.range(Never, U, int) & ConstraintSet.range(list[int], T, object) + ) | ConstraintSet.range(bytes, V, object) + + # `U` is deliberately non-inferable here. + # TODO: We should not include a solution for non-inferable U. + # TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=Never, V=bytes], Solution[V=bytes]] + # TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=list[int], V=bytes], Solution[V=bytes]] + # revealed: tuple[Solution[T=list[int], U=int], Solution[V=bytes]] + reveal_type(constraints.solutions(inferable=tuple[T, V])) + # TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=Never], Solution[]] + # TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=list[int]], Solution[]] + # revealed: tuple[Solution[T=list[int]], Solution[]] + reveal_type(constraints.solutions_for(T, inferable=tuple[T, V])) + # TODO: sometimes: revealed tuple[Solution[], Solution[V=bytes], Solution[V=bytes]] + # revealed: tuple[Solution[], Solution[V=bytes]] + reveal_type(constraints.solutions_for(V, inferable=tuple[T, V])) + + quantified = constraints.for_all(tuple[T, U]) + expected = ConstraintSet.range(bytes, V, object) + static_assert(quantified == expected) + # revealed: tuple[Solution[V=bytes]] + reveal_type(quantified.solutions_for(V, inferable=tuple[V])) + +def noninferable_negated[T, U]() -> None: + constraints = ~(ConstraintSet.range(Never, T, int) | ConstraintSet.range(Never, T, str)) | ConstraintSet.range( + bytes, U, object + ) + + quantified = constraints.for_all(tuple[T]) + expected = ConstraintSet.range(bytes, U, object) + static_assert(quantified == expected) + # revealed: tuple[Solution[U=bytes]] + reveal_type(quantified.solutions_for(U, inferable=tuple[U])) +``` + +## Call-site upper bounds preserve intersection order + +Upper bounds inferred from contravariant callable parameters are intersected in call-site source +order. This exercises the direct `UpperBound` insertion path separately from sequent-derived bounds. + +```py +from typing import Callable, Protocol, TypeVar + +class P(Protocol): + def p(self) -> None: ... + +class Q(Protocol): + def q(self) -> None: ... + +T = TypeVar("T") + +def accepts_p(value: P) -> None: ... +def accepts_q(value: Q) -> None: ... +def infer_from_callbacks(first: Callable[[T], None], second: Callable[[T], None]) -> T: + raise NotImplementedError + +# revealed: P & Q +reveal_type(infer_from_callbacks(accepts_p, accepts_q)) +# revealed: Q & P +reveal_type(infer_from_callbacks(accepts_q, accepts_p)) +``` + +## Generic-callable and protocol relation constraints + +Relations can introduce fresh typevars and nested invariant constraints before those typevars are +quantified away. A `TypedDict` union additionally exercises common-constraint probing and the +fallback protocol-inference path; neither should depend on TDD order. + +```py +from typing import Callable, Literal, Protocol, TypeVar, TypedDict +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet, TypeOf + +def listify[T](value: T) -> list[T]: + return [value] + +def invariant_callable[U, V]() -> None: + constraints = ConstraintSet.range(bool, U, int) & ConstraintSet.range(int, V, int) + # TODO: no error. Existential reduction of the callable's fresh typevar is currently lossy. + # TODO: sometimes: no error + # error: [static-assert-error] + static_assert(constraints.implies_subtype_of(TypeOf[listify], Callable[[U], list[V]])) + +ConstrainedValue = TypeVar("ConstrainedValue", int, object, covariant=True) + +class GetValue(Protocol[ConstrainedValue]): + def __getitem__(self, key: Literal["value"], /) -> ConstrainedValue: ... + +class ValueA(TypedDict): + value: int + +class ValueB(TypedDict): + value: int + +def get_value(value: GetValue[ConstrainedValue]) -> ConstrainedValue: + raise NotImplementedError + +def typed_dict_union(value: ValueA | ValueB) -> None: + # TODO: sometimes: revealed object + # revealed: int + reveal_type(get_value(value)) +``` + +## Recursive derived relations remain cycle-safe + +Derived constraints can recursively invoke relation checking. The coinductive owned-set cycle +boundary must continue to terminate without accepting an incompatible non-recursive member when +ordering changes. + +```py +from __future__ import annotations +from typing import Protocol, cast + +class Array(Protocol): + def __abs__(self) -> Array: ... + def __pos__(self) -> Array: ... + def marker(self) -> int: ... + +class Concrete[T]: + def __abs__[S](self: S) -> S: + return self + + def __pos__[S](self: S) -> S: + return self + + def marker(self) -> str: + return "" + +def convert[T](value: Concrete[T]) -> Array: + return cast(Array, value) + +# error: [invalid-assignment] +invalid: Array = Concrete[int]() +``` + +## High-fanout sequents and inferred-union truncation + +The cross-product between the twelve lower- and twelve upper-bound relationships exhausts the shared +sequent fuel budget. The remaining solution, its element order, and the elements retained by +truncated diagnostic display must not depend on which implications were encountered first. + +```py +from typing import Literal, Never +from ty_extensions import static_assert +from ty_extensions._internal import ConstraintSet + +def high_fanout[ + P, + L0, + L1, + L2, + L3, + L4, + L5, + L6, + L7, + L8, + L9, + L10, + L11, + R0, + R1, + R2, + R3, + R4, + R5, + R6, + R7, + R8, + R9, + R10, + R11, +]() -> None: + lower = ( + ConstraintSet.range(Literal[0], L0, P) + & ConstraintSet.range(Literal[1], L1, P) + & ConstraintSet.range(Literal[2], L2, P) + & ConstraintSet.range(Literal[3], L3, P) + & ConstraintSet.range(Literal[4], L4, P) + & ConstraintSet.range(Literal[5], L5, P) + & ConstraintSet.range(Literal[6], L6, P) + & ConstraintSet.range(Literal[7], L7, P) + & ConstraintSet.range(Literal[8], L8, P) + & ConstraintSet.range(Literal[9], L9, P) + & ConstraintSet.range(Literal[10], L10, P) + & ConstraintSet.range(Literal[11], L11, P) + ) + upper = ( + ConstraintSet.range(Never, P, R0) + & ConstraintSet.range(Never, P, R1) + & ConstraintSet.range(Never, P, R2) + & ConstraintSet.range(Never, P, R3) + & ConstraintSet.range(Never, P, R4) + & ConstraintSet.range(Never, P, R5) + & ConstraintSet.range(Never, P, R6) + & ConstraintSet.range(Never, P, R7) + & ConstraintSet.range(Never, P, R8) + & ConstraintSet.range(Never, P, R9) + & ConstraintSet.range(Never, P, R10) + & ConstraintSet.range(Never, P, R11) + ) + inferable = tuple[ + P, + L0, + L1, + L2, + L3, + L4, + L5, + L6, + L7, + L8, + L9, + L10, + L11, + R0, + R1, + R2, + R3, + R4, + R5, + R6, + R7, + R8, + R9, + R10, + R11, + ] + constraints = lower & upper + pivot = constraints.solutions_for(P, inferable=inferable) + result = constraints.solutions_for(R11, inferable=inferable) + + # TODO: inferred solutions should not retain the intermediate inferable typevars. + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 1, 4, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 1, 2, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 1, 2, 3, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[P=L0@high_fanout | Literal[0, 1, 2, 3, 4, 5, 6, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + # revealed: tuple[Solution[P=L0@high_fanout | L1@high_fanout | L2@high_fanout | Literal[2, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout]] + reveal_type(pivot) + + # TODO: sometimes: revealed tuple[Solution[R11=P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | L2@high_fanout | Literal[2, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | Literal[0, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | Literal[0, 1, 4, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | Literal[0, 1, 5, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | Literal[0, 1, 2, 3, 6, 7, 8, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # TODO: sometimes: revealed tuple[Solution[R11=L0@high_fanout | Literal[0, 1, 2, 3, 4, 5, 6, 9, 10, 11] | L1@high_fanout | L2@high_fanout | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + # revealed: tuple[Solution[R11=L1@high_fanout | L2@high_fanout | Literal[2, 3, 4, 5, 6, 7, 8, 9, 10, 11] | L3@high_fanout | L4@high_fanout | L5@high_fanout | L6@high_fanout | L7@high_fanout | L8@high_fanout | L9@high_fanout | L10@high_fanout | L11@high_fanout | P@high_fanout]] + reveal_type(result) + + impossible = constraints & ConstraintSet.range(Never, R11, Literal[0]) + # TODO: sometimes: error [static-assert-error] "Static assertion error: argument evaluates to `False`" + static_assert(not impossible.satisfied_by_all_typevars(inferable=inferable)) +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1b8d1259b9..d0c7eed3f8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4089,6 +4089,22 @@ impl<'db> Type<'db> { )) .into() } + Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) + if name == "solutions_for" => + { + Place::bound(Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetSolutionsFor(tracked), + )) + .into() + } + Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) + if name == "solutions" => + { + Place::bound(Type::KnownBoundMethod( + KnownBoundMethodType::ConstraintSetSolutions(tracked), + )) + .into() + } Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked)) if name == "with_detailed_display" => { @@ -6203,6 +6219,14 @@ impl<'db> Type<'db> { invalid_expressions: smallvec_inline![InvalidTypeExpression::ConstraintSet], fallback_type: Type::unknown(), }), + KnownInstanceType::ConstraintSetSolution(__call__) => { + Err(InvalidTypeExpressionError { + invalid_expressions: smallvec_inline![ + InvalidTypeExpression::ConstraintSetSolution + ], + fallback_type: Type::unknown(), + }) + } KnownInstanceType::GenericContext(__call__) => Err(InvalidTypeExpressionError { invalid_expressions: smallvec_inline![InvalidTypeExpression::GenericContext], fallback_type: Type::unknown(), @@ -6497,6 +6521,7 @@ impl<'db> Type<'db> { | KnownInstanceType::Deprecated(_) | KnownInstanceType::Field(_) | KnownInstanceType::ConstraintSet(_) + | KnownInstanceType::ConstraintSetSolution(_) | KnownInstanceType::GenericContext(_) | KnownInstanceType::Specialization(_) | KnownInstanceType::Literal(_) @@ -6514,6 +6539,8 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) ) ) { @@ -6879,6 +6906,8 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) ) | Type::DataclassDecorator(_) @@ -7108,6 +7137,7 @@ impl<'db> Type<'db> { | KnownInstanceType::Deprecated(_) | KnownInstanceType::Field(_) | KnownInstanceType::ConstraintSet(_) + | KnownInstanceType::ConstraintSetSolution(_) | KnownInstanceType::GenericContext(_) | KnownInstanceType::Specialization(_) | KnownInstanceType::Literal(_) @@ -7144,6 +7174,8 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) | Type::DataclassDecorator(_) @@ -8316,6 +8348,8 @@ enum InvalidTypeExpression<'db> { Field, /// Same for `ty_extensions._internal.ConstraintSet` ConstraintSet, + /// Same for `ty_extensions._internal.ConstraintSetSolution` + ConstraintSetSolution, /// Same for `ty_extensions._internal.GenericContext` GenericContext, /// Same for `ty_extensions._internal.Specialization` @@ -8385,6 +8419,10 @@ impl<'db> InvalidTypeExpression<'db> { f, "`ty_extensions._internal.ConstraintSet` is not allowed in {location}s", ), + InvalidTypeExpression::ConstraintSetSolution => write!( + f, + "`ty_extensions._internal.ConstraintSetSolution` is not allowed in {location}s", + ), InvalidTypeExpression::GenericContext => { write!( f, diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 8a0bd0d8f6..364cc4bb5c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -51,7 +51,7 @@ use crate::types::generics::{ TypeVarInference, }; use crate::types::infer::original_class_type; -use crate::types::known_instance::FieldInstance; +use crate::types::known_instance::{FieldInstance, InternedConstraintSetSolution}; use crate::types::signatures::{ CallableSignature, Parameter, ParameterDisplayName, ParameterKind, Parameters, ParametersKind, PartialApplication, PartialSignatureApplication, @@ -2814,6 +2814,78 @@ impl<'db> Bindings<'db> { overload.set_return_type(Type::bool_literal(result)); } + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetSolutionsFor( + tracked, + )) => { + let [Some(typevar), Some(inferable)] = overload.parameter_types() else { + continue; + }; + let Type::TypeVar(typevar) = typevar.project_type_form(db) else { + continue; + }; + let Type::NominalInstance(inferable) = inferable.project_type_form(db) + else { + continue; + }; + let Some(inferable) = inferable_typevars_from_tuple(db, &inferable) else { + continue; + }; + + let constraints = ConstraintSetBuilder::new(); + let set = constraints.load(db, tracked.constraints(db)); + let result = match set.solutions(db, &constraints, inferable) { + Solutions::Constrained(paths) => Type::heterogeneous_tuple( + db, + paths.into_iter().map(|path| { + let path: Box<[_]> = path + .into_iter() + .filter(|binding| binding.bound_typevar == typevar) + .collect(); + Type::KnownInstance(KnownInstanceType::ConstraintSetSolution( + InternedConstraintSetSolution::new(db, path), + )) + }), + ), + Solutions::Unsatisfiable => Type::none(db), + Solutions::Unconstrained => Type::empty_tuple(db), + }; + overload.set_return_type(result); + } + + Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetSolutions( + tracked, + )) => { + let [Some(inferable)] = overload.parameter_types() else { + continue; + }; + let Type::NominalInstance(inferable) = inferable.project_type_form(db) + else { + continue; + }; + let Some(inferable) = inferable_typevars_from_tuple(db, &inferable) else { + continue; + }; + + let constraints = ConstraintSetBuilder::new(); + let set = constraints.load(db, tracked.constraints(db)); + let result = match set.solutions(db, &constraints, inferable) { + Solutions::Constrained(paths) => Type::heterogeneous_tuple( + db, + paths.into_iter().map(|path| { + Type::KnownInstance(KnownInstanceType::ConstraintSetSolution( + InternedConstraintSetSolution::new( + db, + path.into_boxed_slice(), + ), + )) + }), + ), + Solutions::Unsatisfiable => Type::none(db), + Solutions::Unconstrained => Type::empty_tuple(db), + }; + overload.set_return_type(result); + } + Type::KnownBoundMethod( KnownBoundMethodType::ConstraintSetWithDetailedDisplay(tracked), ) => { diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 75949b2504..17bba090de 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -148,6 +148,7 @@ pub enum KnownClass { FunctoolsPartial, // ty_extensions ConstraintSet, + ConstraintSetSolution, GenericContext, Specialization, TyExtensionsAsyncIterable, @@ -282,6 +283,7 @@ impl KnownClass { | Self::NamedTupleFallback | Self::NamedTupleLike | Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::ProtocolMeta @@ -393,6 +395,7 @@ impl KnownClass { | KnownClass::NamedTupleFallback | KnownClass::NamedTupleLike | KnownClass::ConstraintSet + | KnownClass::ConstraintSetSolution | KnownClass::GenericContext | KnownClass::Specialization | KnownClass::TypedDictFallback @@ -504,6 +507,7 @@ impl KnownClass { | KnownClass::NamedTupleFallback | KnownClass::NamedTupleLike | KnownClass::ConstraintSet + | KnownClass::ConstraintSetSolution | KnownClass::GenericContext | KnownClass::Specialization | KnownClass::TypedDictFallback @@ -617,6 +621,7 @@ impl KnownClass { | KnownClass::NamedTupleLike | KnownClass::NamedTupleFallback | KnownClass::ConstraintSet + | KnownClass::ConstraintSetSolution | KnownClass::GenericContext | KnownClass::Specialization | KnownClass::BuiltinFunctionType @@ -736,6 +741,7 @@ impl KnownClass { | Self::KwOnly | Self::NamedTupleFallback | Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::TypedDictFallback @@ -857,6 +863,7 @@ impl KnownClass { | KnownClass::Path | KnownClass::FunctoolsPartial | KnownClass::ConstraintSet + | KnownClass::ConstraintSetSolution | KnownClass::GenericContext | KnownClass::Specialization | KnownClass::PydanticBaseModel @@ -976,6 +983,7 @@ impl KnownClass { Self::NamedTupleFallback => "NamedTupleFallback", Self::NamedTupleLike => "NamedTupleLike", Self::ConstraintSet => "ConstraintSet", + Self::ConstraintSetSolution => "ConstraintSetSolution", Self::GenericContext => "GenericContext", Self::Specialization => "Specialization", Self::TypedDictFallback => "TypedDictFallback", @@ -1375,6 +1383,7 @@ impl KnownClass { Self::NamedTupleFallback | Self::TypedDictFallback => KnownModule::TypeCheckerInternals, Self::NamedTupleLike => KnownModule::TyExtensions, Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::TyExtensionsAsyncIterable @@ -1487,6 +1496,7 @@ impl KnownClass { | Self::NamedTupleFallback | Self::NamedTupleLike | Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::TypedDictFallback @@ -1604,6 +1614,7 @@ impl KnownClass { | Self::NamedTupleFallback | Self::NamedTupleLike | Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::TypedDictFallback @@ -1721,6 +1732,7 @@ impl KnownClass { "NamedTupleFallback" => &[Self::NamedTupleFallback], "NamedTupleLike" => &[Self::NamedTupleLike], "ConstraintSet" => &[Self::ConstraintSet], + "ConstraintSetSolution" => &[Self::ConstraintSetSolution], "GenericContext" => &[Self::GenericContext], "Specialization" => &[Self::Specialization], "TypedDictFallback" => &[Self::TypedDictFallback], @@ -1820,6 +1832,7 @@ impl KnownClass { | Self::Sentinel | Self::NamedTupleLike | Self::ConstraintSet + | Self::ConstraintSetSolution | Self::GenericContext | Self::Specialization | Self::TyExtensionsAsyncIterable diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 65e64241cf..d93cedfb18 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -228,6 +228,7 @@ impl<'db> ClassBase<'db> { | KnownInstanceType::Deprecated(_) | KnownInstanceType::Field(_) | KnownInstanceType::ConstraintSet(_) + | KnownInstanceType::ConstraintSetSolution(_) | KnownInstanceType::Callable(_) | KnownInstanceType::GenericContext(_) | KnownInstanceType::Specialization(_) diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 9e4a745b9b..d2505c17dc 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -92,7 +92,7 @@ use std::fmt::{Debug, Display}; use std::iter; use std::marker::PhantomData; use std::ops::Range; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use indexmap::map::Entry; use itertools::Itertools; @@ -100,6 +100,7 @@ use ruff_index::{Idx, IndexVec, newtype_index}; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use ty_python_core::rank::RankBitBox; +use ty_static::EnvVars; use crate::types::class::GenericAlias; use crate::types::generics::InferableTypeVars; @@ -1337,7 +1338,45 @@ impl<'db> BoundTypeVarInstance<'db> { builder: &ConstraintSetBuilder<'db>, typevar: Self, ) -> bool { - builder.typevar_id(db, self).index() < builder.typevar_id(db, typevar).index() + wobble_index(builder.typevar_id(db, self).index()) + < wobble_index(builder.typevar_id(db, typevar).index()) + } +} + +/// Optionally applies a transformation to a builder-local typevar or constraint ID, which lets us +/// exercise different BDD variable orderings. +/// +/// Under normal operation, the IDs won't be modified, and we will construct BDDs based on the +/// (builder-local) source order that we encounter typevars and constraints. +/// +/// Our results _shouldn't_ depend on the BDD variable ordering that we choose. You can use the +/// `TY_CONSTRAINT_SET_ORDER` environment variable to artificially choose different permutations of +/// the "natural" variable ordering, to ensure that results are consistent. +fn wobble_index(index: usize) -> usize { + #[derive(Clone, Copy)] + enum Order { + Normal, + Reverse, + Xor(usize), + } + + static ORDER: LazyLock = LazyLock::new(|| { + let Some(value) = std::env::var_os(EnvVars::TY_CONSTRAINT_SET_ORDER) else { + return Order::Normal; + }; + if value == "reverse" { + return Order::Reverse; + } + value + .to_str() + .and_then(|value| value.parse::().ok()) + .map_or(Order::Normal, Order::Xor) + }); + + match *ORDER { + Order::Normal => index, + Order::Reverse => !index, + Order::Xor(mask) => index ^ mask, } } @@ -1964,7 +2003,7 @@ impl ConstraintId { /// empirically that we get smaller BDDs with an ordering that is more aligned with source /// order. fn ordering(self) -> impl Ord { - std::cmp::Reverse(self.index()) + std::cmp::Reverse(wobble_index(self.index())) } /// Returns whether this constraint implies another — i.e., whether every type that @@ -3754,8 +3793,8 @@ impl<'db> PathBounds<'db> { }); let mut result = Vec::with_capacity(sorted_paths.len()); - let mut mappings: FxHashMap, ConstraintBoundsBuilder<'db>> = - FxHashMap::default(); + let mut mappings: FxIndexMap, ConstraintBoundsBuilder<'db>> = + FxIndexMap::default(); for path in sorted_paths { mappings.clear(); @@ -3784,7 +3823,7 @@ impl<'db> PathBounds<'db> { } let path_bounds = mappings - .drain() + .drain(..) .map(|(bound_typevar, bounds)| bounds.finish(db, bound_typevar)) .collect(); result.push(path_bounds); @@ -3844,8 +3883,8 @@ impl<'db> PathBounds<'db> { } } - let mut mappings: FxHashMap, ConstraintBoundsBuilder<'db>> = - FxHashMap::default(); + let mut mappings: FxIndexMap, ConstraintBoundsBuilder<'db>> = + FxIndexMap::default(); constraints.sort_by_key(|(_, _, source_order)| *source_order); for (typevar, constraint, _) in constraints { let bounds = mappings.entry(typevar).or_default(); @@ -3858,7 +3897,7 @@ impl<'db> PathBounds<'db> { } let path = mappings - .drain() + .drain(..) .map(|(bound_typevar, bounds)| bounds.finish(db, bound_typevar)) .collect(); Some(PathBounds::Constrained(Box::new([path]))) @@ -5106,8 +5145,8 @@ pub(crate) enum Solutions<'db> { pub(crate) type Solution<'db> = Vec>; -#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] -pub(crate) struct TypeVarSolution<'db> { +#[derive(Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub struct TypeVarSolution<'db> { pub(crate) bound_typevar: BoundTypeVarInstance<'db>, pub(crate) solution: Type<'db>, } @@ -7621,6 +7660,224 @@ mod tests { }); } + #[derive(Clone, Copy)] + struct PermutedConstraint<'db>( + BoundTypeVarInstance<'db>, + Option>, + Option>, + ); + + impl<'db> PermutedConstraint<'db> { + fn node(self, db: &'db dyn Db, builder: &ConstraintSetBuilder<'db>) -> NodeId { + let PermutedConstraint(typevar, lower, upper) = self; + Constraint::new_node_with_bounds(db, builder, typevar, lower, upper) + } + } + + /// Tests that we get the same set of solutions for a constraint set, regardless of the + /// variable ordering that is chosen for its "atoms" (the raw constraints that the constraint + /// set is built from). + /// + /// TODO: We _don't_ currently get a consistent result for each permutation. Right now, + /// `expected` is a list of all of the different results that we get. Once we solve all of the + /// sources of nondeterminism, `expected` should become a single string, and we should verify + /// that we get that specific result for each permutation. + #[track_caller] + fn check_solutions_for_constraint_orderings<'db>( + db: &'db dyn Db, + typevars: &[BoundTypeVarInstance<'db>], + atoms: &[PermutedConstraint<'db>], + build_bdd: impl Fn(&ConstraintSetBuilder<'db>) -> NodeId, + expected: impl IntoIterator, + ) { + let inferable = InferableTypeVars::from_typevars( + db, + typevars + .iter() + .map(|typevar| typevar.identity(db)) + .collect(), + ); + let mut signatures = FxIndexSet::default(); + + for constraint_order in (0..atoms.len()).permutations(atoms.len()) { + let builder = ConstraintSetBuilder::new(); + for typevar in typevars { + builder.intern_typevar(db, *typevar); + } + for index in constraint_order { + let PermutedConstraint(typevar, lower, upper) = atoms[index]; + builder.intern_constraint( + db, + Constraint { + typevar, + bounds: ConstraintBounds::new(lower, upper), + }, + ); + } + + let set = ConstraintSet::from_node(&builder, build_bdd(&builder)); + let solutions = set.solutions(db, &builder, inferable); + let mut merged = FxHashMap::default(); + if let Solutions::Constrained(paths) = &solutions { + for path in paths { + for binding in path { + merged + .entry(binding.bound_typevar) + .and_modify(|existing| { + *existing = + UnionType::from_two_elements(db, *existing, binding.solution); + }) + .or_insert(binding.solution); + } + } + } + let merged = typevars + .iter() + .filter_map(|typevar| { + merged.get(typevar).map(|ty| { + format!("{}={}", typevar.identity(db).display(db), ty.display(db)) + }) + }) + .join(", "); + let paths = match &solutions { + Solutions::Unsatisfiable => String::from("unsatisfiable"), + Solutions::Unconstrained => String::from("unconstrained"), + Solutions::Constrained(paths) => paths + .iter() + .map(|path| { + path.iter() + .map(|binding| { + format!( + "{}={}", + binding.bound_typevar.identity(db).display(db), + binding.solution.display(db) + ) + }) + .join(", ") + }) + .join("; "), + }; + signatures.insert(format!( + "never={} always={} merged=[{merged}] paths=[{paths}]", + set.is_never_satisfied(db), + set.is_always_satisfied(db), + )); + } + + let expected: FxIndexSet<_> = expected.into_iter().map(String::from).collect(); + assert_eq!(signatures, expected); + } + + #[test] + fn constraint_ordering_changes_nested_transitive_solutions() { + let db = setup_db(); + let t = create_typevar(&db, "T"); + let u = create_typevar(&db, "U"); + let v = create_typevar(&db, "V"); + let int = KnownClass::Int.to_instance(&db); + let bytes = KnownClass::Bytes.to_instance(&db); + let list_u = KnownClass::List.to_specialized_instance(&db, &[Type::TypeVar(u)]); + let list_int = KnownClass::List.to_specialized_instance(&db, &[int]); + let atoms = [ + PermutedConstraint(t, None, Some(list_u)), + PermutedConstraint(u, None, Some(int)), + PermutedConstraint(t, Some(list_int), None), + PermutedConstraint(v, Some(bytes), None), + ]; + + check_solutions_for_constraint_orderings( + &db, + &[t, u, v], + &atoms, + |builder| { + let [t_list_u, u_int, list_int_t, bytes_v] = + atoms.map(|atom| atom.node(&db, builder)); + t_list_u + .and_with_offset(builder, u_int) + .and_with_offset(builder, list_int_t) + .or_with_offset(builder, bytes_v) + }, + // TODO: All permutations should produce the first result. TDD traversal currently + // leaks irrelevant positive constraints onto the `V = bytes` alternative. + [ + "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; V=bytes]", + "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; T=list[int], V=bytes; V=bytes]", + "never=false always=false merged=[T=list[int], U=int, V=bytes] paths=[T=list[int], U=int; U=int, V=bytes; V=bytes]", + "never=false always=false merged=[T=list[int] | list[U], U=int, V=bytes] paths=[T=list[int], U=int; T=list[U], V=bytes; V=bytes]", + ], + ); + } + + #[test] + fn constraint_ordering_changes_negated_alternative_solutions() { + let db = setup_db(); + let t = create_typevar(&db, "T"); + let u = create_typevar(&db, "U"); + let int = KnownClass::Int.to_instance(&db); + let str = KnownClass::Str.to_instance(&db); + let bytes = KnownClass::Bytes.to_instance(&db); + let atoms = [ + PermutedConstraint(t, None, Some(int)), + PermutedConstraint(t, None, Some(str)), + PermutedConstraint(u, Some(bytes), None), + ]; + + check_solutions_for_constraint_orderings( + &db, + &[t, u], + &atoms, + |builder| { + let [t_int, t_str, bytes_u] = atoms.map(|atom| atom.node(&db, builder)); + t_int + .or_with_offset(builder, t_str) + .negate(builder) + .or_with_offset(builder, bytes_u) + }, + // TODO: All permutations should produce the first result. A satisfied alternative + // should not infer `T` from unrelated positive decisions made earlier in a BDD path. + [ + "never=false always=false merged=[U=bytes] paths=[; U=bytes]", + "never=false always=false merged=[T=str, U=bytes] paths=[; T=str, U=bytes; U=bytes]", + "never=false always=false merged=[T=int, U=bytes] paths=[; T=int, U=bytes; U=bytes]", + ], + ); + } + + #[test] + fn constraint_ordering_changes_derived_upper_bound_display() { + let db = setup_db(); + let t = create_typevar(&db, "T"); + let u = create_typevar(&db, "U"); + let int = KnownClass::Int.to_instance(&db); + let str = KnownClass::Str.to_instance(&db); + let atoms = [ + PermutedConstraint(t, None, Some(int)), + PermutedConstraint(t, None, Some(str)), + PermutedConstraint(t, Some(int), None), + PermutedConstraint(u, None, Some(int)), + ]; + + check_solutions_for_constraint_orderings( + &db, + &[t, u], + &atoms, + |builder| { + let [t_int, t_str, int_t, u_int] = atoms.map(|atom| atom.node(&db, builder)); + t_int + .or_with_offset(builder, t_str) + .and_with_offset(builder, int_t) + .and_with_offset(builder, u_int) + }, + // TODO: `SequentMap::for_constraint_pair` can receive its inputs in BDD order, not + // source order. That changes which equivalent upper-bound intersection is constructed + // first. + [ + "never=false always=false merged=[T=int | U, U=T & int] paths=[T=int | U, U=T & int]", + "never=false always=false merged=[T=int | U, U=int & T] paths=[T=int | U, U=int & T]", + ], + ); + } + #[track_caller] fn check_display_graph<'db, 'c>( db: &'db dyn Db, diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 60af0f7367..b52f3706c7 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -1226,6 +1226,12 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'db> { return f .write_str("bound method `ConstraintSet.satisfied_by_all_typevars`"); } + KnownBoundMethodType::ConstraintSetSolutionsFor(_) => { + return f.write_str("bound method `ConstraintSet.solutions_for`"); + } + KnownBoundMethodType::ConstraintSetSolutions(_) => { + return f.write_str("bound method `ConstraintSet.solutions`"); + } KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => { return f.write_str("bound method `ConstraintSet.with_detailed_display`"); } @@ -3194,17 +3200,19 @@ impl Display for DisplayStringLiteralType<'_> { pub(crate) struct DisplayKnownInstanceRepr<'db> { pub(crate) known_instance: KnownInstanceType<'db>, pub(crate) db: &'db dyn Db, + pub(crate) settings: DisplaySettings<'db>, } impl<'db> KnownInstanceType<'db> { pub(crate) fn display_with( self, db: &'db dyn Db, - _settings: DisplaySettings<'db>, + settings: DisplaySettings<'db>, ) -> DisplayKnownInstanceRepr<'db> { DisplayKnownInstanceRepr { known_instance: self, db, + settings, } } } @@ -3290,6 +3298,21 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'db> { f.write_str("[bool]") } } + KnownInstanceType::ConstraintSetSolution(solution) => { + f.set_invalid_type_annotation(); + f.with_type(ty).write_str("Solution[")?; + for (index, binding) in solution.bindings(self.db).iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + write!(f, "{}=", binding.bound_typevar.name(self.db))?; + binding + .solution + .display_with(self.db, self.settings.clone()) + .fmt_detailed(f)?; + } + f.write_char(']') + } KnownInstanceType::GenericContext(generic_context) => { f.with_type(ty) .write_str("ty_extensions._internal.GenericContext")?; diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 716e9c64b3..bcb16f733e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1556,6 +1556,18 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } Type::unknown() } + KnownInstanceType::ConstraintSetSolution(_) => { + if !self.in_string_annotation() { + self.infer_expression(slice, TypeContext::default()); + } + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { + builder.into_diagnostic(format_args!( + "`ty_extensions._internal.ConstraintSetSolution` is not allowed in {}s", + self.type_expression_context(), + )); + } + Type::unknown() + } KnownInstanceType::GenericContext(_) => { if !self.in_string_annotation() { self.infer_expression(slice, TypeContext::default()); diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index c912aa050e..9508362a20 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -9,7 +9,7 @@ use crate::{ PromotionKind, PromotionMode, StringLiteralType, Type, TypeAliasType, TypeContext, TypeMapping, TypeVarNonce, TypeVarVariance, UnionBuilder, class::NamedTupleSpec, - constraints::OwnedConstraintSet, + constraints::{OwnedConstraintSet, TypeVarSolution}, dedicated::pydantic::ConfigBoolean, generics::{Specialization, walk_generic_context}, newtype::NewType, @@ -48,6 +48,16 @@ impl<'db> InternedConstraintSet<'db> { } } +/// A Salsa-interned solution path exposed to mdtests as `ConstraintSetSolution`. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub struct InternedConstraintSetSolution<'db> { + #[returns(ref)] + pub(super) bindings: Box<[TypeVarSolution<'db>]>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for InternedConstraintSetSolution<'_> {} + /// A salsa-interned payload for `functools.partial(...)` instances. #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct FunctoolsPartialInstance<'db> { @@ -99,6 +109,10 @@ pub enum KnownInstanceType<'db> { /// `ty_extensions._internal.ConstraintSet`. ConstraintSet(InternedConstraintSet<'db>), + /// A solution path, which is exposed in mdtests as an instance of + /// `ty_extensions._internal.ConstraintSetSolution`. + ConstraintSetSolution(InternedConstraintSetSolution<'db>), + /// A generic context, which is exposed in mdtests as an instance of /// `ty_extensions._internal.GenericContext`. GenericContext(GenericContext<'db>), @@ -170,6 +184,11 @@ pub(super) fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Size | KnownInstanceType::Specialization(_) => { // Nothing to visit } + KnownInstanceType::ConstraintSetSolution(solution) => { + for binding in solution.bindings(db) { + visitor.visit_type(db, binding.solution); + } + } KnownInstanceType::Field(field) => { if let Some(default_ty) = field.default_type(db) { visitor.visit_type(db, default_ty); @@ -236,6 +255,7 @@ impl<'db> KnownInstanceType<'db> { Self::Deprecated(deprecated) => Some(Self::Deprecated(deprecated)), Self::Range { is_non_empty } => Some(Self::Range { is_non_empty }), Self::ConstraintSet(set) => Some(Self::ConstraintSet(set)), + Self::ConstraintSetSolution(solution) => Some(Self::ConstraintSetSolution(solution)), Self::TypeVar(typevar) => Some(Self::TypeVar(typevar)), Self::TypeAliasType(type_alias) => Some(Self::TypeAliasType(type_alias)), Self::Field(field) => field @@ -296,6 +316,7 @@ impl<'db> KnownInstanceType<'db> { Self::Deprecated(_) => KnownClass::Deprecated, Self::Field(_) => KnownClass::Field, Self::ConstraintSet(_) => KnownClass::ConstraintSet, + Self::ConstraintSetSolution(_) => KnownClass::ConstraintSetSolution, Self::GenericContext(_) => KnownClass::GenericContext, Self::Specialization(_) => KnownClass::Specialization, Self::UnionType(_) => KnownClass::UnionType, @@ -449,6 +470,7 @@ impl<'db> KnownInstanceType<'db> { | KnownInstanceType::Deprecated(_) | KnownInstanceType::Field(_) | KnownInstanceType::ConstraintSet(_) + | KnownInstanceType::ConstraintSetSolution(_) | KnownInstanceType::GenericContext(_) | KnownInstanceType::Specialization(_) | KnownInstanceType::Literal(_) diff --git a/crates/ty_python_semantic/src/types/method.rs b/crates/ty_python_semantic/src/types/method.rs index ed12320b68..d69e91c3f5 100644 --- a/crates/ty_python_semantic/src/types/method.rs +++ b/crates/ty_python_semantic/src/types/method.rs @@ -224,6 +224,8 @@ pub enum KnownBoundMethodType<'db> { ConstraintSetSatisfies(InternedConstraintSet<'db>), ConstraintSetForAll(InternedConstraintSet<'db>), ConstraintSetSatisfiedByAllTypeVars(InternedConstraintSet<'db>), + ConstraintSetSolutionsFor(InternedConstraintSet<'db>), + ConstraintSetSolutions(InternedConstraintSet<'db>), ConstraintSetWithDetailedDisplay(InternedConstraintSet<'db>), } @@ -261,6 +263,8 @@ pub(super) fn walk_method_wrapper_type<'db, V: visitor::TypeVisitor<'db> + ?Size | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => {} } } @@ -306,6 +310,8 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => Some(self), } } @@ -326,6 +332,8 @@ impl<'db> KnownBoundMethodType<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => { KnownClass::ConstraintSet } @@ -511,6 +519,47 @@ impl<'db> KnownBoundMethodType<'db> { ))) } + KnownBoundMethodType::ConstraintSetSolutionsFor(_) => { + Either::Right(std::iter::once(Signature::new( + Parameters::standard([ + Parameter::positional_only(Some(Name::new_static("typevar"))) + .with_annotated_type(object_type_form()), + Parameter::keyword_only(Name::new_static("inferable")).with_annotated_type( + TypeFormType::from_type_expression( + db, + Type::homogeneous_tuple(db, Type::object()), + ), + ), + ]), + UnionType::from_two_elements( + db, + Type::homogeneous_tuple( + db, + KnownClass::ConstraintSetSolution.to_instance(db), + ), + Type::none(db), + ), + ))) + } + + KnownBoundMethodType::ConstraintSetSolutions(_) => { + Either::Right(std::iter::once(Signature::new( + Parameters::standard([Parameter::keyword_only(Name::new_static("inferable")) + .with_annotated_type(TypeFormType::from_type_expression( + db, + Type::homogeneous_tuple(db, Type::object()), + ))]), + UnionType::from_two_elements( + db, + Type::homogeneous_tuple( + db, + KnownClass::ConstraintSetSolution.to_instance(db), + ), + Type::none(db), + ), + ))) + } + KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_) => { Either::Right(std::iter::once(Signature::new( Parameters::empty(), @@ -584,6 +633,14 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_), ) + | ( + KnownBoundMethodType::ConstraintSetSolutionsFor(_), + KnownBoundMethodType::ConstraintSetSolutionsFor(_), + ) + | ( + KnownBoundMethodType::ConstraintSetSolutions(_), + KnownBoundMethodType::ConstraintSetSolutions(_), + ) | ( KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), @@ -603,6 +660,8 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), KnownBoundMethodType::FunctionTypeDunderGet(_) | KnownBoundMethodType::FunctionTypeDunderCall(_) @@ -617,6 +676,8 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) => self.never(), } diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 1dff1e283e..ed979f9bb8 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -264,6 +264,8 @@ impl<'db> Type<'db> { | KnownBoundMethodType::ConstraintSetSatisfies(_) | KnownBoundMethodType::ConstraintSetForAll(_) | KnownBoundMethodType::ConstraintSetSatisfiedByAllTypeVars(_) + | KnownBoundMethodType::ConstraintSetSolutionsFor(_) + | KnownBoundMethodType::ConstraintSetSolutions(_) | KnownBoundMethodType::ConstraintSetWithDetailedDisplay(_), ) | Type::DataclassDecorator(_) diff --git a/crates/ty_static/src/env_vars.rs b/crates/ty_static/src/env_vars.rs index 381623820d..49701a39fd 100644 --- a/crates/ty_static/src/env_vars.rs +++ b/crates/ty_static/src/env_vars.rs @@ -32,6 +32,13 @@ impl EnvVars { #[attr_hidden] pub const TY_MEMORY_REPORT: &'static str = "TY_MEMORY_REPORT"; + /// Perturbs constraint-set variable ordering to help detect order-dependent inference. + /// + /// Set to `reverse` to reverse builder-local IDs, or to an integer to select an arbitrary + /// permutation of the naturally chosen variable ordering. + #[attr_hidden] + pub const TY_CONSTRAINT_SET_ORDER: &'static str = "TY_CONSTRAINT_SET_ORDER"; + /// Specifies an upper limit for the number of tasks ty is allowed to run in parallel. /// /// For example, how many files should be checked in parallel. diff --git a/crates/ty_vendored/ty_extensions/_internal.pyi b/crates/ty_vendored/ty_extensions/_internal.pyi index de43623d53..11896f6b01 100644 --- a/crates/ty_vendored/ty_extensions/_internal.pyi +++ b/crates/ty_vendored/ty_extensions/_internal.pyi @@ -79,6 +79,9 @@ used by ty and cannot be used in annotations. # Constraints # ----------- +class ConstraintSetSolution: + """One solution path for a constraint set.""" + class ConstraintSet: @staticmethod def range( @@ -129,6 +132,31 @@ class ConstraintSet: constraint set will be considered non-inferable. """ + def solutions_for( + self, + typevar: TypeForm[object], + *, + inferable: TypeForm[tuple[object, ...]], + ) -> tuple[ConstraintSetSolution, ...] | None: + """ + Returns the explicit solutions inferred for `typevar` across all paths. + + `inferable` specifies all typevars that should be solved for. Every + solution path is preserved, with its bindings filtered to `typevar`. + Returns `None` if the constraint set is unsatisfiable. + """ + + def solutions( + self, *, inferable: TypeForm[tuple[object, ...]] + ) -> tuple[ConstraintSetSolution, ...] | None: + """ + Returns all explicit solutions, preserving path and binding order. + + `inferable` specifies all typevars that should be solved for. Each + solution contains the bindings inferred on one satisfying path. + Returns `None` if the constraint set is unsatisfiable. + """ + def __bool__(self) -> bool: ... def __eq__(self, other: ConstraintSet) -> bool: ... def __ne__(self, other: ConstraintSet) -> bool: ... From f34ad603f07c766f9c4941a2f23ae88c84812c93 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 20 Jul 2026 16:38:07 -0700 Subject: [PATCH 168/222] [ty] Avoid recursive TypeVarTuple alias expansion (#27032) ## Summary Closes astral-sh/ty#4045. Recursive aliases that extend a `TypeVarTuple` specialization can create a fresh specialized alias on every expansion while ty checks a type-parameter default. The self-reference guard previously keyed on the complete specialized alias, so it never recognized the repeated alias origin and could hang or overflow the stack. This keys the guard on the stable alias definition while preserving the existing specialized-argument checks. ## Test Plan Add mdtest regression coverage for the original nested case, prefix/suffix pack growth, and a well-formed recursive TypeVarTuple default. --- .../mdtest/generics/pep695/aliases.md | 26 +++++++++++++++++++ .../ty_python_semantic/src/types/typevar.rs | 9 ++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md index 51dde06b26..c1b8684556 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md @@ -317,6 +317,32 @@ def _(p: P) -> None: pass ``` +## Recursive TypeVarTuple alias defaults + +Recursive aliases that extend a `TypeVarTuple` specialization must not recursively expand while +checking their defaults. + +```toml +[environment] +python-version = "3.13" +``` + +```py +# error: [invalid-legacy-type-variable] +# error: [invalid-type-form] +type Nested[*Ts = Nested[*Ts]] = tuple[Nested[*Ts, Nested[*Ts]]] + +# error: [invalid-legacy-type-variable] +# error: [invalid-type-form] +type Suffix[*Ts = Suffix[*Ts]] = list[Suffix[*Ts, int]] + +# error: [invalid-legacy-type-variable] +# error: [invalid-type-form] +type Prefix[*Ts = Prefix[*Ts]] = tuple[Prefix[int, *Ts]] + +type ValidDefault[*Ts = *tuple[ValidDefault[int]]] = tuple[ValidDefault[*Ts, int]] +``` + ## Snapshots of verbose diagnostics ```py diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 1de2606bb1..2bc2b3edef 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -365,7 +365,7 @@ impl<'db> TypeVarInstance<'db> { ty: Type<'db>, visitor: &TypeVarDefaultVisitor<'db>, ) -> bool { - type SeenTypeAliases<'db> = SmallVec<[TypeAliasType<'db>; 1]>; + type SeenTypeAliases<'db> = SmallVec<[Definition<'db>; 1]>; #[derive(Copy, Clone)] struct State<'db, 'a> { @@ -402,10 +402,13 @@ impl<'db> TypeVarInstance<'db> { ) -> bool { { let mut seen_type_aliases = state.seen_type_aliases.borrow_mut(); - if seen_type_aliases.contains(&type_alias) { + let definition = type_alias.definition(state.db); + // A recursive alias can produce a new specialization every time its body is + // expanded, so use its definition as the stable recursion key. + if seen_type_aliases.contains(&definition) { return false; } - seen_type_aliases.push(type_alias); + seen_type_aliases.push(definition); } let value_type = if let Some(specialization) = type_alias.specialization(state.db) { From ba777a806bfea80bb1e2ee3431a482f9edcf300e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 21:52:03 -0400 Subject: [PATCH 169/222] [ty] Cache non-terminal-call reachability prefixes (#26810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Statement-level call predicates are assigned in source order but appear in reverse order in reachability decision diagrams. We warm preceding calls in source order to avoid a deeply recursive Salsa query chain, but the existing implementation rescans the entire predicate prefix for every later expression-inference query. With `n` statement calls, the query for call `i` scans the first `i` predicates, producing `1 + 2 + ... + n = O(n²)` total scanning even though the individual call analyses are cached. This change indexes statement-call predicates once for scopes with more than 16 calls and decomposes each requested prefix into reusable power-of-two ranges. Each leaf analyzes 16 actual calls iteratively, while smaller scopes retain the direct path without allocating the tracked index. This revisits the approach from #26763, using call-based rather than raw-predicate-based chunks so the cached work remains proportional to the expensive predicates. This is the first of three related PRs. [#26811](https://github.com/astral-sh/ruff/pull/26811) caches sparse reachability checkpoints, and [#26793](https://github.com/astral-sh/ruff/pull/26793) defers statement-call narrowing gates. --- crates/ruff_benchmark/benches/ty.rs | 24 ++++ crates/ty_python_semantic/src/reachability.rs | 113 +++++++++++++++--- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index 599e190589..bfdfcb30ca 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1413,6 +1413,29 @@ fn benchmark_typeis_narrowing(criterion: &mut Criterion) { }); } +/// Regression benchmark for . +/// +/// Each statement-level call creates a reachability predicate. Repeatedly scanning every preceding +/// predicate while checking later expressions makes this pattern quadratic. +fn benchmark_repeated_statement_calls(criterion: &mut Criterion) { + setup_rayon(); + + let mut code = String::from("def f() -> None:\n value = 'abc'\n"); + code.push_str(&" value.upper()\n".repeat(1_500)); + + criterion.bench_function("ty_micro[repeated_statement_calls]", |b| { + b.iter_batched_ref( + || setup_micro_case(&code), + |case| { + let Case { db, .. } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); +} + /// Benchmarks solving many union-bearing upper bounds while inferring a generic call. /// /// Each callable argument places a distinct union upper bound on `T` through callable-parameter @@ -1930,6 +1953,7 @@ criterion_group!( benchmark_literal_equality_fallthrough_guarded_any, benchmark_literal_or_pattern_reachability, benchmark_typeis_narrowing, + benchmark_repeated_statement_calls, benchmark_factored_upper_bounds, benchmark_pandas_tdd, benchmark_recursive_typed_dict_union_contextual_inference, diff --git a/crates/ty_python_semantic/src/reachability.rs b/crates/ty_python_semantic/src/reachability.rs index f589947693..2c7fe729c2 100644 --- a/crates/ty_python_semantic/src/reachability.rs +++ b/crates/ty_python_semantic/src/reachability.rs @@ -228,6 +228,7 @@ use ty_python_core::{ }, reachability_constraints::{ReachabilityConstraints, ScopedReachabilityConstraintId}, scope::ScopeId, + use_def_map, }; /// Narrow `subject_ty` by all preceding unguarded match patterns. @@ -526,6 +527,8 @@ std::thread_local! { static ACTIVE_NON_TERMINAL_CALL_PREFIXES: ActiveRecursionDetector = ActiveRecursionDetector::default(); } +const NON_TERMINAL_CALL_CHUNK_SIZE: usize = 16; + fn predicate_scope<'db>(db: &'db dyn Db, predicate: &Predicate<'db>) -> ScopeId<'db> { match predicate.node { PredicateNode::Expression(expression) => expression.scope(db), @@ -562,33 +565,113 @@ fn analyze_non_terminal_call_prefix<'db>( predicates: &IndexSlice>, root_predicate: ScopedPredicateId, ) { - let range = 0..=root_predicate.index(); - if !range.clone().any(|index| { - matches!( - predicates[ScopedPredicateId::new(index)].node, - PredicateNode::IsNonTerminalCall(_) - ) - }) { - return; - } + let scope = predicate_scope(db, &predicates[root_predicate]); + let has_many_calls = predicates + .iter() + .filter(|predicate| matches!(predicate.node, PredicateNode::IsNonTerminalCall(_))) + .nth(NON_TERMINAL_CALL_CHUNK_SIZE) + .is_some(); - let key = predicate_scope(db, &predicates[root_predicate]).as_id(); ACTIVE_NON_TERMINAL_CALL_PREFIXES.with(|active| { active.visit( - &key, + &scope.as_id(), || {}, || { - for index in range { - let predicate = &predicates[ScopedPredicateId::new(index)]; - if matches!(predicate.node, PredicateNode::IsNonTerminalCall(_)) { - analyze_single(db, predicate); + if !has_many_calls { + for predicate in &predicates.raw[..=root_predicate.index()] { + if matches!(predicate.node, PredicateNode::IsNonTerminalCall(_)) { + analyze_single(db, predicate); + } } + return; + } + + let call_predicates = non_terminal_call_predicates(db, scope); + let call_count = + call_predicates.partition_point(|predicate| *predicate <= root_predicate); + if call_count <= NON_TERMINAL_CALL_CHUNK_SIZE { + analyze_non_terminal_calls(db, predicates, &call_predicates[..call_count]); + return; } + + let mut start = 0; + let mut remaining = call_count / NON_TERMINAL_CALL_CHUNK_SIZE; + + while remaining > 0 { + let level = remaining.ilog2(); + let length = 1 << level; + analyze_non_terminal_call_range(db, scope, level, start >> level); + start += length; + remaining -= length; + } + + let tail_start = + call_count / NON_TERMINAL_CALL_CHUNK_SIZE * NON_TERMINAL_CALL_CHUNK_SIZE; + analyze_non_terminal_calls( + db, + predicates, + &call_predicates[tail_start..call_count], + ); }, ); }); } +/// Returns the statement-call predicates for `scope` in source order. +/// +/// This tracked index is used only once a scope exceeds [`NON_TERMINAL_CALL_CHUNK_SIZE`], avoiding +/// a persistent allocation for the common case of scopes with few calls. +#[salsa::tracked(returns(deref), heap_size = get_size2::GetSize::get_heap_size)] +fn non_terminal_call_predicates<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, +) -> Box<[ScopedPredicateId]> { + use_def_map(db, scope) + .predicates() + .iter_enumerated() + .filter_map(|(id, predicate)| { + matches!(predicate.node, PredicateNode::IsNonTerminalCall(_)).then_some(id) + }) + .collect() +} + +fn analyze_non_terminal_calls<'db>( + db: &'db dyn Db, + predicates: &IndexSlice>, + call_predicates: &[ScopedPredicateId], +) { + for id in call_predicates { + analyze_single(db, &predicates[*id]); + } +} + +/// Analyzes a power-of-two range of call-predicate blocks in source order. +/// +/// Prefixes can be decomposed into these canonical ranges and reused by later expression-inference +/// queries. Splitting ranges in half keeps the Salsa query stack logarithmic even when the first +/// requested prefix contains thousands of calls. Each leaf handles multiple calls iteratively to +/// avoid retaining a Salsa argument and query result for every individual predicate. +#[salsa::tracked(returns(copy), heap_size = get_size2::GetSize::get_heap_size)] +fn analyze_non_terminal_call_range<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + level: u32, + index: usize, +) { + if level == 0 { + let use_def = use_def_map(db, scope); + let call_predicates = non_terminal_call_predicates(db, scope); + let start = index * NON_TERMINAL_CALL_CHUNK_SIZE; + let end = start + NON_TERMINAL_CALL_CHUNK_SIZE; + analyze_non_terminal_calls(db, use_def.predicates(), &call_predicates[start..end]); + return; + } + + let child_index = index * 2; + analyze_non_terminal_call_range(db, scope, level - 1, child_index); + analyze_non_terminal_call_range(db, scope, level - 1, child_index + 1); +} + pub(crate) trait ReachabilityConstraintsExtension<'db> { /// Analyze the statically known reachability for a given constraint. fn evaluate( From 850ac2167c19bf09f64cddac91864202b38ae29a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 22:02:56 -0400 Subject: [PATCH 170/222] [ty] Cache sparse reachability checkpoints (#26811) ## Summary #26810 avoids repeatedly scanning and inferring the statement-call prefix, but each standalone expression-inference query can still walk the complete accumulated reachability decision diagram. Repeating that walk after every statement remains quadratic even when the individual predicate analyses are already cached. This change adds a Salsa-tracked checkpoint at every 256th statement-call predicate. Later evaluations can reuse the result of an earlier decision-diagram suffix while retaining only a sparse set of query keys and memos. Scopes with at most 16 calls continue to use the direct evaluator, and checkpoint queries conservatively participate in cycle recovery. This is the second part of the fix for https://github.com/astral-sh/ty/issues/3986 and is stacked on #26810. --- crates/ty_python_semantic/src/reachability.rs | 177 +++++++++++++----- 1 file changed, 132 insertions(+), 45 deletions(-) diff --git a/crates/ty_python_semantic/src/reachability.rs b/crates/ty_python_semantic/src/reachability.rs index 2c7fe729c2..f0c2afa8f1 100644 --- a/crates/ty_python_semantic/src/reachability.rs +++ b/crates/ty_python_semantic/src/reachability.rs @@ -528,6 +528,7 @@ std::thread_local! { } const NON_TERMINAL_CALL_CHUNK_SIZE: usize = 16; +const REACHABILITY_EVALUATION_CHUNK_SIZE: usize = 256; fn predicate_scope<'db>(db: &'db dyn Db, predicate: &Predicate<'db>) -> ScopeId<'db> { match predicate.node { @@ -564,7 +565,7 @@ fn analyze_non_terminal_call_prefix<'db>( db: &'db dyn Db, predicates: &IndexSlice>, root_predicate: ScopedPredicateId, -) { +) -> bool { let scope = predicate_scope(db, &predicates[root_predicate]); let has_many_calls = predicates .iter() @@ -615,6 +616,8 @@ fn analyze_non_terminal_call_prefix<'db>( }, ); }); + + has_many_calls } /// Returns the statement-call predicates for `scope` in source order. @@ -672,6 +675,118 @@ fn analyze_non_terminal_call_range<'db>( analyze_non_terminal_call_range(db, scope, level - 1, child_index + 1); } +/// Evaluates a reachability constraint after warming its statement-call prefix. +/// +/// Large scopes reuse canonical call ranges and sparse decision-diagram checkpoints; small scopes +/// retain the direct evaluation path without creating either cached index. +fn evaluate_reachability_constraint<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + id: ScopedReachabilityConstraintId, +) -> Truthiness { + if let Some(reachability) = terminal_reachability(id) { + return reachability; + } + + let use_def = use_def_map(db, scope); + let constraints = use_def.reachability_constraints(); + let predicates = use_def.predicates(); + let root_predicate = constraints.get_interior_node(id).atom(); + let has_many_calls = analyze_non_terminal_call_prefix(db, predicates, root_predicate); + let call_predicates = has_many_calls.then(|| non_terminal_call_predicates(db, scope)); + + evaluate_reachability_path( + db, + scope, + constraints, + predicates, + call_predicates, + id, + true, + ) +} + +fn terminal_reachability(id: ScopedReachabilityConstraintId) -> Option { + match id { + ScopedReachabilityConstraintId::ALWAYS_TRUE => Some(Truthiness::AlwaysTrue), + ScopedReachabilityConstraintId::AMBIGUOUS => Some(Truthiness::Ambiguous), + ScopedReachabilityConstraintId::ALWAYS_FALSE => Some(Truthiness::AlwaysFalse), + _ => None, + } +} + +fn is_reachability_checkpoint( + call_predicates: &[ScopedPredicateId], + predicate: ScopedPredicateId, +) -> bool { + call_predicates + .binary_search(&predicate) + .is_ok_and(|index| (index + 1) % REACHABILITY_EVALUATION_CHUNK_SIZE == 0) +} + +/// Walks a reachability decision diagram until it reaches a terminal or reusable checkpoint. +/// +/// `use_checkpoint` is false only when entering from a checkpoint query. In that case, the first +/// node is evaluated directly to prevent the query from immediately calling itself again. +fn evaluate_reachability_path<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + constraints: &ReachabilityConstraints, + predicates: &IndexSlice>, + call_predicates: Option<&[ScopedPredicateId]>, + mut id: ScopedReachabilityConstraintId, + mut use_checkpoint: bool, +) -> Truthiness { + loop { + if let Some(reachability) = terminal_reachability(id) { + return reachability; + } + + let node = constraints.get_interior_node(id); + if use_checkpoint + && call_predicates.is_some_and(|call_predicates| { + is_reachability_checkpoint(call_predicates, node.atom()) + }) + { + return evaluate_reachability_checkpoint(db, scope, id); + } + + id = match analyze_single(db, &predicates[node.atom()]) { + Truthiness::AlwaysTrue => node.if_true(), + Truthiness::Ambiguous => node.if_ambiguous(), + Truthiness::AlwaysFalse => node.if_false(), + }; + use_checkpoint = true; + } +} + +/// Evaluates a canonical suffix of a reachability decision diagram. +/// +/// Only every [`REACHABILITY_EVALUATION_CHUNK_SIZE`]th non-terminal-call predicate is a checkpoint. +/// This lets later statements reuse the constraints accumulated by earlier statements without +/// retaining a Salsa query key and memo for every reachability constraint in the scope. +#[salsa::tracked( + returns(copy), + cycle_initial = |_, _, _, _| Truthiness::Ambiguous, + heap_size = get_size2::GetSize::get_heap_size +)] +fn evaluate_reachability_checkpoint<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + id: ScopedReachabilityConstraintId, +) -> Truthiness { + let use_def = use_def_map(db, scope); + evaluate_reachability_path( + db, + scope, + use_def.reachability_constraints(), + use_def.predicates(), + Some(non_terminal_call_predicates(db, scope)), + id, + false, + ) +} + pub(crate) trait ReachabilityConstraintsExtension<'db> { /// Analyze the statically known reachability for a given constraint. fn evaluate( @@ -688,51 +803,23 @@ impl<'db> ReachabilityConstraintsExtension<'db> for ReachabilityConstraints { &self, db: &'db dyn Db, predicates: &IndexSlice>, - mut id: ScopedReachabilityConstraintId, + id: ScopedReachabilityConstraintId, ) -> Truthiness { - type Id = ScopedReachabilityConstraintId; - - // Analyze statement-level calls through this root one by one in source order, so any - // earlier call needed while inferring a later one is already cached instead of deepening - // the Salsa query stack. This avoids growing an excessive stack for deeply nested - // reachability queries. - // - // Without this prefix analysis, given: - // - // call_a() # predicate 0 - // call_b() # predicate 1 - // call_c() # predicate 2 - // - // we'd analyze them backwards: - // - // analyze call_c - // └─ analyze call_b - // └─ analyze call_a - // - // The prefix pass explicitly analyzes them forwards: - // - // analyze call_a → cached - // analyze call_b → call_a is already cached - // analyze call_c → call_b is already cached - if !id.is_terminal() { - let root_predicate = self.get_interior_node(id).atom(); - analyze_non_terminal_call_prefix(db, predicates, root_predicate); + if let Some(reachability) = terminal_reachability(id) { + return reachability; } - loop { - let node = match id { - Id::ALWAYS_TRUE => return Truthiness::AlwaysTrue, - Id::AMBIGUOUS => return Truthiness::Ambiguous, - Id::ALWAYS_FALSE => return Truthiness::AlwaysFalse, - _ => self.get_interior_node(id), - }; - let predicate = &predicates[node.atom()]; - match analyze_single(db, predicate) { - Truthiness::AlwaysTrue => id = node.if_true(), - Truthiness::Ambiguous => id = node.if_ambiguous(), - Truthiness::AlwaysFalse => id = node.if_false(), - } - } + let root_predicate = self.get_interior_node(id).atom(); + analyze_non_terminal_call_prefix(db, predicates, root_predicate); + evaluate_reachability_path( + db, + predicate_scope(db, &predicates[root_predicate]), + self, + predicates, + None, + id, + true, + ) } } @@ -1578,7 +1665,7 @@ impl<'db> ReachabilityEvaluationCache<'db> { return result; } - let result = constraints.evaluate(db, predicates, id); + let result = evaluate_reachability_constraint(db, scope, id); self.other_entries.borrow_mut().insert(key, result); return result; } @@ -1588,7 +1675,7 @@ impl<'db> ReachabilityEvaluationCache<'db> { return result; } - let result = constraints.evaluate(db, predicates, id); + let result = evaluate_reachability_constraint(db, self.primary_scope, id); let mut entries = self.primary_entries.borrow_mut(); if entries.len() <= index { entries.resize(index + 1, None); From db17b17a825963a29df8c9435f2257ecdb17c66d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 20 Jul 2026 22:15:11 -0400 Subject: [PATCH 171/222] [ty] Defer statement-call narrowing gates (#26793) ## Summary This reapplies the deferred call-gate optimization from #26775 after it was reverted in #26792, and addresses the [post-merge finding on #26775](https://github.com/astral-sh/ruff/pull/26775#issuecomment-4964970253). It is stacked on the prefix and reachability-evaluation caches in #26810 and #26811. Prior to this change, each statement-level call immediately applied an `IsNonTerminalCall` narrowing gate to every live place so calls returning `Never` could invalidate later narrowing. Repeated calls therefore rebuilt all live place states after every statement. We now store the narrowing gate alongside the pending scope-wide reachability constraint, materialize reachability when a place is used, and replay its narrowing gates only before that place changes or merges. The previous implementation dropped all pending call gates when a nested control-flow merge left a place untouched. That also discarded gates recorded before the branches diverged, allowing narrowing from an unreachable path to affect the merged type. We now find the branch tips' shared ancestor once per flow merge, preserve the shared call gates through that point, and combine only the branch-local suffixes. The repeated-call benchmark now covers unnarrowed, pre-narrowed, and fixed-reachability bindings, and the narrowing mdtests cover shared and branch-local gates across nested merges. Closes https://github.com/astral-sh/ty/issues/3986. --- crates/ruff_benchmark/benches/ty.rs | 54 ++-- crates/ty_python_core/src/builder.rs | 15 +- crates/ty_python_core/src/use_def.rs | 266 ++++++++++++++++-- .../mdtest/narrow/post_if_statement.md | 84 ++++++ 4 files changed, 364 insertions(+), 55 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index bfdfcb30ca..46faee666e 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1415,25 +1415,47 @@ fn benchmark_typeis_narrowing(criterion: &mut Criterion) { /// Regression benchmark for . /// -/// Each statement-level call creates a reachability predicate. Repeatedly scanning every preceding -/// predicate while checking later expressions makes this pattern quadratic. +/// Non-terminal-call predicates must gate later narrowing. Keeping these scope-wide constraints in +/// an append-only tree avoids eagerly rewriting every live place state after each call. Exercise +/// unnarrowed, already-narrowed, and fixed-reachability bindings because each takes a different +/// path through reachability and narrowing evaluation. fn benchmark_repeated_statement_calls(criterion: &mut Criterion) { setup_rayon(); - let mut code = String::from("def f() -> None:\n value = 'abc'\n"); - code.push_str(&" value.upper()\n".repeat(1_500)); - - criterion.bench_function("ty_micro[repeated_statement_calls]", |b| { - b.iter_batched_ref( - || setup_micro_case(&code), - |case| { - let Case { db, .. } = case; - let result = db.check(); - assert_eq!(result.len(), 0); - }, - BatchSize::SmallInput, - ); - }); + let cases = [ + ( + "ty_micro[repeated_statement_calls]", + String::from("def f() -> None:\n value = 'abc'\n"), + " value.upper()\n", + ), + ( + "ty_micro[repeated_statement_calls_pre_narrowed]", + String::from( + "def f(value: str | None) -> None:\n if value is None:\n return\n", + ), + " value.upper()\n", + ), + ( + "ty_micro[repeated_statement_calls_fixed_reachability]", + String::from("def f(value: str, flag: bool) -> None:\n if flag:\n"), + " value.upper()\n", + ), + ]; + + for (name, mut code, statement) in cases { + code.push_str(&statement.repeat(1_500)); + criterion.bench_function(name, |b| { + b.iter_batched_ref( + || setup_micro_case(&code), + |case| { + let Case { db, .. } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); + } } /// Benchmarks solving many union-bearing upper bounds while inferring a generic call. diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 36a8e264fd..9cf72470ab 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -4199,15 +4199,14 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .add_atom(predicate_id); if self.in_function_scope() { - self.record_reachability_constraint_id(predicate_id); - - // Also gate narrowing by this constraint: if the call returns - // `Never`, any narrowing in the current branch should be - // invalidated (since this path is unreachable). This enables - // narrowing to be preserved after if-statements where one branch - // calls a `NoReturn` function like `sys.exit()`. + let reachability_constraint = self + .current_reachability_constraints_mut() + .add_atom(predicate_id); self.current_use_def_map_mut() - .record_narrowing_constraint_for_all_places(narrowing_constraint); + .record_non_terminal_call_constraints( + reachability_constraint, + narrowing_constraint, + ); } else { // In non-function scopes, we only record a narrowing constraint // (not a reachability constraint). Recording reachability for diff --git a/crates/ty_python_core/src/use_def.rs b/crates/ty_python_core/src/use_def.rs index 9babfd5b09..3caac2daa5 100644 --- a/crates/ty_python_core/src/use_def.rs +++ b/crates/ty_python_core/src/use_def.rs @@ -1382,14 +1382,14 @@ struct PendingReachabilityId; #[derive(Debug)] struct PendingReachabilityConstraint { parent: PendingReachabilityId, - constraint: ScopedReachabilityConstraintId, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, } -/// An append-only tree of scope-wide reachability constraints. +/// An append-only tree of scope-wide reachability constraints and call narrowing gates. /// -/// Each [`PendingPlaceState`] remembers the last node applied to its place state, so snapshots can -/// share place states and defer applying subsequent constraints until the place is observed or -/// changed. +/// Each [`PendingPlaceState`] remembers the last node applied for each constraint kind, so +/// snapshots can share place states and defer applying subsequent constraints until needed. #[derive(Debug)] struct PendingReachability { constraints: IndexVec, @@ -1402,7 +1402,8 @@ impl Default for PendingReachability { let root = constraints.next_index(); constraints.push(PendingReachabilityConstraint { parent: root, - constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, + reachability_constraint: ScopedReachabilityConstraintId::ALWAYS_TRUE, + narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, }); Self { constraints, @@ -1412,18 +1413,67 @@ impl Default for PendingReachability { } impl PendingReachability { - fn push(&mut self, constraint: ScopedReachabilityConstraintId) { + fn push( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, + ) { self.current = self.constraints.push(PendingReachabilityConstraint { parent: self.current, - constraint, + reachability_constraint, + narrowing_constraint, }); } - /// Applies the constraints between the place's last materialized node and `target`. + /// Applies both constraint kinds between the place's last materialized nodes and `target`. /// /// The place's node must be an ancestor of `target`. After materialization, the place is /// uniquely owned for mutation and records `target` as its last applied node. fn materialize<'a>( + &self, + pending: &'a mut PendingPlaceState, + target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, + reachability_constraints: &mut ReachabilityConstraintsBuilder, + ) -> &'a mut PlaceState { + self.materialize_reachability(pending, target, reachability_constraints); + self.materialize_narrowing(pending, target, narrowing_constraints); + + Rc::make_mut(&mut pending.state) + } + + fn materialize_narrowing( + &self, + pending: &mut PendingPlaceState, + target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, + ) { + if pending.narrowing != target { + let mut unapplied = SmallVec::<[ScopedNarrowingConstraint; 4]>::new(); + let mut current = target; + while current != pending.narrowing { + let event = &self.constraints[current]; + if event.narrowing_constraint != ScopedNarrowingConstraint::ALWAYS_TRUE { + unapplied.push(event.narrowing_constraint); + } + assert_ne!( + current, event.parent, + "pending narrowing must be an ancestor" + ); + current = event.parent; + } + + if !unapplied.is_empty() { + let state = Rc::make_mut(&mut pending.state); + for constraint in unapplied.into_iter().rev() { + state.record_narrowing_constraint(narrowing_constraints, constraint); + } + } + pending.narrowing = target; + } + } + + fn materialize_reachability<'a>( &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, @@ -1434,7 +1484,7 @@ impl PendingReachability { let mut current = target; while current != pending.reachability { let event = &self.constraints[current]; - unapplied.push(event.constraint); + unapplied.push(event.reachability_constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1461,14 +1511,34 @@ impl PendingReachability { &self, pending: &'a mut PendingPlaceState, target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) -> &'a PlaceState { - if pending.reachability != target { - self.materialize(pending, target, reachability_constraints); + if pending.reachability != target || pending.narrowing != target { + self.materialize( + pending, + target, + narrowing_constraints, + reachability_constraints, + ); } &pending.state } + /// Returns the place state needed to resolve a use. + /// + /// A call's narrowing gate is only needed if the place is later changed or merged, so it is + /// not materialized here. + fn materialize_ref_at_use<'a>( + &self, + pending: &'a mut PendingPlaceState, + target: PendingReachabilityId, + reachability_constraints: &mut ReachabilityConstraintsBuilder, + ) -> &'a PlaceState { + self.materialize_reachability(pending, target, reachability_constraints); + &pending.state + } + /// Combines the constraints after `ancestor` through `target` into a single constraint. /// /// `ancestor` must be an ancestor of `target`. @@ -1482,7 +1552,8 @@ impl PendingReachability { let mut current = target; while current != ancestor { let event = &self.constraints[current]; - constraint = reachability_constraints.add_and_constraint(constraint, event.constraint); + constraint = reachability_constraints + .add_and_constraint(constraint, event.reachability_constraint); assert_ne!( current, event.parent, "pending reachability must be an ancestor" @@ -1491,6 +1562,52 @@ impl PendingReachability { } constraint } + + /// Combines the call narrowing gates after `ancestor` through `target` into one constraint. + /// + /// `ancestor` must be an ancestor of `target`. + fn narrowing_constraint_between( + &self, + ancestor: PendingReachabilityId, + target: PendingReachabilityId, + narrowing_constraints: &mut NarrowingConstraintsBuilder, + ) -> ScopedNarrowingConstraint { + let mut unapplied = SmallVec::<[ScopedNarrowingConstraint; 4]>::new(); + let mut current = target; + while current != ancestor { + let event = &self.constraints[current]; + if event.narrowing_constraint != ScopedNarrowingConstraint::ALWAYS_TRUE { + unapplied.push(event.narrowing_constraint); + } + assert_ne!( + current, event.parent, + "pending narrowing must be an ancestor" + ); + current = event.parent; + } + + let mut constraint = ScopedNarrowingConstraint::ALWAYS_TRUE; + for pending in unapplied.into_iter().rev() { + constraint = narrowing_constraints.add_and_constraint(constraint, pending); + } + constraint + } + + /// Returns the lowest common ancestor of two nodes in the pending-constraint tree. + fn common_ancestor( + &self, + mut left: PendingReachabilityId, + mut right: PendingReachabilityId, + ) -> PendingReachabilityId { + while left != right { + if left.index() > right.index() { + left = self.constraints[left].parent; + } else { + right = self.constraints[right].parent; + } + } + left + } } /// A copy-on-write place state and the last reachability node materialized into it. @@ -1498,6 +1615,7 @@ impl PendingReachability { struct PendingPlaceState { state: Rc, reachability: PendingReachabilityId, + narrowing: PendingReachabilityId, } impl PendingPlaceState { @@ -1505,6 +1623,7 @@ impl PendingPlaceState { Self { state: Rc::new(state), reachability, + narrowing: reachability, } } } @@ -1535,10 +1654,22 @@ impl PendingReachability { narrowing_constraints: &mut NarrowingConstraintsBuilder, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { + let branch_ancestor = self.common_ancestor(self.current, branch); + let current_narrowing = + self.narrowing_constraint_between(branch_ancestor, self.current, narrowing_constraints); + let branch_narrowing = + self.narrowing_constraint_between(branch_ancestor, branch, narrowing_constraints); + let merged_narrowing = + narrowing_constraints.add_or_constraint(current_narrowing, branch_narrowing); let mut branch_states = branch_states.into_iter(); for current in current_states { let Some(mut branch_state) = branch_states.next() else { - let current = self.materialize(current, self.current, reachability_constraints); + let current = self.materialize( + current, + self.current, + narrowing_constraints, + reachability_constraints, + ); current.merge( PlaceState::undefined(branch_reachability), narrowing_constraints, @@ -1551,12 +1682,22 @@ impl PendingReachability { // common case is a truthy/falsy pair whose constraints cancel to `ALWAYS_TRUE`, leaving // the shared state untouched. if current.reachability == branch_state.reachability + && current.narrowing == branch_state.narrowing && Rc::ptr_eq(¤t.state, &branch_state.state) { if self.current == branch { continue; } + // Preserve call gates that precede the branch, then merge gates introduced on the + // individual branch paths. If either path has no gate, the merged gate simplifies + // to `ALWAYS_TRUE` and can be discarded. + self.materialize_narrowing(current, branch_ancestor, narrowing_constraints); + if merged_narrowing != ScopedNarrowingConstraint::ALWAYS_TRUE { + Rc::make_mut(&mut current.state) + .record_narrowing_constraint(narrowing_constraints, merged_narrowing); + } + let current_constraint = self.constraint_between( current.reachability, self.current, @@ -1576,12 +1717,23 @@ impl PendingReachability { ); } current.reachability = self.current; + current.narrowing = self.current; continue; } - self.materialize(&mut branch_state, branch, reachability_constraints); + self.materialize( + &mut branch_state, + branch, + narrowing_constraints, + reachability_constraints, + ); let branch_state = Rc::unwrap_or_clone(branch_state.state); - let current = self.materialize(current, self.current, reachability_constraints); + let current = self.materialize( + current, + self.current, + narrowing_constraints, + reachability_constraints, + ); current.merge( branch_state, narrowing_constraints, @@ -1763,6 +1915,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let definitions_at_definition = DefinitionsAtDefinition { @@ -1856,6 +2009,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings_at_use( @@ -1885,6 +2039,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint_for_bindings( @@ -1931,6 +2086,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -1944,6 +2100,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint( @@ -1973,6 +2130,7 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .clone(); @@ -1981,6 +2139,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize_ref( &mut self.member_states[member_id], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); associated_member_states.insert(member_id, state.clone()); @@ -2034,6 +2193,7 @@ impl<'db> UseDefMapBuilder<'db> { let symbol_state = self.pending_reachability.materialize( &mut self.symbol_states[symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2062,6 +2222,7 @@ impl<'db> UseDefMapBuilder<'db> { let member_state = self.pending_reachability.materialize( &mut self.member_states[member_id], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); let mut post_definition_state = @@ -2103,6 +2264,7 @@ impl<'db> UseDefMapBuilder<'db> { let state = self.pending_reachability.materialize( state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); state.record_narrowing_constraint(&mut self.narrowing_constraints, constraint); @@ -2112,11 +2274,35 @@ impl<'db> UseDefMapBuilder<'db> { pub(super) fn record_reachability_constraint( &mut self, constraint: ScopedReachabilityConstraintId, + ) { + self.record_reachability_constraint_impl( + constraint, + ScopedNarrowingConstraint::ALWAYS_TRUE, + ); + } + + /// Records a call's reachability predicate and its corresponding narrowing gate together. + /// + /// Reachability is materialized when a place is used, while the narrowing gate remains pending + /// until that place is changed or merged. + pub(super) fn record_non_terminal_call_constraints( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, + ) { + self.record_reachability_constraint_impl(reachability_constraint, narrowing_constraint); + } + + fn record_reachability_constraint_impl( + &mut self, + reachability_constraint: ScopedReachabilityConstraintId, + narrowing_constraint: ScopedNarrowingConstraint, ) { self.reachability = self .reachability_constraints - .add_and_constraint(self.reachability, constraint); - self.pending_reachability.push(constraint); + .add_and_constraint(self.reachability, reachability_constraint); + self.pending_reachability + .push(reachability_constraint, narrowing_constraint); } pub(super) fn record_declaration( @@ -2131,6 +2317,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2169,6 +2356,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); place_state.record_declaration(def_id, self.reachability); @@ -2209,6 +2397,7 @@ impl<'db> UseDefMapBuilder<'db> { let place_state = self.pending_reachability.materialize( place_state, pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ); @@ -2226,11 +2415,12 @@ impl<'db> UseDefMapBuilder<'db> { let pending = self.pending_reachability.current; let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let bindings = self - .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) - .bindings() - .clone(); + let place_state = self.pending_reachability.materialize_ref_at_use( + place_state, + pending, + &mut self.reachability_constraints, + ); + let bindings = place_state.bindings().clone(); self.record_use_bindings(bindings, use_id); } @@ -2244,11 +2434,12 @@ impl<'db> UseDefMapBuilder<'db> { for place in places { let place_state = pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); - let bindings = self - .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) - .bindings() - .clone(); + let place_state = self.pending_reachability.materialize_ref_at_use( + place_state, + pending, + &mut self.reachability_constraints, + ); + let bindings = place_state.bindings().clone(); let binding_definition_ids = bindings.iter().map(LiveBinding::binding); self.mark_definition_ids_used(binding_definition_ids); @@ -2328,7 +2519,12 @@ impl<'db> UseDefMapBuilder<'db> { ); let bindings = self .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .materialize_ref( + place_state, + pending, + &mut self.narrowing_constraints, + &mut self.reachability_constraints, + ) .bindings(); let is_class_symbol = enclosing_scope.is_class() && enclosing_place.is_symbol(); @@ -2371,6 +2567,7 @@ impl<'db> UseDefMapBuilder<'db> { .materialize_ref( &mut self.symbol_states[enclosing_symbol], pending, + &mut self.narrowing_constraints, &mut self.reachability_constraints, ) .bindings() @@ -2432,7 +2629,12 @@ impl<'db> UseDefMapBuilder<'db> { pending_place_state_mut(place, &mut self.symbol_states, &mut self.member_states); let bindings = self .pending_reachability - .materialize_ref(place_state, pending, &mut self.reachability_constraints) + .materialize_ref( + place_state, + pending, + &mut self.narrowing_constraints, + &mut self.reachability_constraints, + ) .bindings(); bindings.iter().copied() @@ -2519,7 +2721,9 @@ impl<'db> UseDefMapBuilder<'db> { .iter_mut() .chain(self.member_states.iter_mut()) { - self.pending_reachability.materialize( + // No later state change can require the correlation represented by pending call + // narrowing gates, so only reachability needs to be finalized here. + self.pending_reachability.materialize_reachability( state, pending, &mut self.reachability_constraints, diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index d8073960e2..d53379654e 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -198,6 +198,90 @@ def _(val: int | None): reveal_type(val) # revealed: int ``` +Narrowing that occurs after the `NoReturn` call must also be discarded with the unreachable branch: + +```py +from typing_extensions import Never + +def fail() -> Never: + raise RuntimeError + +def _(x: int | None, flag: bool): + if flag: + fail() + if x is not None: + return + else: + if x is None: + return + + reveal_type(x) # revealed: int +``` + +Call constraints that precede a nested merge must still gate narrowing later in the outer branch: + +```py +from typing_extensions import Never + +def fail_nested_merge() -> Never: + raise RuntimeError + +def _(x: int | None, outer: bool, inner: bool) -> None: + if outer: + fail_nested_merge() + + if inner: + pass + else: + pass + + if x is not None: + return + else: + if x is None: + return + + reveal_type(x) # revealed: int +``` + +Call constraints introduced inside the nested branches are still discarded at that merge: + +```py +def _(x: int | None, outer: bool, inner: bool) -> None: + if outer: + if inner: + pass + else: + fail_nested_merge() + + if x is not None: + return + else: + if x is None: + return + + reveal_type(x) # revealed: None | int +``` + +If every nested branch contains a call, their combined call constraint must be preserved: + +```py +def _(x: int | None, outer: bool, inner: bool) -> None: + if outer: + if inner: + fail_nested_merge() + else: + fail_nested_merge() + + if x is not None: + return + else: + if x is None: + return + + reveal_type(x) # revealed: int +``` + And for elif branches: ```py From d1e77914c73b41bd44e099026575f83dc09b2373 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 21 Jul 2026 10:48:41 +0200 Subject: [PATCH 172/222] [ty] Recover from cancelled file indexing (#26876) --- Cargo.lock | 2 + Cargo.toml | 1 + crates/ty_project/Cargo.toml | 3 +- crates/ty_project/src/files.rs | 73 +++++++++++++++++++++++++++++----- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 09db75b096..8ccf0861fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "ordermap", + "parking_lot", "smallvec", "thin-vec", ] @@ -4790,6 +4791,7 @@ dependencies = [ "insta", "notify", "ordermap", + "parking_lot", "pep440_rs", "rayon", "regex", diff --git a/Cargo.toml b/Cargo.toml index f6e605f085..bed5ff239a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ mimalloc = { version = "0.1.49", features = ["v2"] } natord = { version = "1.0.9" } notify = { version = "8.0.0" } ordermap = { version = "1.0.0" } +parking_lot = { version = "0.12.4" } path-absolutize = { version = "3.1.1" } path-slash = { version = "0.2.1" } pathdiff = { version = "0.2.1" } diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 005e836499..de0e541de9 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -38,10 +38,11 @@ camino = { workspace = true } colored = { workspace = true } compact_str = { workspace = true, features = ["serde"] } crossbeam = { workspace = true } -get-size2 = { workspace = true, features = ["ordermap"] } +get-size2 = { workspace = true, features = ["ordermap", "parking_lot"] } globset = { workspace = true } notify = { workspace = true } ordermap = { workspace = true, features = ["serde"] } +parking_lot = { workspace = true } pep440_rs = { workspace = true, features = ["version-ranges"] } rayon = { workspace = true } regex = { workspace = true } diff --git a/crates/ty_project/src/files.rs b/crates/ty_project/src/files.rs index 4e63c89041..96674ad8da 100644 --- a/crates/ty_project/src/files.rs +++ b/crates/ty_project/src/files.rs @@ -2,6 +2,7 @@ use std::marker::PhantomData; use std::ops::Deref; use std::sync::Arc; +use parking_lot::{Mutex, MutexGuard}; use rustc_hash::FxHashSet; use salsa::{Durability, Setter}; @@ -23,24 +24,27 @@ use crate::db::Db; /// ensure that Salsa always knows when the set of indexed files have changed. #[derive(Debug, get_size2::GetSize)] pub struct IndexedFiles { - state: std::sync::Mutex, + // This mutex is intentionally non-poisoning: Salsa cancellation can unwind while the lazy + // index is being built. Indexing only transitions to `Indexed` after a complete walk, so the + // `Lazy` state remains valid and can be retried. + state: Mutex, } impl IndexedFiles { pub fn lazy() -> Self { Self { - state: std::sync::Mutex::new(State::Lazy), + state: Mutex::new(State::Lazy), } } fn indexed(inner: Arc) -> Self { Self { - state: std::sync::Mutex::new(State::Indexed(inner)), + state: Mutex::new(State::Indexed(inner)), } } pub(super) fn get(&self) -> Index<'_> { - let state = self.state.lock().unwrap(); + let state = self.state.lock(); match &*state { State::Lazy => Index::Lazy(LazyFiles { files: state }), @@ -52,21 +56,21 @@ impl IndexedFiles { } pub(super) fn is_lazy(&self) -> bool { - matches!(*self.state.lock().unwrap(), State::Lazy) + matches!(*self.state.lock(), State::Lazy) } /// Permanently freezes the project's file-set input without cloning the indexed files. pub(super) fn freeze(db: &mut dyn Db, project: Project) { let state = { let files = project.file_set(db); - std::mem::replace(&mut *files.state.lock().unwrap(), State::Lazy) + std::mem::replace(&mut *files.state.lock(), State::Lazy) }; project .set_file_set(db) .with_durability(Durability::NEVER_CHANGE) .to(Self { - state: std::sync::Mutex::new(state), + state: Mutex::new(state), }); } @@ -92,7 +96,7 @@ impl IndexedFiles { // can't outlive the database (constrained by the `db` lifetime). let state = { let files = project.file_set(db); - let mut locked = files.state.lock().unwrap(); + let mut locked = files.state.lock(); std::mem::replace(&mut *locked, State::Lazy) }; @@ -136,7 +140,7 @@ pub(super) enum Index<'db> { /// Package files that have not been indexed yet. pub(super) struct LazyFiles<'db> { - files: std::sync::MutexGuard<'db, State>, + files: MutexGuard<'db, State>, } impl<'db> LazyFiles<'db> { @@ -255,7 +259,7 @@ impl IndexedMut<'_> { .to(IndexedFiles::indexed(indexed)); } else { // The `indexed_mut` replaced the `state` with Lazy. Restore it back to the indexed state. - *self.project.file_set(db).state.lock().unwrap() = State::Indexed(indexed); + *self.project.file_set(db).state.lock() = State::Indexed(indexed); } } } @@ -268,7 +272,10 @@ impl Drop for IndexedMut<'_> { #[cfg(test)] mod tests { + use std::time::{Duration, Instant}; + use rustc_hash::FxHashSet; + use salsa::{Database, Durability, EventKind}; use crate::ProjectMetadata; use crate::db::Db; @@ -312,4 +319,50 @@ mod tests { Ok(()) } + + #[test] + fn cancelled_file_indexing_recovers_file_set() -> anyhow::Result<()> { + let metadata = ProjectMetadata::new("test", SystemPathBuf::from("/test")); + let mut db = TestDb::new(metadata); + db.write_files((0..10_000).map(|index| (format!("/test/test_{index}.py"), "")))?; + db.take_salsa_events(); + + let project = db.project(); + let indexing_db = db.clone(); + let indexing = std::thread::spawn(move || project.files(&indexing_db).len()); + + // Wait until the lazy index has started walking files before requesting cancellation. + let deadline = Instant::now() + Duration::from_secs(5); + while !db + .take_salsa_events() + .iter() + .any(|event| matches!(event.kind, EventKind::WillCheckCancellation)) + { + assert!(Instant::now() < deadline, "file indexing did not start"); + std::thread::yield_now(); + } + + db.synthetic_write(Durability::LOW); + let cancelled = match indexing.join() { + Ok(indexed_files) => { + // Indexing can finish between the cancellation check and the write. That's valid; + // there is no interrupted index to recover in that case. + assert_eq!(indexed_files, 10_000); + return Ok(()); + } + Err(cancelled) => cancelled, + }; + assert!( + matches!( + cancelled.downcast_ref::(), + Some(salsa::Cancelled::PendingWrite) + ), + "file indexing did not propagate the salsa cancellation" + ); + + // The next access must retry the incomplete lazy index. + assert_eq!(project.files(&db).len(), 10_000); + + Ok(()) + } } From a415d0a065c085991ec3e2f6a77855b0b9635777 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 21 Jul 2026 10:51:13 +0200 Subject: [PATCH 173/222] [ty] Respect `rules` and `analysis` in PEP 723 script metadata configurations (#26671) ## Summary Add support for configuring some (`rules` and `analysis` settings, same as for `overrides`) from PEP 723 `tool.ty` script metadata. I do plan to add support for `environment` settings and maybe even `terminal`, but that requires more work, because different environments require different dbs. Given that we'll need to use different dbs. Why bother with integrating this into `file_settings`, which only allows overriding a small sub-set of options. The idea behind this is that I want to share the same db for scripts with the same `environment` settings (and dependencies in the future). This should reduce overall resource consumption because it allows sharing parsed ASTs, semantic indices, for scripts that use the same environment (and venv, which might not be that uncommon, thanks to uv's canonical venvs). The integration into `file_settings` enables this sharing by creating separate DBs only for the settings where this is necessary, but relying on `file_settings` to handle differences in enabled rules etc. Using `file_settings` has one downside. It most likely means that the script metadata is parsed twice. I think this is negligible. Most metadata blocks are tiny. https://github.com/user-attachments/assets/d2be2e67-2a6f-405a-9852-860377f8496c ## Configuration precedence Scripts don't inherit any settings from the project (you got me, this is not a 100% true today where we still use the project's python version, but it will be). They're fully isolated. Each of them represent their own project. I'm not a 100% sure yet if this is the ideal behavior or if we need some mechanism that allows inheriting settings from the project. But I'd like to defer this discussion to later. Scripts respect configurations from other sources, the same as projects do: * CLI options or LSP configurations take highest precedence, overriding configurations in the script metadata * The scripts own configuration * User configuration * Fallback configurations. We only use this in the LSP today as a hint of what the python version is. Specifically, we use the selected python environment in VS Code, if the user didn't specify a python environment. ## Error handling For now, I've intentionally decided not to deal with invalid script metadata blocks. I expect that the validation should happen in `check_file`, or even earlier, in the code constructing the script's database. This is something I'll tackle next, and is why this PR assumes that all script metadata blocks are valid, blocks with syntax errors are simply ignored, unknown rules are not flagged. ## Memory regression The main regression comes from the new `script_metadata` query. The query already uses an `Option>` to reduce the overhead for files without script metadata. The second cause of the regression is that `file_settings` now depends on `script_metadata`. A dependency that Salsa now needs to track. There's not much we can do about this. I considered moving the text scan into `file_settings` but that only trades a dependency on `script_metadata` for depending on `source_text` (and doing more work). We do need to record the dependency because a user can add a script metadata block to a regular file, which changes the file's settings. I don't see a way (with proper incremental computation) to fully avoid the memory regression. We may want to enable within-same-revision garbage collection in the future, as we don't really need to retain the metadata for a prolonged time. ## Test Plan Added query, mdtest, and CLI coverage. --------- Co-authored-by: Aria Desires --- Cargo.lock | 2 + crates/ruff_python_ast/src/script.rs | 22 +- crates/ty/tests/cli/main.rs | 1 + crates/ty/tests/cli/scripts.rs | 415 ++++++++++++++++++ crates/ty_project/Cargo.toml | 1 + crates/ty_project/src/metadata.rs | 1 + crates/ty_project/src/metadata/options.rs | 16 + crates/ty_project/src/metadata/pyproject.rs | 16 +- crates/ty_project/src/metadata/script.rs | 62 +++ crates/ty_project/src/metadata/settings.rs | 37 +- .../resources/mdtest/scripts.md | 128 ++++++ crates/ty_test/Cargo.toml | 1 + crates/ty_test/src/config.rs | 27 ++ crates/ty_test/src/db.rs | 152 ++++--- 14 files changed, 820 insertions(+), 61 deletions(-) create mode 100644 crates/ty/tests/cli/scripts.rs create mode 100644 crates/ty_project/src/metadata/script.rs create mode 100644 crates/ty_python_semantic/resources/mdtest/scripts.md diff --git a/Cargo.lock b/Cargo.lock index 8ccf0861fb..66584314eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4789,6 +4789,7 @@ dependencies = [ "get-size2", "globset", "insta", + "memchr", "notify", "ordermap", "parking_lot", @@ -4997,6 +4998,7 @@ dependencies = [ "salsa", "serde", "tempfile", + "toml 1.1.2+spec-1.1.0", "tracing", "ty_module_resolver", "ty_python_core", diff --git a/crates/ruff_python_ast/src/script.rs b/crates/ruff_python_ast/src/script.rs index 287769d338..9180b3ccdb 100644 --- a/crates/ruff_python_ast/src/script.rs +++ b/crates/ruff_python_ast/src/script.rs @@ -20,6 +20,11 @@ pub struct ScriptTag { } impl ScriptTag { + /// Returns the TOML contents of the metadata block. + pub fn metadata(&self) -> &str { + &self.metadata + } + /// Given the contents of a Python file, extract the `script` metadata block with leading /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python /// script. @@ -52,16 +57,27 @@ impl ScriptTag { // Identify the opening pragma. let index = FINDER.find(contents)?; + Self::parse_at(contents, index) + } + + /// Extracts a `script` metadata block known to start at `index`. + /// + /// Returns `None` if `index` does not point to an exact opening pragma at the start of a line. + pub fn parse_at(contents: &[u8], index: usize) -> Option { + let (prelude, contents) = contents.split_at_checked(index)?; + // The opening pragma must be the first line, or immediately preceded by a newline. - if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) { + if prelude + .last() + .is_some_and(|byte| !matches!(*byte, b'\r' | b'\n')) + { return None; } // Extract the preceding content. - let prelude = std::str::from_utf8(&contents[..index]).ok()?; + let prelude = std::str::from_utf8(prelude).ok()?; // Decode as UTF-8. - let contents = &contents[index..]; let contents = std::str::from_utf8(contents).ok()?; let mut lines = contents.lines(); diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 1e88f73efe..c7f1e0bead 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -6,6 +6,7 @@ mod fixes; mod python_environment; mod rule; mod rule_selection; +mod scripts; use anyhow::Context as _; use insta::Settings; diff --git a/crates/ty/tests/cli/scripts.rs b/crates/ty/tests/cli/scripts.rs new file mode 100644 index 0000000000..885340644e --- /dev/null +++ b/crates/ty/tests/cli/scripts.rs @@ -0,0 +1,415 @@ +use insta_cmd::assert_cmd_snapshot; + +use crate::CliTest; + +#[test] +fn project_settings_and_overrides_do_not_apply() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.rules] + unresolved-reference = "ignore" + + [[tool.ty.overrides]] + include = ["script.py"] + + [tool.ty.overrides.rules] + unresolved-reference = "error" + "#, + ), + ( + "script.py", + r#" + # /// script + # [tool.ty.rules] + # unresolved-reference = "warn" + # /// + + print(missing) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `missing` used when not defined + --> script.py:7:7 + | + 7 | print(missing) + | ^^^^^^^ + | + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn metadata_without_tool_ty_uses_default_settings() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.rules] + all = "ignore" + + [tool.ty.analysis] + respect-type-ignore-comments = false + "#, + ), + ( + "script.py", + r#" + # /// script + # dependencies = [] + # /// + + value: int = "not an int" + suppressed: int = "not an int" # type: ignore + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-assignment]: Object of type `Literal["not an int"]` is not assignable to `int` + --> script.py:6:8 + | + 6 | value: int = "not an int" + | --- ^^^^^^^^^^^^ Incompatible value of type `Literal["not an int"]` + | | + | Declared type + | + + Found 1 diagnostic + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn environment_options() -> anyhow::Result<()> { + // TODO: This is not yet supported, but we should support this. + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-version = "3.12" + "#, + ), + ( + "script.py", + r#" + # /// script + # requires-python = ">=3.7" + # + # [tool.ty.environment] + # python-version = "3.7" + # /// + + import sys + from typing import reveal_type + + reveal_type(sys.version_info[:2] == (3, 12)) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> script.py:12:13 + | + 12 | reveal_type(sys.version_info[:2] == (3, 12)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Literal[True]` + | + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn inline_overrides_are_ignored() -> anyhow::Result<()> { + // TODO: Emit a diagnostic for options that are not allowed within scripts. + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # [tool.ty.rules] + # unresolved-reference = "warn" + # + # [[tool.ty.overrides]] + # include = ["script.py"] + # + # [tool.ty.overrides.rules] + # unresolved-reference = "ignore" + # /// + + print(missing) + "#, + )?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `missing` used when not defined + --> script.py:13:7 + | + 13 | print(missing) + | ^^^^^^^ + | + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn inline_terminal_settings_do_not_apply() -> anyhow::Result<()> { + // TODO: Either support (when calling `ty check