Compare commits

...

16 Commits

Author SHA1 Message Date
Zanie Blue
c3b4fab764 [ty] Track narrowing unions to prevent exponential blowup
When inferring a call expression with a union type context, we try narrowing
to each element of the union. If nested calls have the same union as their
parameter type, this would lead to exponential blowup. By tracking which
unions we're already narrowing against, we skip redundant nested narrowing.

On a synthetic benchmark with nested `list_schema()` calls:
- 4-level nesting: >120s → 0.13s
2025-12-18 20:24:33 -06:00
Zanie Blue
abf17c6ef4 [ty] Add short-circuits for union type relation checks
- Short-circuit union-vs-union comparisons when both sides are identical
- Add fast membership check before full relation checking when comparing
  a type against a union
2025-12-18 20:24:18 -06:00
Zanie Blue
b9f65213d0 [ty] Cache Type::is_assignable_to
Add salsa caching to `Type::is_assignable_to` to memoize repeated
assignability checks.
2025-12-18 20:24:03 -06:00
Zanie Blue
ff2553665c [ty] Cache ClassType::is_subclass_of
Add salsa caching to `ClassType::is_subclass_of` to avoid repeated MRO
traversals when checking class relationships.
2025-12-18 20:23:28 -06:00
Zanie Blue
8ebbe6b0f6 Invert case 2025-12-18 18:35:38 -06:00
Zanie Blue
6bc88c90b2 [ty] Cache Type::is_disjoint_from 2025-12-18 18:35:38 -06:00
Zanie Blue
3c694c7d86 [ty] Cache Type::is_subtype_of 2025-12-18 18:35:38 -06:00
Zanie Blue
1603948aae [ty] Cache ClassType::nearest_disjoint_base 2025-12-18 18:35:38 -06:00
Micha Reiser
cb6ba23b0a More lazy negated computations 2025-12-18 18:32:39 -06:00
Micha Reiser
a918833d19 Defer insertion 2025-12-18 18:32:39 -06:00
Micha Reiser
bcf9295973 [ty] Small union builder nits 2025-12-18 18:32:39 -06:00
Douglas Creager
5a2d3cda3d [ty] Remove some nondeterminism in constraint set tests (#22064)
We're seeing a lot of nondeterminism in the ecosystem tests at the
moment, which started (or at least got worse) once `Callable` inference
landed.

This PR attempts to remove this nondeterminism. We recently
(https://github.com/astral-sh/ruff/pull/21983) added a `source_order`
field to BDD nodes, which tracks when their constraint was added to the
BDD. Since we build up constraints based on the order that they appear
in the underlying source, that gives us a stable ordering even though we
use an arbitrary salsa-derived ordering for the BDD variables.

The issue (at least for some of the flakiness) is that we add "derived"
constraints when walking a BDD tree, and those derived constraints
inherit or borrow the `source_order` of the "real" constraint that
implied them. That means we can get multiple constraints in our
specialization that all have the same `source_order`. If we're not
careful, those "tied" constraints can be ordered arbitrarily.

The fix requires ~three~ ~four~ several steps:

- When starting to construct a sequent map (the data structure that
stores the derived constraints), we first sort all of the "real"
constraints by their `source_order`. That ensures that we insert things
into the sequent map in a stable order.
- During sequent map construction, derived facts are discovered by a
deterministic process applied to constraints in a (now) stable order. So
derived facts are now also inserted in a stable order.
- We update the fields of `SequentMap` to use `FxOrderSet` instead of
`FxHashSet`, so that we retain that stable insertion order.
- When walking BDD paths when constructing a specialization, we were
already sorting the constraints by their `source_order`. However, we
were not considering that we might get derived constraints, and
therefore constraints with "ties". Because of that, we need to make sure
to use a _stable_ sort, that retains the insertion order for those ties.

All together, this...should...fix the nondeterminism. (Unfortunately, I
haven't been able to effectively test this, since I haven't been able to
coerce local tests to flop into the other order that we sometimes see in
CI.)
2025-12-18 19:00:20 -05:00
Jack O'Connor
fa57253980 [ty] Implement disjointness for TypedDicts (#22044)
This is a preliminary step towards tagged union narrowing for `TypedDict`:
https://github.com/astral-sh/ty/issues/1479
2025-12-18 13:20:22 -08:00
Amethyst Reese
b7fbd986bc [ruff] fix preview-since values for RUF103 and RUF104 (#22061)
Missed including this in the follow-up on #21908
2025-12-18 13:18:04 -08:00
Amethyst Reese
3d334a313e Report diagnostics for invalid/unmatched range suppression comments (#21908)
## Summary

- Adds new RUF103 and RUF104 diagnostics for invalid and unmatched
suppression comments
- Reports RUF100 for any unused range suppression
- Reports RUF102 for range suppression comment with invalid rule codes
- Reports RUF103 for range suppression comment with invalid suppression syntax
- Reports RUF104 diagnostics for any unmatched range suppression comment (disable w/o enable)


## Test Plan

Updated snapshots from test cases with unmatched suppression comments

Issue #3711
Fixes #21878
Fixes #21875
2025-12-18 12:58:58 -08:00
Aria Desires
2e44a861cb [ty] Disable possibly-missing-imports by default (#22041)
@carljm put forth a reasonably compelling argument that just disabling
this lint might be advisable. If we agree, here's the implementation.

* Fixes https://github.com/astral-sh/ty/issues/309

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-12-18 20:06:34 +00:00
26 changed files with 1360 additions and 192 deletions

View File

@@ -4,5 +4,6 @@
# Enable off-by-default rules.
[rules]
possibly-unresolved-reference = "warn"
possibly-missing-import = "warn"
unused-ignore-comment = "warn"
division-by-zero = "warn"

View File

@@ -86,3 +86,26 @@ def f():
# Multiple codes but none are used
# ruff: disable[E741, F401, F841]
print("hello")
def f():
# Unknown rule codes
# ruff: disable[YF829]
# ruff: disable[F841, RQW320]
value = 0
# ruff: enable[F841, RQW320]
# ruff: enable[YF829]
def f():
# External rule codes should be ignored
# ruff: disable[TK421]
print("hello")
# ruff: enable[TK421]
def f():
# Empty or missing rule codes
# ruff: disable
# ruff: disable[]
print("hello")

View File

@@ -1064,6 +1064,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "100") => rules::ruff::rules::UnusedNOQA,
(Ruff, "101") => rules::ruff::rules::RedirectedNOQA,
(Ruff, "102") => rules::ruff::rules::InvalidRuleCode,
(Ruff, "103") => rules::ruff::rules::InvalidSuppressionComment,
(Ruff, "104") => rules::ruff::rules::UnmatchedSuppressionComment,
(Ruff, "200") => rules::ruff::rules::InvalidPyprojectToml,
#[cfg(any(feature = "test-rules", test))]

View File

@@ -313,12 +313,20 @@ mod tests {
Rule::UnusedVariable,
Rule::AmbiguousVariableName,
Rule::UnusedNOQA,
]),
Rule::InvalidRuleCode,
Rule::InvalidSuppressionComment,
Rule::UnmatchedSuppressionComment,
])
.with_external_rules(&["TK421"]),
&settings::LinterSettings::for_rules(vec![
Rule::UnusedVariable,
Rule::AmbiguousVariableName,
Rule::UnusedNOQA,
Rule::InvalidRuleCode,
Rule::InvalidSuppressionComment,
Rule::UnmatchedSuppressionComment,
])
.with_external_rules(&["TK421"])
.with_preview_mode(),
);
Ok(())

View File

@@ -9,6 +9,21 @@ use crate::registry::Rule;
use crate::rule_redirects::get_redirect_target;
use crate::{AlwaysFixableViolation, Edit, Fix};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InvalidRuleCodeKind {
Noqa,
Suppression,
}
impl InvalidRuleCodeKind {
fn as_str(&self) -> &str {
match self {
InvalidRuleCodeKind::Noqa => "`# noqa`",
InvalidRuleCodeKind::Suppression => "suppression",
}
}
}
/// ## What it does
/// Checks for `noqa` codes that are invalid.
///
@@ -36,12 +51,17 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
#[violation_metadata(preview_since = "0.11.4")]
pub(crate) struct InvalidRuleCode {
pub(crate) rule_code: String,
pub(crate) kind: InvalidRuleCodeKind,
}
impl AlwaysFixableViolation for InvalidRuleCode {
#[derive_message_formats]
fn message(&self) -> String {
format!("Invalid rule code in `# noqa`: {}", self.rule_code)
format!(
"Invalid rule code in {}: {}",
self.kind.as_str(),
self.rule_code
)
}
fn fix_title(&self) -> String {
@@ -61,7 +81,9 @@ pub(crate) fn invalid_noqa_code(
continue;
};
let all_valid = directive.iter().all(|code| code_is_valid(code, external));
let all_valid = directive
.iter()
.all(|code| code_is_valid(code.as_str(), external));
if all_valid {
continue;
@@ -69,7 +91,7 @@ pub(crate) fn invalid_noqa_code(
let (valid_codes, invalid_codes): (Vec<_>, Vec<_>) = directive
.iter()
.partition(|&code| code_is_valid(code, external));
.partition(|&code| code_is_valid(code.as_str(), external));
if valid_codes.is_empty() {
all_codes_invalid_diagnostic(directive, invalid_codes, context);
@@ -81,10 +103,9 @@ pub(crate) fn invalid_noqa_code(
}
}
fn code_is_valid(code: &Code, external: &[String]) -> bool {
let code_str = code.as_str();
Rule::from_code(get_redirect_target(code_str).unwrap_or(code_str)).is_ok()
|| external.iter().any(|ext| code_str.starts_with(ext))
pub(crate) fn code_is_valid(code: &str, external: &[String]) -> bool {
Rule::from_code(get_redirect_target(code).unwrap_or(code)).is_ok()
|| external.iter().any(|ext| code.starts_with(ext))
}
fn all_codes_invalid_diagnostic(
@@ -100,6 +121,7 @@ fn all_codes_invalid_diagnostic(
.map(Code::as_str)
.collect::<Vec<_>>()
.join(", "),
kind: InvalidRuleCodeKind::Noqa,
},
directive.range(),
)
@@ -116,6 +138,7 @@ fn some_codes_are_invalid_diagnostic(
.report_diagnostic(
InvalidRuleCode {
rule_code: invalid_code.to_string(),
kind: InvalidRuleCodeKind::Noqa,
},
invalid_code.range(),
)

View File

@@ -0,0 +1,59 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use crate::AlwaysFixableViolation;
use crate::suppression::{InvalidSuppressionKind, ParseErrorKind};
/// ## What it does
/// Checks for invalid suppression comments
///
/// ## Why is this bad?
/// Invalid suppression comments are ignored by Ruff, and should either
/// be fixed or removed to avoid confusion.
///
/// ## Example
/// ```python
/// ruff: disable # missing codes
/// ```
///
/// Use instead:
/// ```python
/// # ruff: disable[E501]
/// ```
///
/// Or delete the invalid suppression comment.
///
/// ## References
/// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.11")]
pub(crate) struct InvalidSuppressionComment {
pub(crate) kind: InvalidSuppressionCommentKind,
}
impl AlwaysFixableViolation for InvalidSuppressionComment {
#[derive_message_formats]
fn message(&self) -> String {
let msg = match self.kind {
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Indentation) => {
"unexpected indentation".to_string()
}
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Trailing) => {
"trailing comments are not supported".to_string()
}
InvalidSuppressionCommentKind::Invalid(InvalidSuppressionKind::Unmatched) => {
"no matching 'disable' comment".to_string()
}
InvalidSuppressionCommentKind::Error(error) => format!("{error}"),
};
format!("Invalid suppression comment: {msg}")
}
fn fix_title(&self) -> String {
"Remove suppression comment".to_string()
}
}
pub(crate) enum InvalidSuppressionCommentKind {
Invalid(InvalidSuppressionKind),
Error(ParseErrorKind),
}

View File

@@ -22,6 +22,7 @@ pub(crate) use invalid_formatter_suppression_comment::*;
pub(crate) use invalid_index_type::*;
pub(crate) use invalid_pyproject_toml::*;
pub(crate) use invalid_rule_code::*;
pub(crate) use invalid_suppression_comment::*;
pub(crate) use legacy_form_pytest_raises::*;
pub(crate) use logging_eager_conversion::*;
pub(crate) use map_int_version_parsing::*;
@@ -46,6 +47,7 @@ pub(crate) use starmap_zip::*;
pub(crate) use static_key_dict_comprehension::*;
#[cfg(any(feature = "test-rules", test))]
pub(crate) use test_rules::*;
pub(crate) use unmatched_suppression_comment::*;
pub(crate) use unnecessary_cast_to_int::*;
pub(crate) use unnecessary_iterable_allocation_for_first_element::*;
pub(crate) use unnecessary_key_check::*;
@@ -87,6 +89,7 @@ mod invalid_formatter_suppression_comment;
mod invalid_index_type;
mod invalid_pyproject_toml;
mod invalid_rule_code;
mod invalid_suppression_comment;
mod legacy_form_pytest_raises;
mod logging_eager_conversion;
mod map_int_version_parsing;
@@ -113,6 +116,7 @@ mod static_key_dict_comprehension;
mod suppression_comment_visitor;
#[cfg(any(feature = "test-rules", test))]
pub(crate) mod test_rules;
mod unmatched_suppression_comment;
mod unnecessary_cast_to_int;
mod unnecessary_iterable_allocation_for_first_element;
mod unnecessary_key_check;

View File

@@ -0,0 +1,42 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use crate::Violation;
/// ## What it does
/// Checks for unmatched range suppression comments
///
/// ## Why is this bad?
/// Unmatched range suppression comments can inadvertently suppress violations
/// over larger sections of code than intended, particularly at module scope.
///
/// ## Example
/// ```python
/// def foo():
/// # ruff: disable[E501] # unmatched
/// REALLY_LONG_VALUES = [...]
///
/// print(REALLY_LONG_VALUES)
/// ```
///
/// Use instead:
/// ```python
/// def foo():
/// # ruff: disable[E501]
/// REALLY_LONG_VALUES = [...]
/// # ruff: enable[E501]
///
/// print(REALLY_LONG_VALUES)
/// ```
///
/// ## References
/// - [Ruff error suppression](https://docs.astral.sh/ruff/linter/#error-suppression)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.11")]
pub(crate) struct UnmatchedSuppressionComment;
impl Violation for UnmatchedSuppressionComment {
#[derive_message_formats]
fn message(&self) -> String {
"Suppression comment without matching `#ruff:enable` comment".to_string()
}
}

View File

@@ -6,8 +6,8 @@ source: crates/ruff_linter/src/rules/ruff/mod.rs
+linter.preview = enabled
--- Summary ---
Removed: 14
Added: 11
Removed: 15
Added: 23
--- Removed ---
E741 Ambiguous variable name: `I`
@@ -238,8 +238,60 @@ help: Remove assignment to unused variable `I`
note: This is an unsafe fix and may change runtime behavior
F841 [*] Local variable `value` is assigned to but never used
--> suppressions.py:95:5
|
93 | # ruff: disable[YF829]
94 | # ruff: disable[F841, RQW320]
95 | value = 0
| ^^^^^
96 | # ruff: enable[F841, RQW320]
97 | # ruff: enable[YF829]
|
help: Remove assignment to unused variable `value`
92 | # Unknown rule codes
93 | # ruff: disable[YF829]
94 | # ruff: disable[F841, RQW320]
- value = 0
95 + pass
96 | # ruff: enable[F841, RQW320]
97 | # ruff: enable[YF829]
98 |
note: This is an unsafe fix and may change runtime behavior
--- Added ---
RUF104 Suppression comment without matching `#ruff:enable` comment
--> suppressions.py:11:5
|
9 | # These should both be ignored by the implicit range suppression.
10 | # Should also generate an "unmatched suppression" warning.
11 | # ruff:disable[E741,F841]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
12 | I = 1
|
RUF103 [*] Invalid suppression comment: no matching 'disable' comment
--> suppressions.py:19:5
|
17 | # should be generated.
18 | I = 1
19 | # ruff: enable[E741, F841]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Remove suppression comment
16 | # Neither warning is ignored, and an "unmatched suppression"
17 | # should be generated.
18 | I = 1
- # ruff: enable[E741, F841]
19 |
20 |
21 | def f():
note: This is an unsafe fix and may change runtime behavior
RUF100 [*] Unused suppression (non-enabled: `E501`)
--> suppressions.py:46:5
|
@@ -298,6 +350,17 @@ help: Remove unused `noqa` directive
58 |
RUF104 Suppression comment without matching `#ruff:enable` comment
--> suppressions.py:61:5
|
59 | def f():
60 | # TODO: Duplicate codes should be counted as duplicate, not unused
61 | # ruff: disable[F841, F841]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
62 | foo = 0
|
RUF100 [*] Unused suppression (unused: `F841`)
--> suppressions.py:61:21
|
@@ -318,6 +381,18 @@ help: Remove unused suppression
64 |
RUF104 Suppression comment without matching `#ruff:enable` comment
--> suppressions.py:68:5
|
66 | # Overlapping range suppressions, one should be marked as used,
67 | # and the other should trigger an unused suppression diagnostic
68 | # ruff: disable[F841]
| ^^^^^^^^^^^^^^^^^^^^^
69 | # ruff: disable[F841]
70 | foo = 0
|
RUF100 [*] Unused suppression (unused: `F841`)
--> suppressions.py:69:5
|
@@ -337,6 +412,17 @@ help: Remove unused suppression
71 |
RUF104 Suppression comment without matching `#ruff:enable` comment
--> suppressions.py:75:5
|
73 | def f():
74 | # Multiple codes but only one is used
75 | # ruff: disable[E741, F401, F841]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
76 | foo = 0
|
RUF100 [*] Unused suppression (unused: `E741`)
--> suppressions.py:75:21
|
@@ -377,6 +463,17 @@ help: Remove unused suppression
78 |
RUF104 Suppression comment without matching `#ruff:enable` comment
--> suppressions.py:81:5
|
79 | def f():
80 | # Multiple codes but only two are used
81 | # ruff: disable[E741, F401, F841]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
82 | I = 0
|
RUF100 [*] Unused suppression (non-enabled: `F401`)
--> suppressions.py:81:27
|
@@ -413,6 +510,8 @@ help: Remove unused suppression
- # ruff: disable[E741, F401, F841]
87 + # ruff: disable[F401, F841]
88 | print("hello")
89 |
90 |
RUF100 [*] Unused suppression (non-enabled: `F401`)
@@ -431,6 +530,8 @@ help: Remove unused suppression
- # ruff: disable[E741, F401, F841]
87 + # ruff: disable[E741, F841]
88 | print("hello")
89 |
90 |
RUF100 [*] Unused suppression (unused: `F841`)
@@ -449,3 +550,122 @@ help: Remove unused suppression
- # ruff: disable[E741, F401, F841]
87 + # ruff: disable[E741, F401]
88 | print("hello")
89 |
90 |
RUF102 [*] Invalid rule code in suppression: YF829
--> suppressions.py:93:21
|
91 | def f():
92 | # Unknown rule codes
93 | # ruff: disable[YF829]
| ^^^^^
94 | # ruff: disable[F841, RQW320]
95 | value = 0
|
help: Remove the rule code
90 |
91 | def f():
92 | # Unknown rule codes
- # ruff: disable[YF829]
93 | # ruff: disable[F841, RQW320]
94 | value = 0
95 | # ruff: enable[F841, RQW320]
RUF102 [*] Invalid rule code in suppression: RQW320
--> suppressions.py:94:27
|
92 | # Unknown rule codes
93 | # ruff: disable[YF829]
94 | # ruff: disable[F841, RQW320]
| ^^^^^^
95 | value = 0
96 | # ruff: enable[F841, RQW320]
|
help: Remove the rule code
91 | def f():
92 | # Unknown rule codes
93 | # ruff: disable[YF829]
- # ruff: disable[F841, RQW320]
94 + # ruff: disable[F841]
95 | value = 0
96 | # ruff: enable[F841, RQW320]
97 | # ruff: enable[YF829]
RUF102 [*] Invalid rule code in suppression: RQW320
--> suppressions.py:96:26
|
94 | # ruff: disable[F841, RQW320]
95 | value = 0
96 | # ruff: enable[F841, RQW320]
| ^^^^^^
97 | # ruff: enable[YF829]
|
help: Remove the rule code
93 | # ruff: disable[YF829]
94 | # ruff: disable[F841, RQW320]
95 | value = 0
- # ruff: enable[F841, RQW320]
96 + # ruff: enable[F841]
97 | # ruff: enable[YF829]
98 |
99 |
RUF102 [*] Invalid rule code in suppression: YF829
--> suppressions.py:97:20
|
95 | value = 0
96 | # ruff: enable[F841, RQW320]
97 | # ruff: enable[YF829]
| ^^^^^
|
help: Remove the rule code
94 | # ruff: disable[F841, RQW320]
95 | value = 0
96 | # ruff: enable[F841, RQW320]
- # ruff: enable[YF829]
97 |
98 |
99 | def f():
RUF103 [*] Invalid suppression comment: missing suppression codes like `[E501, ...]`
--> suppressions.py:109:5
|
107 | def f():
108 | # Empty or missing rule codes
109 | # ruff: disable
| ^^^^^^^^^^^^^^^
110 | # ruff: disable[]
111 | print("hello")
|
help: Remove suppression comment
106 |
107 | def f():
108 | # Empty or missing rule codes
- # ruff: disable
109 | # ruff: disable[]
110 | print("hello")
note: This is an unsafe fix and may change runtime behavior
RUF103 [*] Invalid suppression comment: missing suppression codes like `[E501, ...]`
--> suppressions.py:110:5
|
108 | # Empty or missing rule codes
109 | # ruff: disable
110 | # ruff: disable[]
| ^^^^^^^^^^^^^^^^^
111 | print("hello")
|
help: Remove suppression comment
107 | def f():
108 | # Empty or missing rule codes
109 | # ruff: disable
- # ruff: disable[]
110 | print("hello")
note: This is an unsafe fix and may change runtime behavior

View File

@@ -471,6 +471,13 @@ impl LinterSettings {
self
}
#[must_use]
pub fn with_external_rules(mut self, rules: &[&str]) -> Self {
self.external
.extend(rules.iter().map(std::string::ToString::to_string));
self
}
/// Resolve the [`TargetVersion`] to use for linting.
///
/// This method respects the per-file version overrides in

View File

@@ -4,6 +4,7 @@ use ruff_db::diagnostic::Diagnostic;
use ruff_diagnostics::{Edit, Fix};
use ruff_python_ast::token::{TokenKind, Tokens};
use ruff_python_ast::whitespace::indentation;
use rustc_hash::FxHashSet;
use std::cell::Cell;
use std::{error::Error, fmt::Formatter};
use thiserror::Error;
@@ -17,7 +18,11 @@ use crate::checkers::ast::LintContext;
use crate::codes::Rule;
use crate::fix::edits::delete_comment;
use crate::preview::is_range_suppressions_enabled;
use crate::rules::ruff::rules::{UnusedCodes, UnusedNOQA, UnusedNOQAKind};
use crate::rule_redirects::get_redirect_target;
use crate::rules::ruff::rules::{
InvalidRuleCode, InvalidRuleCodeKind, InvalidSuppressionComment, InvalidSuppressionCommentKind,
UnmatchedSuppressionComment, UnusedCodes, UnusedNOQA, UnusedNOQAKind, code_is_valid,
};
use crate::settings::LinterSettings;
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -130,7 +135,7 @@ impl Suppressions {
}
pub(crate) fn is_empty(&self) -> bool {
self.valid.is_empty()
self.valid.is_empty() && self.invalid.is_empty() && self.errors.is_empty()
}
/// Check if a diagnostic is suppressed by any known range suppressions
@@ -150,7 +155,9 @@ impl Suppressions {
};
for suppression in &self.valid {
if *code == suppression.code.as_str() && suppression.range.contains_range(range) {
let suppression_code =
get_redirect_target(suppression.code.as_str()).unwrap_or(suppression.code.as_str());
if *code == suppression_code && suppression.range.contains_range(range) {
suppression.used.set(true);
return true;
}
@@ -159,81 +166,140 @@ impl Suppressions {
}
pub(crate) fn check_suppressions(&self, context: &LintContext, locator: &Locator) {
if !context.any_rule_enabled(&[Rule::UnusedNOQA, Rule::InvalidRuleCode]) {
return;
}
let unused = self
.valid
.iter()
.filter(|suppression| !suppression.used.get());
for suppression in unused {
let Ok(rule) = Rule::from_code(&suppression.code) else {
continue; // TODO: invalid code
};
for comment in &suppression.comments {
let mut range = comment.range;
let edit = if comment.codes.len() == 1 {
delete_comment(comment.range, locator)
} else {
let code_index = comment
.codes
.iter()
.position(|range| locator.slice(range) == suppression.code)
.unwrap();
range = comment.codes[code_index];
let code_range = if code_index < (comment.codes.len() - 1) {
TextRange::new(
comment.codes[code_index].start(),
comment.codes[code_index + 1].start(),
)
} else {
TextRange::new(
comment.codes[code_index - 1].end(),
comment.codes[code_index].end(),
)
let mut unmatched_ranges = FxHashSet::default();
for suppression in &self.valid {
if !code_is_valid(&suppression.code, &context.settings().external) {
// InvalidRuleCode
if context.is_rule_enabled(Rule::InvalidRuleCode) {
for comment in &suppression.comments {
let (range, edit) = Suppressions::delete_code_or_comment(
locator,
suppression,
comment,
true,
);
context
.report_diagnostic(
InvalidRuleCode {
rule_code: suppression.code.to_string(),
kind: InvalidRuleCodeKind::Suppression,
},
range,
)
.set_fix(Fix::safe_edit(edit));
}
}
} else if !suppression.used.get() {
// UnusedNOQA
if context.is_rule_enabled(Rule::UnusedNOQA) {
let Ok(rule) = Rule::from_code(
get_redirect_target(&suppression.code).unwrap_or(&suppression.code),
) else {
continue; // "external" lint code, don't treat it as unused
};
Edit::range_deletion(code_range)
};
for comment in &suppression.comments {
let (range, edit) = Suppressions::delete_code_or_comment(
locator,
suppression,
comment,
false,
);
let codes = if context.is_rule_enabled(rule) {
UnusedCodes {
unmatched: vec![suppression.code.to_string()],
..Default::default()
}
} else {
UnusedCodes {
disabled: vec![suppression.code.to_string()],
..Default::default()
}
};
let codes = if context.is_rule_enabled(rule) {
UnusedCodes {
unmatched: vec![suppression.code.to_string()],
..Default::default()
}
} else {
UnusedCodes {
disabled: vec![suppression.code.to_string()],
..Default::default()
}
};
let mut diagnostic = context.report_diagnostic(
UnusedNOQA {
codes: Some(codes),
kind: UnusedNOQAKind::Suppression,
},
range,
);
diagnostic.set_fix(Fix::safe_edit(edit));
context
.report_diagnostic(
UnusedNOQA {
codes: Some(codes),
kind: UnusedNOQAKind::Suppression,
},
range,
)
.set_fix(Fix::safe_edit(edit));
}
}
} else if suppression.comments.len() == 1 {
// UnmatchedSuppressionComment
let range = suppression.comments[0].range;
if unmatched_ranges.insert(range) {
context.report_diagnostic_if_enabled(UnmatchedSuppressionComment {}, range);
}
}
}
for error in self
.errors
.iter()
.filter(|error| error.kind == ParseErrorKind::MissingCodes)
{
let mut diagnostic = context.report_diagnostic(
UnusedNOQA {
codes: Some(UnusedCodes::default()),
kind: UnusedNOQAKind::Suppression,
},
error.range,
);
diagnostic.set_fix(Fix::safe_edit(delete_comment(error.range, locator)));
if context.is_rule_enabled(Rule::InvalidSuppressionComment) {
for error in &self.errors {
context
.report_diagnostic(
InvalidSuppressionComment {
kind: InvalidSuppressionCommentKind::Error(error.kind),
},
error.range,
)
.set_fix(Fix::unsafe_edit(delete_comment(error.range, locator)));
}
}
if context.is_rule_enabled(Rule::InvalidSuppressionComment) {
for invalid in &self.invalid {
context
.report_diagnostic(
InvalidSuppressionComment {
kind: InvalidSuppressionCommentKind::Invalid(invalid.kind),
},
invalid.comment.range,
)
.set_fix(Fix::unsafe_edit(delete_comment(
invalid.comment.range,
locator,
)));
}
}
}
fn delete_code_or_comment(
locator: &Locator<'_>,
suppression: &Suppression,
comment: &SuppressionComment,
highlight_only_code: bool,
) -> (TextRange, Edit) {
let mut range = comment.range;
let edit = if comment.codes.len() == 1 {
if highlight_only_code {
range = comment.codes[0];
}
delete_comment(comment.range, locator)
} else {
let code_index = comment
.codes
.iter()
.position(|range| locator.slice(range) == suppression.code)
.unwrap();
range = comment.codes[code_index];
let code_range = if code_index < (comment.codes.len() - 1) {
TextRange::new(
comment.codes[code_index].start(),
comment.codes[code_index + 1].start(),
)
} else {
TextRange::new(
comment.codes[code_index - 1].end(),
comment.codes[code_index].end(),
)
};
Edit::range_deletion(code_range)
};
(range, edit)
}
}
@@ -391,7 +457,7 @@ impl<'a> SuppressionsBuilder<'a> {
}
#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)]
enum ParseErrorKind {
pub(crate) enum ParseErrorKind {
#[error("not a suppression comment")]
NotASuppression,
@@ -401,7 +467,7 @@ enum ParseErrorKind {
#[error("unknown ruff directive")]
UnknownAction,
#[error("missing suppression codes")]
#[error("missing suppression codes like `[E501, ...]`")]
MissingCodes,
#[error("missing closing bracket")]

View File

@@ -2511,38 +2511,6 @@ class A:
A()[0] # TypeError: 'A' object is not subscriptable
```
## `possibly-missing-import`
<small>
Default level: <a href="../rules.md#rule-levels" title="This lint has a default level of 'warn'."><code>warn</code></a> ·
Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.22">0.0.1-alpha.22</a> ·
<a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-missing-import" target="_blank">Related issues</a> ·
<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1575" target="_blank">View source</a>
</small>
**What it does**
Checks for imports of symbols that may be missing.
**Why is this bad?**
Importing a missing module or name will raise a `ModuleNotFoundError`
or `ImportError` at runtime.
**Examples**
```python
# module.py
import datetime
if datetime.date.today().weekday() != 6:
a = 1
# main.py
from module import a # ImportError: cannot import name 'a' from 'module'
```
## `redundant-cast`
<small>
@@ -2778,6 +2746,38 @@ Dividing by zero raises a `ZeroDivisionError` at runtime.
5 / 0
```
## `possibly-missing-import`
<small>
Default level: <a href="../rules.md#rule-levels" title="This lint has a default level of 'ignore'."><code>ignore</code></a> ·
Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.22">0.0.1-alpha.22</a> ·
<a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-missing-import" target="_blank">Related issues</a> ·
<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1575" target="_blank">View source</a>
</small>
**What it does**
Checks for imports of symbols that may be missing.
**Why is this bad?**
Importing a missing module or name will raise a `ModuleNotFoundError`
or `ImportError` at runtime.
**Examples**
```python
# module.py
import datetime
if datetime.date.today().weekday() != 6:
a = 1
# main.py
from module import a # ImportError: cannot import name 'a' from 'module'
```
## `possibly-unresolved-reference`
<small>

View File

@@ -1726,5 +1726,298 @@ reveal_type(actual_td) # revealed: ActualTypedDict
reveal_type(actual_td["name"]) # revealed: str
```
## Disjointness with other `TypedDict`s
Two `TypedDict` types are disjoint if it's impossible to come up with a third (fully-static)
`TypedDict` that's assignable to both. The simplest way to establish this is if both sides have
fields with the same name but disjoint types:
```py
from typing import TypedDict, final
from typing_extensions import ReadOnly
from ty_extensions import static_assert, is_disjoint_from
# Two simple disjoint types, to avoid relying on `@disjoint_base` special cases for built-ins like
# `int` and `str`.
@final
class Final1: ...
@final
class Final2: ...
static_assert(is_disjoint_from(Final1, Final2))
class DisjointTD1(TypedDict):
# Make this example `ReadOnly` because that actually ends up checking the field types for
# disjointness in practice. Mutable fields are stricter. We'll get to that below.
disjoint: ReadOnly[Final1]
# While we're here: It doesn't matter how many other compatible fields there are. Just the one
# incompatible field above establishes disjointness.
common1: object
common2: object
class DisjointTD2(TypedDict):
disjoint: ReadOnly[Final2]
common1: object
common2: object
static_assert(is_disjoint_from(DisjointTD1, DisjointTD2))
```
However, note that most pairs of non-final classes are *not* disjoint from each other, even if
neither inherits from the other, because we could define a third class that multiply-inherits from
both. `TypedDict` disjointness takes this into account. For example:
```py
from ty_extensions import is_assignable_to
class NonFinal1: ...
class NonFinal2: ...
class CommonSub(NonFinal1, NonFinal2): ...
static_assert(not is_disjoint_from(NonFinal1, NonFinal2))
static_assert(not is_assignable_to(NonFinal1, NonFinal2))
static_assert(is_assignable_to(CommonSub, NonFinal1))
static_assert(is_assignable_to(CommonSub, NonFinal2))
class NonDisjointTD1(TypedDict):
non_disjoint: ReadOnly[NonFinal1]
# While we're here: It doesn't matter how many "extra" fields there are, or what order the
# fields are in. Only shared field names can establish disjointness.
extra1: int
class NonDisjointTD2(TypedDict):
extra2: str
non_disjoint: ReadOnly[NonFinal2]
class CommonSubTD(TypedDict):
extra2: str
extra1: int
non_disjoint: ReadOnly[CommonSub]
# The first two TDs above are not assignable in either direction...
static_assert(not is_assignable_to(NonDisjointTD1, NonDisjointTD2))
static_assert(not is_assignable_to(NonDisjointTD2, NonDisjointTD1))
# ...but they're still not disjoint...
static_assert(not is_disjoint_from(NonDisjointTD1, NonDisjointTD2))
# ...because the third TD above is assignable to both of them.
static_assert(is_assignable_to(CommonSubTD, NonDisjointTD1))
static_assert(is_assignable_to(CommonSubTD, NonDisjointTD2))
static_assert(not is_disjoint_from(CommonSubTD, NonDisjointTD1))
static_assert(not is_disjoint_from(CommonSubTD, NonDisjointTD2))
```
We made the important fields `ReadOnly` above, because those only establish disjointness when
they're disjoint themselves. However, the rules for mutable fields are stricter. Mutable fields in
common need to have *compatible* types (in the fully-static case, equivalent types):
```py
from typing import Any, Generic, TypeVar
class IntTD(TypedDict):
x: int
class BoolTD(TypedDict):
x: bool
# `bool` is assignable to `int`, but `int` is not assignable to `bool`. If `x` was `ReadOnly` (even,
# as we'll see below, only on the `int` side), then these two TDs would not be disjoint, but in this
# mutable case they are.
static_assert(is_disjoint_from(IntTD, BoolTD))
static_assert(is_disjoint_from(BoolTD, IntTD))
# Gradual types: `int` is compatible with `bool | Any`, because that could materialize to
# `bool | int`, which is just `int`. (And `int | Any` and `bool | Any` are compatible with each
# other for the same reason.) However, `bool` is *not* compatible with `int | Any`, because there's
# no materialization that's equivalent to `bool`.
class IntOrAnyTD(TypedDict):
x: int | Any
class BoolOrAnyTD(TypedDict):
x: bool | Any
static_assert(not is_disjoint_from(IntTD, IntOrAnyTD))
static_assert(not is_disjoint_from(IntOrAnyTD, IntTD))
static_assert(not is_disjoint_from(IntTD, BoolOrAnyTD))
static_assert(not is_disjoint_from(BoolOrAnyTD, IntTD))
static_assert(not is_disjoint_from(IntOrAnyTD, BoolOrAnyTD))
static_assert(not is_disjoint_from(BoolOrAnyTD, IntOrAnyTD))
static_assert(is_disjoint_from(BoolTD, IntOrAnyTD))
static_assert(is_disjoint_from(IntOrAnyTD, BoolTD))
static_assert(not is_disjoint_from(BoolTD, BoolOrAnyTD))
static_assert(not is_disjoint_from(BoolOrAnyTD, BoolTD))
# `Any` is compatible with everything.
class AnyTD(TypedDict):
x: Any
static_assert(not is_disjoint_from(IntTD, AnyTD))
static_assert(not is_disjoint_from(AnyTD, IntTD))
static_assert(not is_disjoint_from(BoolTD, AnyTD))
static_assert(not is_disjoint_from(AnyTD, BoolTD))
static_assert(not is_disjoint_from(IntOrAnyTD, AnyTD))
static_assert(not is_disjoint_from(AnyTD, IntOrAnyTD))
static_assert(not is_disjoint_from(BoolOrAnyTD, AnyTD))
static_assert(not is_disjoint_from(AnyTD, BoolOrAnyTD))
static_assert(not is_disjoint_from(AnyTD, AnyTD))
# This works with generic `TypedDict`s too.
class TwoIntsTD(TypedDict):
x: int
y: int
class TwoBoolsTD(TypedDict):
x: bool
y: bool
class IntBoolTD(TypedDict):
x: int
y: bool
T = TypeVar("T")
class TwoGenericTD(TypedDict, Generic[T]):
x: T
y: T
static_assert(not is_disjoint_from(TwoGenericTD[Any], TwoIntsTD))
static_assert(not is_disjoint_from(TwoGenericTD[int], TwoIntsTD))
static_assert(is_disjoint_from(TwoGenericTD[bool], TwoIntsTD))
static_assert(not is_disjoint_from(TwoGenericTD[Any], TwoBoolsTD))
static_assert(is_disjoint_from(TwoGenericTD[int], TwoBoolsTD))
static_assert(not is_disjoint_from(TwoGenericTD[bool], TwoBoolsTD))
# TODO: T can't be compatible with both `int` and `bool` at the same time, so these types should be
# disjoint, regardless of the materialization of `T`.
static_assert(not is_disjoint_from(TwoGenericTD[Any], IntBoolTD))
```
If one side is mutable but the other is not, then a "third `TypedDict` that's assignable to both"
would have to have the same type as the mutable side, so we establish disjointness if that type
isn't assignable to the immutable side:
```py
class ReadOnlyIntTD(TypedDict):
x: ReadOnly[int]
class ReadOnlyBoolTD(TypedDict):
x: ReadOnly[bool]
static_assert(not is_disjoint_from(ReadOnlyIntTD, ReadOnlyBoolTD))
static_assert(not is_disjoint_from(ReadOnlyBoolTD, ReadOnlyIntTD))
static_assert(not is_disjoint_from(BoolTD, ReadOnlyIntTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, BoolTD))
static_assert(is_disjoint_from(IntTD, ReadOnlyBoolTD))
static_assert(is_disjoint_from(ReadOnlyBoolTD, IntTD))
```
With mutability above we were able to make the simplifying assumption that the "third `TypedDict`
that's assignable to both" has only mutable fields, because a mutable field is always assignable to
its immutable counterpart. However, `Required` vs `NotRequired` are more complicated, because a a
`Required` field is *not* necessarily assignable to its `NotRequired` counterpart. In particular, if
a `NotRequired` field is also mutable (intuitively, if we're allowed to `del` it), then no
`Required` field is ever assignable to it. So, if either side is `NotRequired` and mutable, and the
other side is `Required` (regardless of mutability), then that's sufficient to establish
disjointness:
```py
from typing_extensions import NotRequired
class NotRequiredIntTD(TypedDict):
x: NotRequired[int]
class NotRequiredReadOnlyIntTD(TypedDict):
x: NotRequired[ReadOnly[int]]
static_assert(is_disjoint_from(NotRequiredIntTD, IntTD))
static_assert(is_disjoint_from(IntTD, NotRequiredIntTD))
static_assert(is_disjoint_from(NotRequiredIntTD, ReadOnlyIntTD))
static_assert(is_disjoint_from(ReadOnlyIntTD, NotRequiredIntTD))
static_assert(not is_disjoint_from(NotRequiredIntTD, NotRequiredReadOnlyIntTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, NotRequiredIntTD))
```
All those rules put together give us the "full disjointness table". We've pretty well tested above
that disjointness is symmetrical, so here we won't worry about asserting both directions for each
check:
```py
class NotRequiredBoolTD(TypedDict):
x: NotRequired[bool]
class NotRequiredReadOnlyBoolTD(TypedDict):
x: NotRequired[ReadOnly[bool]]
static_assert(not is_disjoint_from(IntTD, IntTD))
static_assert(is_disjoint_from(IntTD, BoolTD))
static_assert(not is_disjoint_from(IntTD, ReadOnlyIntTD))
static_assert(is_disjoint_from(IntTD, ReadOnlyBoolTD))
static_assert(is_disjoint_from(IntTD, NotRequiredIntTD))
static_assert(is_disjoint_from(IntTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(IntTD, NotRequiredReadOnlyIntTD))
static_assert(is_disjoint_from(IntTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, BoolTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, ReadOnlyIntTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, ReadOnlyBoolTD))
static_assert(is_disjoint_from(ReadOnlyIntTD, NotRequiredIntTD))
static_assert(is_disjoint_from(ReadOnlyIntTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, NotRequiredReadOnlyIntTD))
static_assert(not is_disjoint_from(ReadOnlyIntTD, NotRequiredReadOnlyBoolTD))
static_assert(is_disjoint_from(NotRequiredIntTD, BoolTD))
static_assert(is_disjoint_from(NotRequiredIntTD, ReadOnlyBoolTD))
static_assert(not is_disjoint_from(NotRequiredIntTD, NotRequiredIntTD))
static_assert(is_disjoint_from(NotRequiredIntTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(NotRequiredIntTD, NotRequiredReadOnlyIntTD))
static_assert(is_disjoint_from(NotRequiredIntTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, BoolTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, ReadOnlyBoolTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, NotRequiredReadOnlyIntTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyIntTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(BoolTD, BoolTD))
static_assert(not is_disjoint_from(BoolTD, ReadOnlyBoolTD))
static_assert(is_disjoint_from(BoolTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(BoolTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(ReadOnlyBoolTD, ReadOnlyBoolTD))
static_assert(is_disjoint_from(ReadOnlyBoolTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(ReadOnlyBoolTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(NotRequiredBoolTD, NotRequiredBoolTD))
static_assert(not is_disjoint_from(NotRequiredBoolTD, NotRequiredReadOnlyBoolTD))
static_assert(not is_disjoint_from(NotRequiredReadOnlyBoolTD, NotRequiredReadOnlyBoolTD))
```
## Disjointness with other types
```py
from typing import TypedDict, Mapping
from ty_extensions import static_assert, is_disjoint_from
class TD(TypedDict):
x: int
class RegularNonTD: ...
static_assert(not is_disjoint_from(TD, object))
static_assert(not is_disjoint_from(TD, Mapping[str, object]))
static_assert(is_disjoint_from(TD, Mapping[int, object]))
static_assert(is_disjoint_from(TD, RegularNonTD))
# TODO: We approximate disjointness with other types `T` by asking whether `dict[str, Any]` is
# assignable to `T`. That covers common cases like the ones above, but does it have some false
# negatives with `dict` types. A `TypedDict` is almost never assignable to a `dict` (or vice versa),
# even when all of the `TypedDict`'s field types match the `dict`'s value type (and are mutable).
# The problem is that the `TypedDict` could have been assigned to from *another* `TypedDict` with
# additional fields, and we don't usually know anything about the types or mutability of those. On
# the other hand, the assignment to `dict` can be allowed if the `TypedDict` has mutable
# `extra_items` of a compatible type. See: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-with-dict
static_assert(is_disjoint_from(TD, dict[str, int])) # error: [static-assert-error]
static_assert(is_disjoint_from(TD, dict[str, str])) # error: [static-assert-error]
```
[subtyping section]: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html

View File

@@ -1952,8 +1952,22 @@ impl<'db> Type<'db> {
///
/// See [`TypeRelation::Subtyping`] for more details.
pub(crate) fn is_subtype_of(self, db: &'db dyn Db, target: Type<'db>) -> bool {
self.when_subtype_of(db, target, InferableTypeVars::None)
.is_always_satisfied(db)
#[salsa::tracked(cycle_initial=is_subtype_of_cycle_initial, heap_size=ruff_memory_usage::heap_size)]
fn is_subtype_of_impl<'db>(
db: &'db dyn Db,
self_ty: Type<'db>,
target: Type<'db>,
) -> bool {
self_ty
.when_subtype_of(db, target, InferableTypeVars::None)
.is_always_satisfied(db)
}
if self == target {
return true;
}
is_subtype_of_impl(db, self, target)
}
fn when_subtype_of(
@@ -1988,8 +2002,22 @@ impl<'db> Type<'db> {
///
/// See `TypeRelation::Assignability` for more details.
pub fn is_assignable_to(self, db: &'db dyn Db, target: Type<'db>) -> bool {
self.when_assignable_to(db, target, InferableTypeVars::None)
.is_always_satisfied(db)
#[salsa::tracked(cycle_initial=is_assignable_to_cycle_initial, heap_size=ruff_memory_usage::heap_size)]
fn is_assignable_to_impl<'db>(
db: &'db dyn Db,
self_ty: Type<'db>,
target: Type<'db>,
) -> bool {
self_ty
.when_assignable_to(db, target, InferableTypeVars::None)
.is_always_satisfied(db)
}
if self == target {
return true;
}
is_assignable_to_impl(db, self, target)
}
/// Return true if this type is assignable to type `target` using constraint-set assignability.
@@ -2403,6 +2431,9 @@ impl<'db> Type<'db> {
// `Never` is the bottom type, the empty set.
(_, Type::Never) => ConstraintSet::from(false),
// Short-circuit: if both sides are the same union, they trivially satisfy the relation.
(Type::Union(left), Type::Union(right)) if left == right => ConstraintSet::from(true),
(Type::Union(union), _) => union.elements(db).iter().when_all(db, |&elem_ty| {
elem_ty.has_relation_to_impl(
db,
@@ -2414,16 +2445,22 @@ impl<'db> Type<'db> {
)
}),
(_, Type::Union(union)) => union.elements(db).iter().when_any(db, |&elem_ty| {
self.has_relation_to_impl(
db,
elem_ty,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}),
(_, Type::Union(union)) => {
// Fast path: if self is directly a member of the union, no need to check relations
if union.elements(db).contains(&self) {
return ConstraintSet::from(true);
}
union.elements(db).iter().when_any(db, |&elem_ty| {
self.has_relation_to_impl(
db,
elem_ty,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
})
}
// If both sides are intersections we need to handle the right side first
// (A & B & C) is a subtype of (A & B) because the left is a subtype of both A and B,
@@ -3224,8 +3261,22 @@ impl<'db> Type<'db> {
/// This function aims to have no false positives, but might return wrong
/// `false` answers in some cases.
pub(crate) fn is_disjoint_from(self, db: &'db dyn Db, other: Type<'db>) -> bool {
self.when_disjoint_from(db, other, InferableTypeVars::None)
.is_always_satisfied(db)
#[salsa::tracked(cycle_initial=is_disjoint_from_cycle_initial, heap_size=ruff_memory_usage::heap_size)]
fn is_disjoint_from_cached<'db>(
db: &'db dyn Db,
self_ty: Type<'db>,
other: Type<'db>,
) -> bool {
self_ty
.when_disjoint_from(db, other, InferableTypeVars::None)
.is_always_satisfied(db)
}
if self == other {
return false;
}
is_disjoint_from_cached(db, self, other)
}
fn when_disjoint_from(
@@ -3307,11 +3358,6 @@ impl<'db> Type<'db> {
})
}
(Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => {
// TODO: Implement disjointness for TypedDict
ConstraintSet::from(false)
}
// `type[T]` is disjoint from a callable or protocol instance if its upper bound or constraints are.
(Type::SubclassOf(subclass_of), Type::Callable(_) | Type::ProtocolInstance(_))
| (Type::Callable(_) | Type::ProtocolInstance(_), Type::SubclassOf(subclass_of))
@@ -4038,6 +4084,34 @@ impl<'db> Type<'db> {
}
(Type::GenericAlias(_), _) | (_, Type::GenericAlias(_)) => ConstraintSet::from(true),
(Type::TypedDict(self_typeddict), Type::TypedDict(other_typeddict)) => {
disjointness_visitor.visit((self, other), || {
self_typeddict.is_disjoint_from_impl(
db,
other_typeddict,
inferable,
disjointness_visitor,
relation_visitor,
)
})
}
// For any type `T`, if `dict[str, Any]` is not assignable to `T`, then all `TypedDict`
// types will always be disjoint from `T`. This doesn't cover all cases -- in fact
// `dict` *itself* is almost always disjoint from `TypedDict` -- but it's a good
// approximation, and some false negatives are acceptable.
(Type::TypedDict(_), other) | (other, Type::TypedDict(_)) => KnownClass::Dict
.to_specialized_instance(db, [KnownClass::Str.to_instance(db), Type::any()])
.has_relation_to_impl(
db,
other,
inferable,
TypeRelation::Assignability,
relation_visitor,
disjointness_visitor,
)
.negate(db),
}
}
@@ -8648,6 +8722,37 @@ impl<'db> VarianceInferable<'db> for Type<'db> {
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_subtype_of_cycle_initial<'db>(
_db: &'db dyn Db,
_id: salsa::Id,
_self_ty: Type<'db>,
_target: Type<'db>,
) -> bool {
false
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_assignable_to_cycle_initial<'db>(
_db: &'db dyn Db,
_id: salsa::Id,
_self_ty: Type<'db>,
_target: Type<'db>,
) -> bool {
// In case of a cycle, conservatively assume assignable to avoid false positives
true
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_disjoint_from_cycle_initial<'db>(
_db: &'db dyn Db,
_id: salsa::Id,
_self_ty: Type<'db>,
_other: Type<'db>,
) -> bool {
false
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_redundant_with_cycle_initial<'db>(
_db: &'db dyn Db,

View File

@@ -365,7 +365,7 @@ impl<'db> UnionBuilder<'db> {
Type::StringLiteral(literal) => {
let mut found = None;
let mut to_remove = None;
let ty_negated = ty.negate(self.db);
let mut ty_negated = None;
for (index, element) in self.elements.iter_mut().enumerate() {
match element {
UnionElement::StringLiterals(literals) => {
@@ -383,8 +383,10 @@ impl<'db> UnionBuilder<'db> {
}
if existing.is_subtype_of(self.db, ty) {
to_remove = Some(index);
continue;
}
if ty_negated.is_subtype_of(self.db, *existing) {
let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db));
if negated.is_subtype_of(self.db, *existing) {
// The type that includes both this new element, and its negation
// (or a supertype of its negation), must be simply `object`.
self.collapse_to_object();
@@ -410,7 +412,7 @@ impl<'db> UnionBuilder<'db> {
Type::BytesLiteral(literal) => {
let mut found = None;
let mut to_remove = None;
let ty_negated = ty.negate(self.db);
let mut ty_negated = None;
for (index, element) in self.elements.iter_mut().enumerate() {
match element {
UnionElement::BytesLiterals(literals) => {
@@ -428,8 +430,11 @@ impl<'db> UnionBuilder<'db> {
}
if existing.is_subtype_of(self.db, ty) {
to_remove = Some(index);
continue;
}
if ty_negated.is_subtype_of(self.db, *existing) {
let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db));
if negated.is_subtype_of(self.db, *existing) {
// The type that includes both this new element, and its negation
// (or a supertype of its negation), must be simply `object`.
self.collapse_to_object();
@@ -455,7 +460,7 @@ impl<'db> UnionBuilder<'db> {
Type::IntLiteral(literal) => {
let mut found = None;
let mut to_remove = None;
let ty_negated = ty.negate(self.db);
let mut ty_negated = None;
for (index, element) in self.elements.iter_mut().enumerate() {
match element {
UnionElement::IntLiterals(literals) => {
@@ -473,8 +478,11 @@ impl<'db> UnionBuilder<'db> {
}
if existing.is_subtype_of(self.db, ty) {
to_remove = Some(index);
continue;
}
if ty_negated.is_subtype_of(self.db, *existing) {
let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db));
if negated.is_subtype_of(self.db, *existing) {
// The type that includes both this new element, and its negation
// (or a supertype of its negation), must be simply `object`.
self.collapse_to_object();
@@ -549,19 +557,28 @@ impl<'db> UnionBuilder<'db> {
// unpacking them.
let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery;
let mut to_remove = SmallVec::<[usize; 2]>::new();
let ty_negated = if should_simplify_full {
ty.negate(self.db)
} else {
Type::Never // won't be used
let mut ty_negated: Option<Type> = None;
let mut i = 0;
let mut insertion_point: Option<usize> = None;
let mut remove_or_replace = |i: usize, elements: &mut Vec<UnionElement<'db>>| {
if insertion_point.is_none() {
insertion_point = Some(i);
} else {
elements.swap_remove(i);
}
};
for (index, element) in self.elements.iter_mut().enumerate() {
while i < self.elements.len() {
let element = &mut self.elements[i];
let element_type = match element.try_reduce(self.db, ty) {
ReduceResult::KeepIf(keep) => {
if !keep {
to_remove.push(index);
remove_or_replace(i, &mut self.elements);
}
i += 1;
continue;
}
ReduceResult::Type(ty) => ty,
@@ -587,19 +604,24 @@ impl<'db> UnionBuilder<'db> {
// problematic if some of those fields point to recursive `Union`s. To avoid cycles,
// compare `TypedDict`s by name/identity instead of using the `has_relation_to`
// machinery.
if let (Type::TypedDict(element_td), Type::TypedDict(ty_td)) = (element_type, ty) {
if element_td == ty_td {
return;
}
if element_type.is_typed_dict() && ty.is_typed_dict() {
i += 1;
continue;
}
if should_simplify_full && !matches!(element_type, Type::TypeAlias(_)) {
if ty.is_redundant_with(self.db, element_type) {
return;
} else if element_type.is_redundant_with(self.db, ty) {
to_remove.push(index);
} else if ty_negated.is_subtype_of(self.db, element_type) {
}
if element_type.is_redundant_with(self.db, ty) {
remove_or_replace(i, &mut self.elements);
i += 1;
continue;
}
let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db));
if negated.is_subtype_of(self.db, element_type) {
// We add `ty` to the union. We just checked that `~ty` is a subtype of an
// existing `element`. This also means that `~ty | ty` is a subtype of
// `element | ty`, because both elements in the first union are subtypes of
@@ -613,13 +635,12 @@ impl<'db> UnionBuilder<'db> {
return;
}
}
i += 1;
}
if let Some((&first, rest)) = to_remove.split_first() {
self.elements[first] = UnionElement::Type(ty);
// We iterate in descending order to keep remaining indices valid after `swap_remove`.
for &index in rest.iter().rev() {
self.elements.swap_remove(index);
}
if let Some(insertion_point) = insertion_point {
self.elements[insertion_point] = UnionElement::Type(ty);
} else {
self.elements.push(UnionElement::Type(ty));
}

View File

@@ -600,8 +600,22 @@ impl<'db> ClassType<'db> {
/// Return `true` if `other` is present in this class's MRO.
pub(super) fn is_subclass_of(self, db: &'db dyn Db, other: ClassType<'db>) -> bool {
self.when_subclass_of(db, other, InferableTypeVars::None)
.is_always_satisfied(db)
#[salsa::tracked(cycle_initial=is_subclass_of_cycle_initial, heap_size=ruff_memory_usage::heap_size)]
fn is_subclass_of_impl<'db>(
db: &'db dyn Db,
self_ty: ClassType<'db>,
other: ClassType<'db>,
) -> bool {
self_ty
.when_subclass_of(db, other, InferableTypeVars::None)
.is_always_satisfied(db)
}
if self == other {
return true;
}
is_subclass_of_impl(db, self, other)
}
pub(super) fn when_subclass_of(
@@ -714,6 +728,7 @@ impl<'db> ClassType<'db> {
/// Return the [`DisjointBase`] that appears first in the MRO of this class.
///
/// Returns `None` if this class does not have any disjoint bases in its MRO.
#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)]
pub(super) fn nearest_disjoint_base(self, db: &'db dyn Db) -> Option<DisjointBase<'db>> {
self.iter_mro(db)
.filter_map(ClassBase::into_class)
@@ -1360,6 +1375,16 @@ fn into_callable_cycle_initial<'db>(
CallableTypes::one(CallableType::bottom(db))
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_subclass_of_cycle_initial<'db>(
_db: &'db dyn Db,
_id: salsa::Id,
_self_ty: ClassType<'db>,
_other: ClassType<'db>,
) -> bool {
false
}
impl<'db> From<GenericAlias<'db>> for ClassType<'db> {
fn from(generic: GenericAlias<'db>) -> ClassType<'db> {
ClassType::Generic(generic)
@@ -4110,7 +4135,7 @@ impl InheritanceCycle {
/// `TypeError`s resulting from class definitions.
///
/// [PEP 800]: https://peps.python.org/pep-0800/
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, get_size2::GetSize, salsa::Update)]
pub(super) struct DisjointBase<'db> {
pub(super) class: ClassLiteral<'db>,
pub(super) kind: DisjointBaseKind,
@@ -4147,7 +4172,7 @@ impl<'db> DisjointBase<'db> {
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)]
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.

View File

@@ -83,7 +83,7 @@ use crate::types::{
BoundTypeVarIdentity, BoundTypeVarInstance, IntersectionType, Type, TypeVarBoundOrConstraints,
UnionType, walk_bound_type_var_type,
};
use crate::{Db, FxOrderMap};
use crate::{Db, FxOrderMap, FxOrderSet};
/// An extension trait for building constraint sets from [`Option`] values.
pub(crate) trait OptionConstraintsExtension<T> {
@@ -2222,10 +2222,21 @@ impl<'db> InteriorNode<'db> {
constraints = %Node::Interior(self).display(db),
"create sequent map",
);
let mut map = SequentMap::default();
Node::Interior(self).for_each_constraint(db, &mut |constraint, _| {
map.add(db, constraint);
// Sort the constraints in this BDD by their `source_order`s before adding them to the
// sequent map. This ensures that constraints appear in the sequent map in a stable order.
// The constraints mentioned in a BDD should all have distinct `source_order`s, so an
// unstable sort is fine.
let mut constraints = Vec::new();
Node::Interior(self).for_each_constraint(db, &mut |constraint, source_order| {
constraints.push((constraint, source_order));
});
constraints.sort_unstable_by_key(|(_, source_order)| *source_order);
let mut map = SequentMap::default();
for (constraint, _) in constraints {
map.add(db, constraint);
}
map
}
@@ -2781,10 +2792,10 @@ struct SequentMap<'db> {
/// Sequents of the form `C₁ ∧ C₂ → D`
pair_implications: FxHashMap<
(ConstrainedTypeVar<'db>, ConstrainedTypeVar<'db>),
FxHashSet<ConstrainedTypeVar<'db>>,
FxOrderSet<ConstrainedTypeVar<'db>>,
>,
/// Sequents of the form `C → D`
single_implications: FxHashMap<ConstrainedTypeVar<'db>, FxHashSet<ConstrainedTypeVar<'db>>>,
single_implications: FxHashMap<ConstrainedTypeVar<'db>, FxOrderSet<ConstrainedTypeVar<'db>>>,
/// Constraints that we have already processed
processed: FxHashSet<ConstrainedTypeVar<'db>>,
/// Constraints that enqueued to be processed

View File

@@ -1594,7 +1594,7 @@ declare_lint! {
pub(crate) static POSSIBLY_MISSING_IMPORT = {
summary: "detects possibly missing imports",
status: LintStatus::stable("0.0.1-alpha.22"),
default_level: Level::Warn,
default_level: Level::Ignore,
}
}

View File

@@ -1590,14 +1590,19 @@ impl<'db> SpecializationBuilder<'db> {
upper: FxOrderSet<Type<'db>>,
}
// Sort the constraints in each path by their `source_order`s, to ensure that we construct
// any unions or intersections in our type mappings in a stable order. Constraints might
// come out of `PathAssignment`s with identical `source_order`s, but if they do, those
// "tied" constraints will still be ordered in a stable way. So we need a stable sort to
// retain that stable per-tie ordering.
let constraints = constraints.limit_to_valid_specializations(self.db);
let mut sorted_paths = Vec::new();
constraints.for_each_path(self.db, |path| {
let mut path: Vec<_> = path.positive_constraints().collect();
path.sort_unstable_by_key(|(_, source_order)| *source_order);
path.sort_by_key(|(_, source_order)| *source_order);
sorted_paths.push(path);
});
sorted_paths.sort_unstable_by(|path1, path2| {
sorted_paths.sort_by(|path1, path2| {
let source_orders1 = path1.iter().map(|(_, source_order)| *source_order);
let source_orders2 = path2.iter().map(|(_, source_order)| *source_order);
source_orders1.cmp(source_orders2)

View File

@@ -310,6 +310,14 @@ pub(super) struct TypeInferenceBuilder<'db, 'ast> {
/// A list of `dataclass_transform` field specifiers that are "active" (when inferring
/// the right hand side of an annotated assignment in a class that is a dataclass).
dataclass_field_specifiers: SmallVec<[Type<'db>; NUM_FIELD_SPECIFIERS_INLINE]>,
/// Unions we're currently narrowing against in ancestor calls.
///
/// When inferring a call expression with a union type context, we try narrowing to each
/// element of the union. If nested calls have the same union as their parameter type,
/// this would lead to exponential blowup. By tracking which unions we're already narrowing
/// against, we skip redundant nested narrowing.
narrowing_unions: FxHashSet<UnionType<'db>>,
}
impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
@@ -348,6 +356,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
cycle_recovery: None,
all_definitely_bound: true,
dataclass_field_specifiers: SmallVec::new(),
narrowing_unions: FxHashSet::default(),
}
}
@@ -7008,13 +7017,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let db = self.db();
// If the type context is a union, attempt to narrow to a specific element.
let narrow_targets: &[_] = match call_expression_tcx.annotation {
// TODO: We could theoretically attempt to narrow to every element of
// the power set of this union. However, this leads to an exponential
// explosion of inference attempts, and is rarely needed in practice.
Some(Type::Union(union)) => union.elements(db),
_ => &[],
};
// However, skip narrowing if we're already narrowing against the same union
// in an ancestor call to avoid exponential blowup with deeply nested calls.
let (narrow_union, narrow_targets): (Option<UnionType<'db>>, &[_]) =
match call_expression_tcx.annotation {
Some(Type::Union(union)) if !self.narrowing_unions.contains(&union) => {
// TODO: We could theoretically attempt to narrow to every element of
// the power set of this union. However, this leads to an exponential
// explosion of inference attempts, and is rarely needed in practice.
(Some(union), union.elements(db))
}
_ => (None, &[]),
};
// Track that we're narrowing against this union to prevent nested calls
// from redundantly narrowing against the same union.
if let Some(union) = narrow_union {
self.narrowing_unions.insert(union);
}
// We silence diagnostics until we successfully narrow to a specific type.
let mut speculated_bindings = bindings.clone();
@@ -7082,12 +7102,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
};
// Prefer the declared type of generic classes.
let mut narrowing_result = None;
for narrowed_ty in narrow_targets
.iter()
.filter(|ty| ty.class_specialization(db).is_some())
{
if let Some(result) = try_narrow(*narrowed_ty) {
return result;
narrowing_result = Some(result);
break;
}
}
@@ -7095,15 +7117,28 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
//
// TODO: We could also attempt an inference without type context, but this
// leads to similar performance issues.
for narrowed_ty in narrow_targets
.iter()
.filter(|ty| ty.class_specialization(db).is_none())
{
if let Some(result) = try_narrow(*narrowed_ty) {
return result;
if narrowing_result.is_none() {
for narrowed_ty in narrow_targets
.iter()
.filter(|ty| ty.class_specialization(db).is_none())
{
if let Some(result) = try_narrow(*narrowed_ty) {
narrowing_result = Some(result);
break;
}
}
}
// Clean up: remove the union from the tracking set.
if let Some(union) = narrow_union {
self.narrowing_unions.remove(&union);
}
// If narrowing succeeded, return the result.
if let Some(result) = narrowing_result {
return result;
}
// Re-enable diagnostics, and infer against the entire union as a fallback.
self.context.set_multi_inference(was_in_multi_inference);
@@ -12592,6 +12627,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
index: _,
region: _,
return_types_and_ranges: _,
narrowing_unions: _,
} = self;
let diagnostics = context.finish();
@@ -12659,6 +12695,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
index: _,
region: _,
return_types_and_ranges: _,
narrowing_unions: _,
} = self;
let _ = scope;
@@ -12736,6 +12773,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
index: _,
region: _,
return_types_and_ranges: _,
narrowing_unions: _,
} = self;
let _ = scope;

View File

@@ -720,7 +720,7 @@ impl<'db> ProtocolInstanceType<'db> {
_value: ProtocolInstanceType<'db>,
_: (),
) -> bool {
true
false
}
is_equivalent_to_object_inner(db, self, ())

View File

@@ -1,3 +1,4 @@
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::ops::{Deref, DerefMut};
@@ -341,6 +342,148 @@ impl<'db> TypedDictType<'db> {
},
)
}
/// Two `TypedDict`s `A` and `B` are disjoint if it's impossible to come up with a third
/// `TypedDict` `C` that's fully-static and assignable to both of them.
///
/// `TypedDict` assignability is determined field-by-field, so we determine disjointness
/// similarly. For any field that's only in `A`, it's always possible for our hypothetical `C`
/// to copy/paste that field without losing assignability to `B` (and vice versa), so we only
/// need to consider fields that are present in both `A` and `B`.
///
/// There are three properties of each field to consider: the declared type, whether it's
/// mutable ("mut" vs "imm" below), and whether it's required ("req" vs "opt" below). Here's a
/// table summary of the restrictions on the declared type of a source field (for us that means
/// in `C`, which we want to be assignable to both `A` and `B`) given a destination field (for
/// us that means in either `A` or `B`). For completeness we'll also include the possibility
/// that the source field is missing entirely, though we'll soon see that we can ignore that
/// case. This table is essentially what `has_relation_to_impl` implements above. Here
/// "equivalent" means the source and destination types must be equivalent/compatible,
/// "assignable" means the source must be assignable to the destination, and "-" means the
/// assignment is never allowed:
///
/// | dest ↓ source → | mut + req | mut + opt | imm + req | imm + opt | \[missing] |
/// |------------------|------------|------------|------------|------------|---------------|
/// | mut + req | equivalent | - | - | - | - |
/// | mut + opt | - | equivalent | - | - | - |
/// | imm + req | assignable | - | assignable | - | - |
/// | imm + opt | assignable | assignable | assignable | assignable | \[dest is obj]|
///
/// We can cut that table down substantially by noticing two things:
///
/// - We don't need to consider the cases where the source field (in `C`) is `ReadOnly`/"imm",
/// because the mutable version of the same field is always "strictly more assignable". In
/// other words, nothing in the `TypedDict` assignability rules ever requires a source field
/// to be immutable.
/// - We don't need to consider the special case where the source field is missing, because
/// that's only allowed when the destination is `ReadOnly[NotRequired[object]]`, which is
/// compatible with *any* choice of source field.
///
/// The cases we actually need to reason about are this smaller table:
///
/// | dest ↓ source → | mut + req | mut + opt |
/// |------------------|------------|------------|
/// | mut + req | equivalent | - |
/// | mut + opt | - | equivalent |
/// | imm + req | assignable | - |
/// | imm + opt | assignable | assignable |
///
/// So, given a field name that's in both `A` and `B`, here are the conditions where it's
/// *impossible* to choose a source field for `C` that's compatible with both destinations,
/// which tells us that `A` and `B` are disjoint:
///
/// 1. If one side is "mut+opt" (which forces the field in `C` to be "opt") and the other side
/// is "req" (which forces the field in `C` to be "req").
/// 2. If both sides are mutable, and their types are not equivalent/compatible. (Because the
/// type in `C` must be compatible with both of them.)
/// 3. If one sides is mutable, and its type is not assignable to the immutable side's type.
/// (Because the type in `C` must be compatible with the mutable side.)
/// 4. If both sides are immutable, and their types are disjoint. (Because the type in `C` must
/// be assignable to both.)
///
/// TODO: Adding support for `closed` and `extra_items` will complicate this.
pub(crate) fn is_disjoint_from_impl(
self,
db: &'db dyn Db,
other: TypedDictType<'db>,
inferable: InferableTypeVars<'_, 'db>,
disjointness_visitor: &IsDisjointVisitor<'db>,
relation_visitor: &HasRelationToVisitor<'db>,
) -> ConstraintSet<'db> {
let fields_in_common = btreemap_values_with_same_key(self.items(db), other.items(db));
fields_in_common.when_any(db, |(self_field, other_field)| {
// Condition 1 above.
if self_field.is_required() || other_field.is_required() {
if (!self_field.is_required() && !self_field.is_read_only())
|| (!other_field.is_required() && !other_field.is_read_only())
{
// One side demands a `Required` source field, while the other side demands a
// `NotRequired` one. They must be disjoint.
return ConstraintSet::from(true);
}
}
if !self_field.is_read_only() && !other_field.is_read_only() {
// Condition 2 above. This field is mutable on both sides, so the so the types must
// be compatible, i.e. mutually assignable.
self_field
.declared_ty
.has_relation_to_impl(
db,
other_field.declared_ty,
inferable,
TypeRelation::Assignability,
relation_visitor,
disjointness_visitor,
)
.and(db, || {
other_field.declared_ty.has_relation_to_impl(
db,
self_field.declared_ty,
inferable,
TypeRelation::Assignability,
relation_visitor,
disjointness_visitor,
)
})
.negate(db)
} else if !self_field.is_read_only() {
// Half of condition 3 above.
self_field
.declared_ty
.has_relation_to_impl(
db,
other_field.declared_ty,
inferable,
TypeRelation::Assignability,
relation_visitor,
disjointness_visitor,
)
.negate(db)
} else if !other_field.is_read_only() {
// The other half of condition 3 above.
other_field
.declared_ty
.has_relation_to_impl(
db,
self_field.declared_ty,
inferable,
TypeRelation::Assignability,
relation_visitor,
disjointness_visitor,
)
.negate(db)
} else {
// Condition 4 above.
self_field.declared_ty.is_disjoint_from_impl(
db,
other_field.declared_ty,
inferable,
disjointness_visitor,
relation_visitor,
)
}
})
}
}
pub(crate) fn walk_typed_dict_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
@@ -905,3 +1048,71 @@ bitflags! {
}
impl get_size2::GetSize for TypedDictFieldFlags {}
/// Yield all the key/val pairs where the same key is present in both `BTreeMap`s. Take advantage
/// of the fact that keys are sorted to walk through each map once without doing any lookups. It
/// would be nice if `BTreeMap` had something like `BTreeSet::intersection` that did this for us,
/// but as far as I know we have to do it ourselves. Life is hard.
fn btreemap_values_with_same_key<'a, K, V1, V2>(
left: &'a BTreeMap<K, V1>,
right: &'a BTreeMap<K, V2>,
) -> impl Iterator<Item = (&'a V1, &'a V2)>
where
K: Ord,
{
let mut left_items = left.iter().peekable();
let mut right_items = right.iter().peekable();
std::iter::from_fn(move || {
while let (Some((left_key, left_val)), Some((right_key, right_val))) =
(left_items.peek().copied(), right_items.peek().copied())
{
match left_key.cmp(right_key) {
Ordering::Equal => {
// Matching keys. Yield this pair of values and advance both iterators.
left_items.next();
right_items.next();
return Some((left_val, right_val));
}
Ordering::Less => {
// `left_items` is behind `right_items` in key order. Advance `left_items`.
left_items.next();
}
Ordering::Greater => {
// The opposite.
right_items.next();
}
}
}
// We've exhausted one or both of the maps, so there can be no more matching keys.
None
})
}
#[test]
fn test_btreemap_overlapping_items() {
// A case with partial overlap and gaps.
let left = BTreeMap::from_iter([("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5)]);
let right = BTreeMap::from_iter([("b", 2.0), ("d", 4.0), ("f", 6.0)]);
assert_eq!(
btreemap_values_with_same_key(&left, &right).collect::<Vec<_>>(),
vec![(&2, &2.0), (&4, &4.0)],
);
assert_eq!(
btreemap_values_with_same_key(&right, &left).collect::<Vec<_>>(),
vec![(&2.0, &2), (&4.0, &4)],
);
// A case where one side is empty.
let left = BTreeMap::<i32, i32>::new();
let right = BTreeMap::<i32, i32>::from_iter([(1, 1), (2, 2)]);
assert!(
btreemap_values_with_same_key(&left, &right)
.next()
.is_none()
);
assert!(
btreemap_values_with_same_key(&right, &left)
.next()
.is_none()
);
}

View File

@@ -89,7 +89,6 @@ Settings: Settings {
"positional-only-parameter-as-kwarg": Error (Default),
"possibly-missing-attribute": Warning (Default),
"possibly-missing-implicit-call": Warning (Default),
"possibly-missing-import": Warning (Default),
"raw-string-type-annotation": Error (Default),
"redundant-cast": Warning (Default),
"static-assert-error": Error (Default),

View File

@@ -384,6 +384,9 @@ foo()
It is strongly suggested to use explicit range suppressions, in order to prevent
accidental suppressions of violations, especially at global module scope.
For this reason, a `RUF104` diagnostic will also be produced for any implicit range.
If implicit range suppressions are desired, the `RUF104` rule can be disabled,
or an inline `noqa` suppression can be added to the end of the "disable" comment.
Range suppressions cannot be used to enable or select rules that aren't already
selected by the project configuration or runtime flags. An "enable" comment can only

2
ruff.schema.json generated
View File

@@ -4051,6 +4051,8 @@
"RUF100",
"RUF101",
"RUF102",
"RUF103",
"RUF104",
"RUF2",
"RUF20",
"RUF200",

2
ty.schema.json generated
View File

@@ -926,7 +926,7 @@
"possibly-missing-import": {
"title": "detects possibly missing imports",
"description": "## What it does\nChecks for imports of symbols that may be missing.\n\n## Why is this bad?\nImporting a missing module or name will raise a `ModuleNotFoundError`\nor `ImportError` at runtime.\n\n## Examples\n```python\n# module.py\nimport datetime\n\nif datetime.date.today().weekday() != 6:\n a = 1\n\n# main.py\nfrom module import a # ImportError: cannot import name 'a' from 'module'\n```",
"default": "warn",
"default": "ignore",
"oneOf": [
{
"$ref": "#/definitions/Level"