Compare commits

...

27 Commits

Author SHA1 Message Date
Aria Desires
cfd75914be render signatures 2025-11-14 16:08:45 -05:00
Aria Desires
42abe02eac print function names for bare Signatures opportunistically 2025-11-13 16:28:32 -05:00
Aria Desires
dcc451d4d2 cleanup implementation 2025-11-13 15:31:21 -05:00
Aria Desires
3a10f87471 only apply the rule if non-trivial 2025-11-12 23:23:29 -05:00
Aria Desires
89236a3b2d Resolve overloads for hovers 2025-11-12 20:20:13 -05:00
Dan Parizher
a6abd65c2c [pydoclint] Fix false positive when Sphinx directives follow Raises section (DOC502) (#20535)
## Summary

Fixes #18959

---------

Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
2025-11-12 21:37:55 +00:00
Aria Desires
3d4b0559f1 [ty] remove erroneous canonicalize (#21405)
Alternative implementation to
https://github.com/astral-sh/ruff/pull/21052
2025-11-12 15:47:33 -05:00
David Peter
2f6f3e1042 [ty] Faster subscript assignment checks for (unions of) TypedDicts (#21378)
## Summary

We synthesize a (potentially large) set of `__setitem__` overloads for
every item in a `TypedDict`. Previously, validation of subscript
assignments on `TypedDict`s relied on actually calling `__setitem__`
with the provided key and value types, which implied that we needed to
do the full overload call evaluation for this large set of overloads.
This PR improves the performance of subscript assignment checks on
`TypedDict`s by validating the assignment directly instead of calling
`__setitem__`.

This PR also adds better handling for assignments to subscripts on union
and intersection types (but does not attempt to make it perfect). It
achieves this by distributing the check over unions and intersections,
instead of calling `__setitem__` on the union/intersection directly. We
already do something similar when validating *attribute* assignments.

## Ecosystem impact

* A lot of diagnostics change their rule type, and/or split into
multiple diagnostics. The new version is more verbose, but easier to
understand, in my opinion
* Almost all of the invalid-key diagnostics come from pydantic, and they
should all go away (including many more) when we implement
https://github.com/astral-sh/ty/issues/1479
* Everything else looks correct to me. There may be some new diagnostics
due to the fact that we now check intersections.

## Test Plan

New Markdown tests.
2025-11-12 20:16:38 +01:00
Shunsuke Shibayama
9dd666d677 [ty] fix global symbol lookup from eager scopes (#21317)
## Summary

cf. https://github.com/astral-sh/ruff/pull/20962

In the following code, `foo` in the comprehension was not reported as
unresolved:

```python
# error: [unresolved-reference] "Name `foo` used when not defined"
foo
foo = [
    # no error!
    # revealed: Divergent
    reveal_type(x) for _ in () for x in [foo]
]

baz = [
    # error: [unresolved-reference] "Name `baz` used when not defined"
    # revealed: Unknown
    reveal_type(x) for _ in () for x in [baz]
]
```

In fact, this is a more serious bug than it looks: for `foo`,
[`explicit_global_symbol` is
called](6cc3393ccd/crates/ty_python_semantic/src/types/infer/builder.rs (L8052)),
causing a symbol that should actually be `Undefined` to be reported as
being of type `Divergent`.

This PR fixes this bug. As a result, the code in
`mdtest/regression/pr_20962_comprehension_panics.md` no longer panics.

## Test Plan

`corpus\cyclic_symbol_in_comprehension.py` is added.
New tests are added in `mdtest/comprehensions/basic.md`.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
Co-authored-by: Carl Meyer <carl@astral.sh>
2025-11-12 10:15:51 -08:00
pyscripter
a1d9cb5830 Added the PyScripter IDE to the list of "Who is using Ruff?" (#21402)
## Summary

Added the PyScripter IDE to the list of "Who is using Ruff?".

PyScripter is a popular python IDE that is using ruff for code
diagnostics, fixes and code formatting.
2025-11-12 18:10:08 +00:00
Nikolas Hearp
8a85a2961e [flake8-simplify] Apply SIM113 when index variable is of type int (#21395)
## Summary

Fixes #21393

Now the rule checks if the index variable is initialized as an `int`
type rather than only flagging if the index variable is initialized to
`0`. I used `ResolvedPythonType` to check if the index variable is an
`int` type.

## Test Plan

Updated snapshot test for `SIM113`.

---------

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
2025-11-12 17:54:39 +00:00
Micha Reiser
43427abb61 [ty] Improve semantic token classification for names (#21399) 2025-11-12 16:34:26 +00:00
David Peter
84c3cecad6 [ty] Baseline for subscript assignment diagnostics (#21404)
## Summary

Add (snapshot) tests for subscript assignment diagnostics. This is
mainly intended to establish a baseline before I hope to improve some of
these messages.
2025-11-12 15:29:26 +01:00
David Peter
e8e8180888 [ty] Implicit type aliases: Add support for typing.Union (#21363)
## Summary

Add support for `typing.Union` in implicit type aliases / in value
position.

## Typing conformance tests

Two new tests are passing

## Ecosystem impact

* The 2k new `invalid-key` diagnostics on pydantic are caused by
https://github.com/astral-sh/ty/issues/1479#issuecomment-3513854645.
* Everything else I've checked is either a known limitation (often
related to type narrowing, because union types are often narrowed down
to a subset of options), or a true positive.

## Test Plan

New Markdown tests
2025-11-12 12:59:14 +01:00
David Peter
f5cf672ed4 [ty] Reorganize walltime benchmarks (#21400) 2025-11-12 12:41:34 +01:00
David Peter
6322f37015 [ty] Better assertion message for benchmark diagnostics check (#21398)
I don't know why, but it always takes me an eternity to find the failing
project name a few lines below in the output. So I'm suggesting we just
add the project name to the assertion message.
2025-11-12 11:02:29 +01:00
Micha Reiser
d272a623d3 [ty] Fix goto for float and complex in type annotation positions (#21388) 2025-11-12 07:54:25 +00:00
Micha Reiser
19c7994e90 [ty] Fix Escape handler in playground (#21397) 2025-11-12 08:54:14 +01:00
Dan Parizher
725ae69773 [pydoclint] Support NumPy-style comma-separated parameters (DOC102) (#20972) 2025-11-12 08:29:23 +01:00
Bhuminjay Soni
d2c3996f4e UP035: Consistently set the deprecated tag (#21396) 2025-11-12 08:17:29 +01:00
Shaygan Hooshyari
988c38c013 [ty] Skip eagerly evaluated scopes for attribute storing (#20856)
## Summary

Fix https://github.com/astral-sh/ty/issues/664

This PR adds support for storing attributes in comprehension scopes (any
eager scope.)

For example in the following code we infer type of `z` correctly:

```py
class C:
    def __init__(self):
        [None for self.z in range(1)]
reveal_type(C().z) # previously [unresolved-attribute] but now shows Unknown | int
```

The fix works by adjusting the following logics:

To identify if an attriute is an assignment to self or cls we need to
check the scope is a method. To allow comprehension scopes here we skip
any eager scope in the check.
Also at this stage the code checks if self or the first method argument
is shadowed by another binding that eager scope to prevent this:

```py
class D:
    g: int

class C:
    def __init__(self):
        [[None for self.g in range(1)] for self in [D()]]
reveal_type(C().g) # [unresolved-attribute]
```

When determining scopes that attributes might be defined after
collecting all the methods of the class the code also returns any
decendant scope that is eager and only has eager parents until the
method scope.

When checking reachability of a attribute definition if the attribute is
defined in an eager scope we use the reachability of the first non eager
scope which must be a method. This allows attributes to be marked as
reachable and be seen.


There are also which I didn't add support for:

```py
class C:
    def __init__(self):
        def f():
            [None for self.z in range(1)]
        f()

reveal_type(C().z) # [unresolved-attribute]
```

In the above example we will not even return the comprehension scope as
an attribute scope because there is a non eager scope (`f` function)
between the comprehension and the `__init__` method

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-11-11 14:45:34 -08:00
Andrew Gallant
164c2a6cc6 [ty] Sort keyword completions above everything else
It looks like VS Code does this forcefully. As in, I don't think we can
override it. It also seems like a plausibly good idea. But by us doing
it too, it makes our completion evaluation framework match real world
conditions. (To the extent that "VS Code" and "real world conditions"
are the same. Which... they aren't. But it's close, since VS Code is so
popular.)
2025-11-11 17:20:55 -05:00
Andrew Gallant
1bbe4f0d5e [ty] Add more keyword completions to scope completions
This should round out the rest of the set. I think I had hesitated doing
this before because some of these don't make sense in every context. But
I think identifying the correct context for every keyword could be quite
difficult. And at the very least, I think offering these at least as a
choice---even if they aren't always correct---is better than not doing
it at all.
2025-11-11 17:20:55 -05:00
Andrew Gallant
cd7354a5c6 [ty] Add completion evaluation task for general keyword completions 2025-11-11 17:20:55 -05:00
Andrew Gallant
ec48a47a88 [ty] Add from <module> im<CURSOR> completion evaluation task
Ideally this would have been added as part of #21291, but I forgot.
2025-11-11 17:20:55 -05:00
Alex Waygood
43297d3455 [ty] Support isinstance() and issubclass() narrowing when the second argument is a typing.py stdlib alias (#21391)
## Summary

A followup to https://github.com/astral-sh/ruff/pull/21386

## Test Plan

New mdtests added
2025-11-11 21:09:24 +00:00
Mahmoud Saada
4373974dd9 [ty] Fix false positive for Final attribute assignment in __init__ (#21158)
## Summary

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

This PR allows `Final` instance attributes to be initialized in
`__init__` methods, as mandated by the Python typing specification (PEP
591). Previously, ty incorrectly prevented this initialization, causing
false positive errors.

The fix checks if we're inside an `__init__` method before rejecting
Final attribute assignments, allowing assignments during
instance initialization while still preventing reassignment elsewhere.

## Test Plan

- Added new test coverage in `final.md` for the reported issue with
`Self` annotations
- Updated existing tests that were incorrectly expecting errors 
- All 278 mdtest tests pass
- Manually tested with real-world code examples

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-11-11 12:54:05 -08:00
72 changed files with 3640 additions and 756 deletions

View File

@@ -491,6 +491,7 @@ Ruff is used by a number of major open-source projects and companies, including:
- [PyTorch](https://github.com/pytorch/pytorch)
- [Pydantic](https://github.com/pydantic/pydantic)
- [Pylint](https://github.com/PyCQA/pylint)
- [PyScripter](https://github.com/pyscripter/pyscripter)
- [PyVista](https://github.com/pyvista/pyvista)
- [Reflex](https://github.com/reflex-dev/reflex)
- [River](https://github.com/online-ml/river)

View File

@@ -71,16 +71,13 @@ impl Display for Benchmark<'_> {
}
}
fn check_project(db: &ProjectDatabase, max_diagnostics: usize) {
fn check_project(db: &ProjectDatabase, project_name: &str, max_diagnostics: usize) {
let result = db.check();
let diagnostics = result.len();
assert!(
diagnostics > 1 && diagnostics <= max_diagnostics,
"Expected between {} and {} diagnostics but got {}",
1,
max_diagnostics,
diagnostics
"Expected between 1 and {max_diagnostics} diagnostics on project '{project_name}' but got {diagnostics}",
);
}
@@ -184,7 +181,7 @@ static PYDANTIC: Benchmark = Benchmark::new(
max_dep_date: "2025-06-17",
python_version: PythonVersion::PY39,
},
1000,
5000,
);
static SYMPY: Benchmark = Benchmark::new(
@@ -226,7 +223,7 @@ static STATIC_FRAME: Benchmark = Benchmark::new(
max_dep_date: "2025-08-09",
python_version: PythonVersion::PY311,
},
800,
900,
);
#[track_caller]
@@ -234,11 +231,11 @@ fn run_single_threaded(bencher: Bencher, benchmark: &Benchmark) {
bencher
.with_inputs(|| benchmark.setup_iteration())
.bench_local_refs(|db| {
check_project(db, benchmark.max_diagnostics);
check_project(db, benchmark.project.name, benchmark.max_diagnostics);
});
}
#[bench(args=[&ALTAIR, &FREQTRADE, &PYDANTIC, &TANJUN], sample_size=2, sample_count=3)]
#[bench(args=[&ALTAIR, &FREQTRADE, &TANJUN], sample_size=2, sample_count=3)]
fn small(bencher: Bencher, benchmark: &Benchmark) {
run_single_threaded(bencher, benchmark);
}
@@ -248,12 +245,12 @@ fn medium(bencher: Bencher, benchmark: &Benchmark) {
run_single_threaded(bencher, benchmark);
}
#[bench(args=[&SYMPY], sample_size=1, sample_count=2)]
#[bench(args=[&SYMPY, &PYDANTIC], sample_size=1, sample_count=2)]
fn large(bencher: Bencher, benchmark: &Benchmark) {
run_single_threaded(bencher, benchmark);
}
#[bench(args=[&PYDANTIC], sample_size=3, sample_count=8)]
#[bench(args=[&ALTAIR], sample_size=3, sample_count=8)]
fn multithreaded(bencher: Bencher, benchmark: &Benchmark) {
let thread_pool = ThreadPoolBuilder::new().build().unwrap();
@@ -261,7 +258,7 @@ fn multithreaded(bencher: Bencher, benchmark: &Benchmark) {
.with_inputs(|| benchmark.setup_iteration())
.bench_local_values(|db| {
thread_pool.install(|| {
check_project(&db, benchmark.max_diagnostics);
check_project(&db, benchmark.project.name, benchmark.max_diagnostics);
db
})
});
@@ -285,7 +282,7 @@ fn main() {
// branch when looking up the ingredient index.
{
let db = TANJUN.setup_iteration();
check_project(&db, TANJUN.max_diagnostics);
check_project(&db, TANJUN.project.name, TANJUN.max_diagnostics);
}
divan::main();

View File

@@ -46,7 +46,8 @@ def func():
def func():
# OK (index doesn't start at 0
# SIM113
# https://github.com/astral-sh/ruff/pull/21395
idx = 10
for x in range(5):
g(x, idx)

View File

@@ -371,6 +371,61 @@ class Foo:
"""
return
# DOC102 - Test case from issue #20959: comma-separated parameters
def leq(x: object, y: object) -> bool:
"""Compare two objects for loose equality.
Parameters
----------
x1, x2 : object
Objects.
Returns
-------
bool
Whether the objects are identical or equal.
"""
return x is y or x == y
# OK - comma-separated parameters that match function signature
def compare_values(x1: int, x2: int) -> bool:
"""Compare two integer values.
Parameters
----------
x1, x2 : int
Values to compare.
Returns
-------
bool
True if values are equal.
"""
return x1 == x2
# DOC102 - mixed comma-separated and regular parameters
def process_data(data, x1: str, x2: str) -> str:
"""Process data with multiple string parameters.
Parameters
----------
data : list
Input data to process.
x1, x2 : str
String parameters for processing.
extra_param : str
Extra parameter not in signature.
Returns
-------
str
Processed result.
"""
return f"{x1}{x2}{len(data)}"
# OK
def baz(x: int) -> int:
"""
@@ -389,3 +444,21 @@ def baz(x: int) -> int:
int
"""
return x
# OK - comma-separated parameters without type annotations
def add_numbers(a, b):
"""
Adds two numbers and returns the result.
Parameters
----------
a, b
The numbers to add.
Returns
-------
int
The sum of the two numbers.
"""
return a + b

View File

@@ -83,6 +83,37 @@ def calculate_speed(distance: float, time: float) -> float:
raise
# DOC502 regression for Sphinx directive after Raises (issue #18959)
def foo():
"""First line.
Raises:
ValueError:
some text
.. versionadded:: 0.7.0
The ``init_kwargs`` argument.
"""
raise ValueError
# DOC502 regression for following section with colons
def example_with_following_section():
"""Summary.
Returns:
str: The resulting expression.
Raises:
ValueError: If the unit is not valid.
Relation to `time_range_lookup`:
- Handles the "start of" modifier.
- Example: "start of month" → `DATETRUNC()`.
"""
raise ValueError
# This should NOT trigger DOC502 because OSError is explicitly re-raised
def f():
"""Do nothing.

View File

@@ -117,3 +117,33 @@ def calculate_speed(distance: float, time: float) -> float:
except TypeError:
print("Not a number? Shame on you!")
raise
# DOC502 regression for Sphinx directive after Raises (issue #18959)
def foo():
"""First line.
Raises
------
ValueError
some text
.. versionadded:: 0.7.0
The ``init_kwargs`` argument.
"""
raise ValueError
# Make sure we don't bail out on a Sphinx directive in the description of one
# of the exceptions
def foo():
"""First line.
Raises
------
ValueError
some text
.. math:: e^{xception}
ZeroDivisionError
Will not be raised, DOC502
"""
raise ValueError

View File

@@ -269,3 +269,8 @@ pub(crate) const fn is_typing_extensions_str_alias_enabled(settings: &LinterSett
pub(crate) const fn is_extended_i18n_function_matching_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/21395
pub(crate) const fn is_enumerate_for_loop_int_index_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}

View File

@@ -61,6 +61,7 @@ mod tests {
#[test_case(Rule::SplitStaticString, Path::new("SIM905.py"))]
#[test_case(Rule::DictGetWithNoneDefault, Path::new("SIM910.py"))]
#[test_case(Rule::EnumerateForLoop, Path::new("SIM113.py"))]
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"preview__{}_{}",

View File

@@ -1,6 +1,8 @@
use crate::preview::is_enumerate_for_loop_int_index_enabled;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt};
use ruff_python_ast::{self as ast, Expr, Int, Number, Operator, Stmt};
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
use ruff_python_semantic::analyze::typing;
use ruff_text_size::Ranged;
@@ -11,6 +13,9 @@ use crate::checkers::ast::Checker;
/// Checks for `for` loops with explicit loop-index variables that can be replaced
/// with `enumerate()`.
///
/// In [preview], this rule checks for index variables initialized with any integer rather than only
/// a literal zero.
///
/// ## Why is this bad?
/// When iterating over a sequence, it's often desirable to keep track of the
/// index of each element alongside the element itself. Prefer the `enumerate`
@@ -35,6 +40,8 @@ use crate::checkers::ast::Checker;
///
/// ## References
/// - [Python documentation: `enumerate`](https://docs.python.org/3/library/functions.html#enumerate)
///
/// [preview]: https://docs.astral.sh/ruff/preview/
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "v0.2.0")]
pub(crate) struct EnumerateForLoop {
@@ -82,17 +89,21 @@ pub(crate) fn enumerate_for_loop(checker: &Checker, for_stmt: &ast::StmtFor) {
continue;
}
// Ensure that the index variable was initialized to 0.
// Ensure that the index variable was initialized to 0 (or instance of `int` if preview is enabled).
let Some(value) = typing::find_binding_value(binding, checker.semantic()) else {
continue;
};
if !matches!(
if !(matches!(
value,
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: Number::Int(Int::ZERO),
..
})
) {
) || matches!(
ResolvedPythonType::from(value),
ResolvedPythonType::Atom(PythonType::Number(NumberLike::Integer))
) && is_enumerate_for_loop_int_index_enabled(checker.settings()))
{
continue;
}

View File

@@ -0,0 +1,60 @@
---
source: crates/ruff_linter/src/rules/flake8_simplify/mod.rs
---
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:6:9
|
4 | for x in range(5):
5 | g(x, idx)
6 | idx += 1
| ^^^^^^^^
7 | h(x)
|
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:17:9
|
15 | if g(x):
16 | break
17 | idx += 1
| ^^^^^^^^
18 | sum += h(x, idx)
|
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:27:9
|
25 | g(x)
26 | h(x, y)
27 | idx += 1
| ^^^^^^^^
|
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:36:9
|
34 | for x in range(5):
35 | sum += h(x, idx)
36 | idx += 1
| ^^^^^^^^
|
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:44:9
|
42 | for x in range(5):
43 | g(x, idx)
44 | idx += 1
| ^^^^^^^^
45 | h(x)
|
SIM113 Use `enumerate()` for index variable `idx` in `for` loop
--> SIM113.py:54:9
|
52 | for x in range(5):
53 | g(x, idx)
54 | idx += 1
| ^^^^^^^^
55 | h(x)
|

View File

@@ -661,19 +661,31 @@ fn parse_parameters_numpy(content: &str, content_start: TextSize) -> Vec<Paramet
.is_some_and(|first_char| !first_char.is_whitespace())
{
if let Some(before_colon) = entry.split(':').next() {
let param = before_colon.trim_end();
let param_name = param.trim_start_matches('*');
if is_identifier(param_name) {
let param_start = line_start + indentation.text_len();
let param_end = param_start + param.text_len();
let param_line = before_colon.trim_end();
entries.push(ParameterEntry {
name: param_name,
range: TextRange::new(
content_start + param_start,
content_start + param_end,
),
});
// Split on commas to handle comma-separated parameters
let mut current_offset = TextSize::from(0);
for param_part in param_line.split(',') {
let param_part_trimmed = param_part.trim();
let param_name = param_part_trimmed.trim_start_matches('*');
if is_identifier(param_name) {
// Calculate the position of this specific parameter part within the line
// Account for leading whitespace that gets trimmed
let param_start_in_line = current_offset
+ (param_part.text_len() - param_part_trimmed.text_len());
let param_start =
line_start + indentation.text_len() + param_start_in_line;
entries.push(ParameterEntry {
name: param_name,
range: TextRange::at(
content_start + param_start,
param_part_trimmed.text_len(),
),
});
}
// Update offset for next iteration: add the part length plus comma length
current_offset = current_offset + param_part.text_len() + ','.text_len();
}
}
}
@@ -710,12 +722,30 @@ fn parse_raises(content: &str, style: Option<SectionStyle>) -> Vec<QualifiedName
/// ```
fn parse_raises_google(content: &str) -> Vec<QualifiedName<'_>> {
let mut entries: Vec<QualifiedName> = Vec::new();
for potential in content.lines() {
let Some(colon_idx) = potential.find(':') else {
continue;
};
let entry = potential[..colon_idx].trim();
entries.push(QualifiedName::user_defined(entry));
let mut lines = content.lines().peekable();
let Some(first) = lines.peek() else {
return entries;
};
let indentation = &first[..first.len() - first.trim_start().len()];
for potential in lines {
if let Some(entry) = potential.strip_prefix(indentation) {
if let Some(first_char) = entry.chars().next() {
if !first_char.is_whitespace() {
if let Some(colon_idx) = entry.find(':') {
let entry = entry[..colon_idx].trim();
if !entry.is_empty() {
entries.push(QualifiedName::user_defined(entry));
}
}
}
}
} else {
// If we can't strip the expected indentation, check if this is a dedented line
// (not blank) - if so, break early as we've reached the end of this section
if !potential.trim().is_empty() {
break;
}
}
}
entries
}
@@ -739,6 +769,12 @@ fn parse_raises_numpy(content: &str) -> Vec<QualifiedName<'_>> {
let indentation = &dashes[..dashes.len() - dashes.trim_start().len()];
for potential in lines {
if let Some(entry) = potential.strip_prefix(indentation) {
// Check for Sphinx directives (lines starting with ..) - these indicate the end of the
// section. In numpy-style, exceptions are dedented to the same level as sphinx
// directives.
if entry.starts_with("..") {
break;
}
if let Some(first_char) = entry.chars().next() {
if !first_char.is_whitespace() {
entries.push(QualifiedName::user_defined(entry.trim_end()));

View File

@@ -95,3 +95,23 @@ DOC502 Raised exception is not explicitly raised: `DivisionByZero`
82 | return distance / time
|
help: Remove `DivisionByZero` from the docstring
DOC502 Raised exception is not explicitly raised: `ZeroDivisionError`
--> DOC502_numpy.py:139:5
|
137 | # of the exceptions
138 | def foo():
139 | / """First line.
140 | |
141 | | Raises
142 | | ------
143 | | ValueError
144 | | some text
145 | | .. math:: e^{xception}
146 | | ZeroDivisionError
147 | | Will not be raised, DOC502
148 | | """
| |_______^
149 | raise ValueError
|
help: Remove `ZeroDivisionError` from the docstring

View File

@@ -187,3 +187,36 @@ DOC102 Documented parameter `a` is not in the function's signature
302 | b
|
help: Remove the extraneous parameter from the docstring
DOC102 Documented parameter `x1` is not in the function's signature
--> DOC102_numpy.py:380:5
|
378 | Parameters
379 | ----------
380 | x1, x2 : object
| ^^
381 | Objects.
|
help: Remove the extraneous parameter from the docstring
DOC102 Documented parameter `x2` is not in the function's signature
--> DOC102_numpy.py:380:9
|
378 | Parameters
379 | ----------
380 | x1, x2 : object
| ^^
381 | Objects.
|
help: Remove the extraneous parameter from the docstring
DOC102 Documented parameter `extra_param` is not in the function's signature
--> DOC102_numpy.py:418:5
|
416 | x1, x2 : str
417 | String parameters for processing.
418 | extra_param : str
| ^^^^^^^^^^^
419 | Extra parameter not in signature.
|
help: Remove the extraneous parameter from the docstring

View File

@@ -766,11 +766,12 @@ pub(crate) fn deprecated_import(checker: &Checker, import_from_stmt: &StmtImport
}
for operation in fixer.with_renames() {
checker.report_diagnostic(
let mut diagnostic = checker.report_diagnostic(
DeprecatedImport {
deprecation: Deprecation::WithRename(operation),
},
import_from_stmt.range(),
);
diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated);
}
}

View File

@@ -323,6 +323,231 @@ fn python_version_inferred_from_system_installation() -> anyhow::Result<()> {
Ok(())
}
/// This attempts to simulate the tangled web of symlinks that a homebrew install has
/// which can easily confuse us if we're ever told to use it.
///
/// The main thing this is regression-testing is a panic in one *extremely* specific case
/// that you have to try really hard to hit (but vscode, hilariously, did hit).
#[cfg(unix)]
#[test]
fn python_argument_trapped_in_a_symlink_factory() -> anyhow::Result<()> {
let case = CliTest::with_files([
// This is the real python binary.
(
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13",
"",
),
// There's a real site-packages here (although it's basically empty).
(
"opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages/foo.py",
"",
),
// There's also a real site-packages here (although it's basically empty).
("opt/homebrew/lib/python3.13/site-packages/bar.py", ""),
// This has the real stdlib, but the site-packages in this dir is a symlink.
(
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/abc.py",
"",
),
// It's important that this our faux-homebrew not be in the same dir as our working directory
// to reproduce the crash, don't ask me why.
(
"project/test.py",
"\
import foo
import bar
import colorama
",
),
])?;
// many python symlinks pointing to a single real python (the longest path)
case.write_symlink(
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13",
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3",
)?;
case.write_symlink(
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3",
"opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3",
)?;
case.write_symlink(
"opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3",
"opt/homebrew/bin/python3",
)?;
// the "real" python's site-packages is a symlink to a different dir
case.write_symlink(
"opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages",
"opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages",
)?;
// Try all 4 pythons with absolute paths to our fauxbrew install
assert_cmd_snapshot!(case.command()
.current_dir(case.root().join("project"))
.arg("--python").arg(case.root().join("opt/homebrew/bin/python3")), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `foo`
--> test.py:1:8
|
1 | import foo
| ^^^
2 | import bar
3 | import colorama
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
error[unresolved-import]: Cannot resolve imported module `colorama`
--> test.py:3:8
|
1 | import foo
2 | import bar
3 | import colorama
| ^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 2 diagnostics
----- stderr -----
");
assert_cmd_snapshot!(case.command()
.current_dir(case.root().join("project"))
.arg("--python").arg(case.root().join("opt/homebrew/Cellar/python@3.13/3.13.5/bin/python3")), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `bar`
--> test.py:2:8
|
1 | import foo
2 | import bar
| ^^^
3 | import colorama
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
error[unresolved-import]: Cannot resolve imported module `colorama`
--> test.py:3:8
|
1 | import foo
2 | import bar
3 | import colorama
| ^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 2 diagnostics
----- stderr -----
");
assert_cmd_snapshot!(case.command()
.current_dir(case.root().join("project"))
.arg("--python").arg(case.root().join("opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3")), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `bar`
--> test.py:2:8
|
1 | import foo
2 | import bar
| ^^^
3 | import colorama
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
error[unresolved-import]: Cannot resolve imported module `colorama`
--> test.py:3:8
|
1 | import foo
2 | import bar
3 | import colorama
| ^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 2 diagnostics
----- stderr -----
");
assert_cmd_snapshot!(case.command()
.current_dir(case.root().join("project"))
.arg("--python").arg(case.root().join("opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13")), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `bar`
--> test.py:2:8
|
1 | import foo
2 | import bar
| ^^^
3 | import colorama
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
error[unresolved-import]: Cannot resolve imported module `colorama`
--> test.py:3:8
|
1 | import foo
2 | import bar
3 | import colorama
| ^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. <temp_dir>/project (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. <temp_dir>/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 2 diagnostics
----- stderr -----
");
Ok(())
}
/// On Unix systems, it's common for a Python installation at `.venv/bin/python` to only be a symlink
/// to a system Python installation. We must be careful not to resolve the symlink too soon!
/// If we do, we will incorrectly add the system installation's `site-packages` as a search path,

View File

@@ -10,12 +10,14 @@ import-deprioritizes-type_check_only,main.py,1,1
import-deprioritizes-type_check_only,main.py,2,1
import-deprioritizes-type_check_only,main.py,3,2
import-deprioritizes-type_check_only,main.py,4,3
import-keyword-completion,main.py,0,1
internal-typeshed-hidden,main.py,0,5
none-completion,main.py,0,11
none-completion,main.py,0,2
numpy-array,main.py,0,
numpy-array,main.py,1,1
object-attr-instance-methods,main.py,0,1
object-attr-instance-methods,main.py,1,1
pass-keyword-completion,main.py,0,1
raise-uses-base-exception,main.py,0,2
scope-existing-over-new-import,main.py,0,1
scope-prioritize-closer,main.py,0,2
@@ -23,4 +25,4 @@ scope-simple-long-identifier,main.py,0,1
tstring-completions,main.py,0,1
ty-extensions-lower-stdlib,main.py,0,8
type-var-typing-over-ast,main.py,0,3
type-var-typing-over-ast,main.py,1,277
type-var-typing-over-ast,main.py,1,279
1 name file index rank
10 import-deprioritizes-type_check_only main.py 2 1
11 import-deprioritizes-type_check_only main.py 3 2
12 import-deprioritizes-type_check_only main.py 4 3
13 import-keyword-completion main.py 0 1
14 internal-typeshed-hidden main.py 0 5
15 none-completion main.py 0 11 2
16 numpy-array main.py 0
17 numpy-array main.py 1 1
18 object-attr-instance-methods main.py 0 1
19 object-attr-instance-methods main.py 1 1
20 pass-keyword-completion main.py 0 1
21 raise-uses-base-exception main.py 0 2
22 scope-existing-over-new-import main.py 0 1
23 scope-prioritize-closer main.py 0 2
25 tstring-completions main.py 0 1
26 ty-extensions-lower-stdlib main.py 0 8
27 type-var-typing-over-ast main.py 0 3
28 type-var-typing-over-ast main.py 1 277 279

View File

@@ -0,0 +1,2 @@
[settings]
auto-import = false

View File

@@ -0,0 +1 @@
from collections im<CURSOR: import>

View File

@@ -0,0 +1,5 @@
[project]
name = "test"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "test"
version = "0.1.0"
source = { virtual = "." }

View File

@@ -0,0 +1,2 @@
[settings]
auto-import = false

View File

@@ -0,0 +1,3 @@
match x:
case int():
pa<CURSOR: pass>

View File

@@ -0,0 +1,5 @@
[project]
name = "test"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "test"
version = "0.1.0"
source = { virtual = "." }

File diff suppressed because it is too large Load Diff

View File

@@ -65,11 +65,10 @@ impl Docstring {
/// Render the docstring for markdown display
pub fn render_markdown(&self) -> String {
let trimmed = documentation_trim(&self.0);
// TODO: now actually parse it and "render" it to markdown.
//
// For now we just wrap the content in a plaintext codeblock
// to avoid the contents erroneously being interpreted as markdown.
format!("```text\n{trimmed}\n```")
// Try to parse and render the contents as markdown,
// and if we fail, wrap it in a codeblock and display it raw.
try_render_markdown(&trimmed).unwrap_or_else(|| format!("```text\n{trimmed}\n```"))
}
/// Extract parameter documentation from popular docstring formats.
@@ -153,6 +152,26 @@ fn documentation_trim(docs: &str) -> String {
output
}
fn try_render_markdown(docstring: &str) -> Option<String> {
let mut output = String::new();
let mut first_line = true;
for line in docstring.lines() {
// We can assume leading whitespace has been normalized
let trimmed_line = line.trim_start_matches(' ');
let num_leading_spaces = line.len() - trimmed_line.len();
if !first_line {
output.push_str(" \n");
}
for _ in 0..num_leading_spaces {
output.push_str("&nbsp;");
}
output.push_str(trimmed_line);
first_line = false;
}
Some(output)
}
/// Extract parameter documentation from Google-style docstrings.
fn extract_google_style_params(docstring: &str) -> HashMap<String, String> {
let mut param_docs = HashMap::new();

View File

@@ -15,7 +15,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
use ty_python_semantic::ResolvedDefinition;
use ty_python_semantic::types::Type;
use ty_python_semantic::types::ide_support::{
call_signature_details, definitions_for_keyword_argument,
call_signature_details, call_type_simplified_by_overloads, definitions_for_keyword_argument,
};
use ty_python_semantic::{
HasDefinition, HasType, ImportAliasResolution, SemanticModel, definitions_for_imported_symbol,
@@ -326,6 +326,18 @@ impl GotoTarget<'_> {
Some(ty)
}
/// Try to get a simplified display of this callable type by resolving overloads
pub(crate) fn call_type_simplified_by_overloads(
&self,
model: &SemanticModel,
) -> Option<String> {
if let GotoTarget::Call { call, .. } = self {
call_type_simplified_by_overloads(model.db(), model, call)
} else {
None
}
}
/// Gets the definitions for this goto target.
///
/// The `alias_resolution` parameter controls whether import aliases

View File

@@ -1592,6 +1592,111 @@ a = Test()
");
}
#[test]
fn float_annotation() {
let test = CursorTest::builder()
.source(
"main.py",
"
a: float<CURSOR> = 3.14
",
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> stdlib/builtins.pyi:346:7
|
345 | @disjoint_base
346 | class int:
| ^^^
347 | """int([x]) -> integer
348 | int(x, base=10) -> integer
|
info: Source
--> main.py:2:4
|
2 | a: float = 3.14
| ^^^^^
|
info[goto-definition]: Definition
--> stdlib/builtins.pyi:659:7
|
658 | @disjoint_base
659 | class float:
| ^^^^^
660 | """Convert a string or number to a floating-point number, if possible."""
|
info: Source
--> main.py:2:4
|
2 | a: float = 3.14
| ^^^^^
|
"#);
}
#[test]
fn complex_annotation() {
let test = CursorTest::builder()
.source(
"main.py",
"
a: complex<CURSOR> = 3.14
",
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> stdlib/builtins.pyi:346:7
|
345 | @disjoint_base
346 | class int:
| ^^^
347 | """int([x]) -> integer
348 | int(x, base=10) -> integer
|
info: Source
--> main.py:2:4
|
2 | a: complex = 3.14
| ^^^^^^^
|
info[goto-definition]: Definition
--> stdlib/builtins.pyi:659:7
|
658 | @disjoint_base
659 | class float:
| ^^^^^
660 | """Convert a string or number to a floating-point number, if possible."""
|
info: Source
--> main.py:2:4
|
2 | a: complex = 3.14
| ^^^^^^^
|
info[goto-definition]: Definition
--> stdlib/builtins.pyi:820:7
|
819 | @disjoint_base
820 | class complex:
| ^^^^^^^
821 | """Create a complex number from a string or numbers.
|
info: Source
--> main.py:2:4
|
2 | a: complex = 3.14
| ^^^^^^^
|
"#);
}
/// Regression test for <https://github.com/astral-sh/ty/issues/1451>.
/// We must ensure we respect re-import convention for stub files for
/// imports in builtins.pyi.

View File

@@ -20,7 +20,6 @@ pub fn hover(db: &dyn Db, file: File, offset: TextSize) -> Option<RangedValue<Ho
}
let model = SemanticModel::new(db, file);
let ty = goto_target.inferred_type(&model);
let docs = goto_target
.get_definition_targets(
file,
@@ -30,9 +29,10 @@ pub fn hover(db: &dyn Db, file: File, offset: TextSize) -> Option<RangedValue<Ho
.and_then(|definitions| definitions.docstring(db))
.map(HoverContent::Docstring);
// TODO: Render the symbol's signature instead of just its type.
let mut contents = Vec::new();
if let Some(ty) = ty {
if let Some(signature) = goto_target.call_type_simplified_by_overloads(&model) {
contents.push(HoverContent::Signature(signature));
} else if let Some(ty) = goto_target.inferred_type(&model) {
tracing::debug!("Inferred type of covering node is {}", ty.display(db));
contents.push(match ty {
Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => typevar
@@ -62,7 +62,7 @@ pub struct Hover<'db> {
impl<'db> Hover<'db> {
/// Renders the hover to a string using the specified markup kind.
pub const fn display<'a>(&'a self, db: &'a dyn Db, kind: MarkupKind) -> DisplayHover<'a> {
pub const fn display<'a>(&'a self, db: &'db dyn Db, kind: MarkupKind) -> DisplayHover<'db, 'a> {
DisplayHover {
db,
hover: self,
@@ -93,13 +93,13 @@ impl<'a, 'db> IntoIterator for &'a Hover<'db> {
}
}
pub struct DisplayHover<'a> {
db: &'a dyn Db,
hover: &'a Hover<'a>,
pub struct DisplayHover<'db, 'a> {
db: &'db dyn Db,
hover: &'a Hover<'db>,
kind: MarkupKind,
}
impl fmt::Display for DisplayHover<'_> {
impl fmt::Display for DisplayHover<'_, '_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut first = true;
for content in &self.hover.contents {
@@ -115,8 +115,9 @@ impl fmt::Display for DisplayHover<'_> {
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[derive(Debug, Clone)]
pub enum HoverContent<'db> {
Signature(String),
Type(Type<'db>, Option<TypeVarVariance>),
Docstring(Docstring),
}
@@ -140,6 +141,9 @@ pub(crate) struct DisplayHoverContent<'a, 'db> {
impl fmt::Display for DisplayHoverContent<'_, '_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.content {
HoverContent::Signature(signature) => {
self.kind.fenced_code_block(&signature, "python").fmt(f)
}
HoverContent::Type(ty, variance) => {
let variance = match variance {
Some(TypeVarVariance::Covariant) => " (covariant)",
@@ -961,14 +965,12 @@ def ab(a: str): ...
assert_snapshot!(test.hover(), @r"
(a: int) -> Unknown
(a: str) -> Unknown
---------------------------------------------
the int overload
---------------------------------------------
```python
(a: int) -> Unknown
(a: str) -> Unknown
```
---
```text
@@ -1025,14 +1027,12 @@ def ab(a: str):
.build();
assert_snapshot!(test.hover(), @r#"
(a: int) -> Unknown
(a: str) -> Unknown
---------------------------------------------
the int overload
---------------------------------------------
```python
(a: int) -> Unknown
(a: str) -> Unknown
```
---
@@ -1090,21 +1090,19 @@ def ab(a: int):
.build();
assert_snapshot!(test.hover(), @r"
(
def ab(
a: int,
b: int
) -> Unknown
(a: int) -> Unknown
---------------------------------------------
the two arg overload
---------------------------------------------
```python
(
def ab(
a: int,
b: int
) -> Unknown
(a: int) -> Unknown
```
---
```text
@@ -1161,20 +1159,12 @@ def ab(a: int):
.build();
assert_snapshot!(test.hover(), @r"
(
a: int,
b: int
) -> Unknown
(a: int) -> Unknown
---------------------------------------------
the two arg overload
---------------------------------------------
```python
(
a: int,
b: int
) -> Unknown
(a: int) -> Unknown
```
---
@@ -1236,33 +1226,21 @@ def ab(a: int, *, c: int):
.build();
assert_snapshot!(test.hover(), @r"
(a: int) -> Unknown
(
def ab(
a: int,
*,
b: int
) -> Unknown
(
a: int,
*,
c: int
) -> Unknown
---------------------------------------------
keywordless overload
---------------------------------------------
```python
(a: int) -> Unknown
(
def ab(
a: int,
*,
b: int
) -> Unknown
(
a: int,
*,
c: int
) -> Unknown
```
---
```text
@@ -1323,13 +1301,7 @@ def ab(a: int, *, c: int):
.build();
assert_snapshot!(test.hover(), @r"
(a: int) -> Unknown
(
a: int,
*,
b: int
) -> Unknown
(
def ab(
a: int,
*,
c: int
@@ -1339,13 +1311,7 @@ def ab(a: int, *, c: int):
---------------------------------------------
```python
(a: int) -> Unknown
(
a: int,
*,
b: int
) -> Unknown
(
def ab(
a: int,
*,
c: int
@@ -1397,11 +1363,11 @@ def ab(a: int, *, c: int):
);
assert_snapshot!(test.hover(), @r#"
(
def foo(
a: int,
b
) -> Unknown
(
def foo(
a: str,
b
) -> Unknown
@@ -1410,11 +1376,11 @@ def ab(a: int, *, c: int):
---------------------------------------------
```python
(
def foo(
a: int,
b
) -> Unknown
(
def foo(
a: str,
b
) -> Unknown
@@ -2634,6 +2600,40 @@ def ab(a: int, *, c: int):
");
}
#[test]
fn hover_float_annotation() {
let test = cursor_test(
r#"
a: float<CURSOR> = 3.14
"#,
);
assert_snapshot!(test.hover(), @r"
int | float
---------------------------------------------
Convert a string or number to a floating-point number, if possible.
---------------------------------------------
```python
int | float
```
---
```text
Convert a string or number to a floating-point number, if possible.
```
---------------------------------------------
info[hover]: Hovered content is
--> main.py:2:4
|
2 | a: float = 3.14
| ^^^^^- Cursor offset
| |
| source
|
");
}
impl CursorTest {
fn hover(&self) -> String {
use std::fmt::Write;

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,7 @@
# Documentation of two fuzzer panics involving comprehensions
# Regression test for https://github.com/astral-sh/ruff/pull/20962
# error message:
# `place_by_id: execute: too many cycle iterations`
Type inference for comprehensions was added in <https://github.com/astral-sh/ruff/pull/20962>. It
added two new fuzzer panics that are documented here for regression testing.
## Too many cycle iterations in `place_by_id`
<!-- expect-panic: too many cycle iterations -->
```py
name_5(name_3)
[0 for unique_name_0 in unique_name_1 for unique_name_2 in name_3]
@@ -34,4 +28,3 @@ else:
@name_3
async def name_5():
pass
```

View File

@@ -87,9 +87,23 @@ class Foo:
class Baz[T: Foo]:
pass
# error: [unresolved-reference] "Name `Foo` used when not defined"
# error: [unresolved-reference] "Name `Bar` used when not defined"
class Qux(Foo, Bar, Baz):
pass
# error: [unresolved-reference] "Name `Foo` used when not defined"
# error: [unresolved-reference] "Name `Bar` used when not defined"
class Quux[_T](Foo, Bar, Baz):
pass
# error: [unresolved-reference]
type S = a
type T = b
type U = Foo
# error: [unresolved-reference]
type V = Bar
type W = Baz
def h[T: Bar]():
# error: [unresolved-reference]
@@ -141,9 +155,23 @@ class Foo:
class Baz[T: Foo]:
pass
# error: [unresolved-reference] "Name `Foo` used when not defined"
# error: [unresolved-reference] "Name `Bar` used when not defined"
class Qux(Foo, Bar, Baz):
pass
# error: [unresolved-reference] "Name `Foo` used when not defined"
# error: [unresolved-reference] "Name `Bar` used when not defined"
class Quux[_T](Foo, Bar, Baz):
pass
# error: [unresolved-reference]
type S = a
type T = b
type U = Foo
# error: [unresolved-reference]
type V = Bar
type W = Baz
def h[T: Bar]():
# error: [unresolved-reference]

View File

@@ -369,6 +369,11 @@ reveal_type(c_instance.y) # revealed: Unknown | int
#### Attributes defined in comprehensions
```toml
[environment]
python-version = "3.12"
```
```py
class TupleIterator:
def __next__(self) -> tuple[int, str]:
@@ -380,19 +385,9 @@ class TupleIterable:
class C:
def __init__(self) -> None:
# TODO: Should not emit this diagnostic
# error: [unresolved-attribute]
[... for self.a in range(3)]
# TODO: Should not emit this diagnostic
# error: [unresolved-attribute]
# error: [unresolved-attribute]
[... for (self.b, self.c) in TupleIterable()]
# TODO: Should not emit this diagnostic
# error: [unresolved-attribute]
# error: [unresolved-attribute]
[... for self.d in range(3) for self.e in range(3)]
# TODO: Should not emit this diagnostic
# error: [unresolved-attribute]
[[... for self.f in range(3)] for _ in range(3)]
[[... for self.g in range(3)] for self in [D()]]
@@ -401,35 +396,74 @@ class D:
c_instance = C()
# TODO: no error, reveal Unknown | int
# error: [unresolved-attribute]
reveal_type(c_instance.a) # revealed: Unknown
reveal_type(c_instance.a) # revealed: Unknown | int
# TODO: no error, reveal Unknown | int
# error: [unresolved-attribute]
reveal_type(c_instance.b) # revealed: Unknown
reveal_type(c_instance.b) # revealed: Unknown | int
# TODO: no error, reveal Unknown | str
# error: [unresolved-attribute]
reveal_type(c_instance.c) # revealed: Unknown
reveal_type(c_instance.c) # revealed: Unknown | str
# TODO: no error, reveal Unknown | int
# error: [unresolved-attribute]
reveal_type(c_instance.d) # revealed: Unknown
reveal_type(c_instance.d) # revealed: Unknown | int
# TODO: no error, reveal Unknown | int
# error: [unresolved-attribute]
reveal_type(c_instance.e) # revealed: Unknown
reveal_type(c_instance.e) # revealed: Unknown | int
# TODO: no error, reveal Unknown | int
# error: [unresolved-attribute]
reveal_type(c_instance.f) # revealed: Unknown
reveal_type(c_instance.f) # revealed: Unknown | int
# This one is correctly not resolved as an attribute:
# error: [unresolved-attribute]
reveal_type(c_instance.g) # revealed: Unknown
```
It does not matter how much the comprehension is nested.
Similarly attributes defined by the comprehension in a generic method are recognized.
```py
class C:
def f[T](self):
[... for self.a in [1]]
[[... for self.b in [1]] for _ in [1]]
c_instance = C()
reveal_type(c_instance.a) # revealed: Unknown | int
reveal_type(c_instance.b) # revealed: Unknown | int
```
If the comprehension is inside another scope like function then that attribute is not inferred.
```py
class C:
def __init__(self):
def f():
# error: [unresolved-attribute]
[... for self.a in [1]]
def g():
# error: [unresolved-attribute]
[... for self.b in [1]]
g()
c_instance = C()
# This attribute is in the function f and is not reachable
# error: [unresolved-attribute]
reveal_type(c_instance.a) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(c_instance.b) # revealed: Unknown
```
If the comprehension is nested in any other eager scope it still can assign attributes.
```py
class C:
def __init__(self):
class D:
[[... for self.a in [1]] for _ in [1]]
reveal_type(C().a) # revealed: Unknown | int
```
#### Conditionally declared / bound attributes
We currently treat implicit instance attributes to be bound, even if they are only conditionally

View File

@@ -58,6 +58,24 @@ Iterating over an unbound iterable yields `Unknown`:
# error: [not-iterable] "Object of type `int` is not iterable"
# revealed: tuple[int, Unknown]
[reveal_type((x, z)) for x in range(3) for z in x]
# error: [unresolved-reference] "Name `foo` used when not defined"
foo
foo = [
# revealed: tuple[int, Unknown]
reveal_type((x, z))
for x in range(3)
# error: [unresolved-reference] "Name `foo` used when not defined"
for z in [foo]
]
baz = [
# revealed: tuple[int, Unknown]
reveal_type((x, z))
for x in range(3)
# error: [unresolved-reference] "Name `baz` used when not defined"
for z in [baz]
]
```
## Starred expressions

View File

@@ -288,6 +288,43 @@ class C[T]:
class Bad2(Iterable[T]): ...
```
## Class bases are evaluated within the type parameter scope
```py
class C[_T](
# error: [unresolved-reference] "Name `C` used when not defined"
C
): ...
# `D` in `list[D]` is resolved to be a type variable of class `D`.
class D[D](list[D]): ...
# error: [unresolved-reference] "Name `E` used when not defined"
if E:
class E[_T](
# error: [unresolved-reference] "Name `E` used when not defined"
E
): ...
# error: [unresolved-reference] "Name `F` used when not defined"
F
# error: [unresolved-reference] "Name `F` used when not defined"
class F[_T](F): ...
def foo():
class G[_T](
# error: [unresolved-reference] "Name `G` used when not defined"
G
): ...
# error: [unresolved-reference] "Name `H` used when not defined"
if H:
class H[_T](
# error: [unresolved-reference] "Name `H` used when not defined"
H
): ...
```
## Class scopes do not cover inner scopes
Just like regular symbols, the typevars of a generic class are only available in that class's scope,

View File

@@ -33,7 +33,7 @@ g(None)
We also support unions in type aliases:
```py
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional, Union
from ty_extensions import Unknown
IntOrStr = int | str
@@ -41,6 +41,8 @@ IntOrStrOrBytes1 = int | str | bytes
IntOrStrOrBytes2 = (int | str) | bytes
IntOrStrOrBytes3 = int | (str | bytes)
IntOrStrOrBytes4 = IntOrStr | bytes
IntOrStrOrBytes5 = int | Union[str, bytes]
IntOrStrOrBytes6 = Union[int, str] | bytes
BytesOrIntOrStr = bytes | IntOrStr
IntOrNone = int | None
NoneOrInt = None | int
@@ -70,6 +72,8 @@ reveal_type(IntOrStrOrBytes1) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes2) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes3) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes4) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes5) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes6) # revealed: types.UnionType
reveal_type(BytesOrIntOrStr) # revealed: types.UnionType
reveal_type(IntOrNone) # revealed: types.UnionType
reveal_type(NoneOrInt) # revealed: types.UnionType
@@ -100,6 +104,8 @@ def _(
int_or_str_or_bytes2: IntOrStrOrBytes2,
int_or_str_or_bytes3: IntOrStrOrBytes3,
int_or_str_or_bytes4: IntOrStrOrBytes4,
int_or_str_or_bytes5: IntOrStrOrBytes5,
int_or_str_or_bytes6: IntOrStrOrBytes6,
bytes_or_int_or_str: BytesOrIntOrStr,
int_or_none: IntOrNone,
none_or_int: NoneOrInt,
@@ -129,6 +135,8 @@ def _(
reveal_type(int_or_str_or_bytes2) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes3) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes4) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes5) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes6) # revealed: int | str | bytes
reveal_type(bytes_or_int_or_str) # revealed: bytes | int | str
reveal_type(int_or_none) # revealed: int | None
reveal_type(none_or_int) # revealed: None | int
@@ -505,13 +513,90 @@ def _(
## `Tuple`
We support implicit type aliases using `typing.Tuple`:
```py
from typing import Tuple
IntAndStr = Tuple[int, str]
SingleInt = Tuple[int]
Ints = Tuple[int, ...]
EmptyTuple = Tuple[()]
def _(int_and_str: IntAndStr):
def _(int_and_str: IntAndStr, single_int: SingleInt, ints: Ints, empty_tuple: EmptyTuple):
reveal_type(int_and_str) # revealed: tuple[int, str]
reveal_type(single_int) # revealed: tuple[int]
reveal_type(ints) # revealed: tuple[int, ...]
reveal_type(empty_tuple) # revealed: tuple[()]
```
Invalid uses cause diagnostics:
```py
from typing import Tuple
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Invalid = Tuple[int, 1]
def _(invalid: Invalid):
reveal_type(invalid) # revealed: tuple[int, Unknown]
```
## `Union`
We support implicit type aliases using `typing.Union`:
```py
from typing import Union
IntOrStr = Union[int, str]
IntOrStrOrBytes = Union[int, Union[str, bytes]]
reveal_type(IntOrStr) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes) # revealed: types.UnionType
def _(
int_or_str: IntOrStr,
int_or_str_or_bytes: IntOrStrOrBytes,
):
reveal_type(int_or_str) # revealed: int | str
reveal_type(int_or_str_or_bytes) # revealed: int | str | bytes
```
If a single type is given, no `types.UnionType` instance is created:
```py
JustInt = Union[int]
reveal_type(JustInt) # revealed: <class 'int'>
def _(just_int: JustInt):
reveal_type(just_int) # revealed: int
```
An empty `typing.Union` leads to a `TypeError` at runtime, so we emit an error. We still infer
`Never` when used as a type expression, which seems reasonable for an empty union:
```py
# error: [invalid-type-form] "`typing.Union` requires at least one type argument"
EmptyUnion = Union[()]
reveal_type(EmptyUnion) # revealed: types.UnionType
def _(empty: EmptyUnion):
reveal_type(empty) # revealed: Never
```
Other invalid uses are also caught:
```py
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Invalid = Union[str, 1]
def _(
invalid: Invalid,
):
reveal_type(invalid) # revealed: str | Unknown
```
## Stringified annotations?
@@ -544,10 +629,19 @@ We *do* support stringified annotations if they appear in a position where a typ
syntactically expected:
```py
ListOfInts = list["int"]
from typing import Union
def _(list_of_ints: ListOfInts):
ListOfInts = list["int"]
StrOrStyle = Union[str, "Style"]
class Style: ...
def _(
list_of_ints: ListOfInts,
str_or_style: StrOrStyle,
):
reveal_type(list_of_ints) # revealed: list[int]
reveal_type(str_or_style) # revealed: str | Style
```
## Recursive

View File

@@ -104,3 +104,27 @@ from typing import Callable
def _(c: Callable[]):
reveal_type(c) # revealed: (...) -> Unknown
```
### `typing.Tuple`
```py
from typing import Tuple
# error: [invalid-syntax] "Expected index or slice expression"
InvalidEmptyTuple = Tuple[]
def _(t: InvalidEmptyTuple):
reveal_type(t) # revealed: tuple[Unknown]
```
### `typing.Union`
```py
from typing import Union
# error: [invalid-syntax] "Expected index or slice expression"
InvalidEmptyUnion = Union[]
def _(u: InvalidEmptyUnion):
reveal_type(u) # revealed: Unknown
```

View File

@@ -58,6 +58,15 @@ d.x = 1
reveal_type(d.x) # revealed: Literal[1]
d.x = unknown()
reveal_type(d.x) # revealed: Unknown
class E:
x: int | None = None
e = E()
if e.x is not None:
class _:
reveal_type(e.x) # revealed: int
```
Narrowing can be "reset" by assigning to the attribute:

View File

@@ -147,6 +147,25 @@ def _(x: int | str | bytes):
reveal_type(x) # revealed: (int & Unknown) | (str & Unknown) | (bytes & Unknown)
```
## `classinfo` is a `typing.py` special form
Certain special forms in `typing.py` are aliases to classes elsewhere in the standard library; these
can be used in `isinstance()` and `issubclass()` checks. We support narrowing using them:
```py
import typing as t
def f(x: dict[str, int] | list[str], y: object):
if isinstance(x, t.Dict):
reveal_type(x) # revealed: dict[str, int]
else:
reveal_type(x) # revealed: list[str]
if isinstance(y, t.Callable):
# TODO: a better top-materialization for `Callable`s (https://github.com/astral-sh/ty/issues/1426)
reveal_type(y) # revealed: () -> object
```
## Class types
```py

View File

@@ -0,0 +1,31 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Invalid key type
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | config: dict[str, int] = {}
2 | config[0] = 3 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal[0]` and a value of type `Literal[3]` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:2:1
|
1 | config: dict[str, int] = {}
2 | config[0] = 3 # error: [invalid-assignment]
| ^^^^^^
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,36 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Invalid key type for `TypedDict`
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Config(TypedDict):
4 | retries: int
5 |
6 | def _(config: Config) -> None:
7 | config[0] = 3 # error: [invalid-key]
```
# Diagnostics
```
error[invalid-key]: Cannot access `Config` with a key of type `Literal[0]`. Only string literals are allowed as keys on TypedDicts.
--> src/mdtest_snippet.py:7:12
|
6 | def _(config: Config) -> None:
7 | config[0] = 3 # error: [invalid-key]
| ^
|
info: rule `invalid-key` is enabled by default
```

View File

@@ -0,0 +1,31 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Invalid value type
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | config: dict[str, int] = {}
2 | config["retries"] = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `Literal["three"]` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:2:1
|
1 | config: dict[str, int] = {}
2 | config["retries"] = "three" # error: [invalid-assignment]
| ^^^^^^
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,48 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Invalid value type for `TypedDict`
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Config(TypedDict):
4 | retries: int
5 |
6 | def _(config: Config) -> None:
7 | config["retries"] = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Invalid assignment to key "retries" with declared type `int` on TypedDict `Config`
--> src/mdtest_snippet.py:7:5
|
6 | def _(config: Config) -> None:
7 | config["retries"] = "three" # error: [invalid-assignment]
| ------ --------- ^^^^^^^ value of type `Literal["three"]`
| | |
| | key has declared type `int`
| TypedDict `Config`
|
info: Item declaration
--> src/mdtest_snippet.py:4:5
|
3 | class Config(TypedDict):
4 | retries: int
| ------------ Item declared here
5 |
6 | def _(config: Config) -> None:
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,38 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Misspelled key for `TypedDict`
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Config(TypedDict):
4 | retries: int
5 |
6 | def _(config: Config) -> None:
7 | config["Retries"] = 30.0 # error: [invalid-key]
```
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Config`
--> src/mdtest_snippet.py:7:5
|
6 | def _(config: Config) -> None:
7 | config["Retries"] = 30.0 # error: [invalid-key]
| ------ ^^^^^^^^^ Unknown key "Retries" - did you mean "retries"?
| |
| TypedDict `Config`
|
info: rule `invalid-key` is enabled by default
```

View File

@@ -0,0 +1,35 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - No `__setitem__` method
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | class ReadOnlyDict:
2 | def __getitem__(self, key: str) -> int:
3 | return 42
4 |
5 | config = ReadOnlyDict()
6 | config["retries"] = 3 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Cannot assign to a subscript on an object of type `ReadOnlyDict` with no `__setitem__` method
--> src/mdtest_snippet.py:6:1
|
5 | config = ReadOnlyDict()
6 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,32 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Possibly missing `__setitem__` method
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | def _(config: dict[str, int] | None) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Cannot assign to a subscript on an object of type `None` with no `__setitem__` method
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | None) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
|
info: The full type of the subscripted object is `dict[str, int] | None`
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,60 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Unknown key for all elemens of a union
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Person(TypedDict):
4 | name: str
5 |
6 | class Animal(TypedDict):
7 | name: str
8 | legs: int
9 |
10 | def _(being: Person | Animal) -> None:
11 | # error: [invalid-key]
12 | # error: [invalid-key]
13 | being["surname"] = "unknown"
```
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:13:5
|
11 | # error: [invalid-key]
12 | # error: [invalid-key]
13 | being["surname"] = "unknown"
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
| |
| TypedDict `Person` in union type `Person | Animal`
|
info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Animal`
--> src/mdtest_snippet.py:13:5
|
11 | # error: [invalid-key]
12 | # error: [invalid-key]
13 | being["surname"] = "unknown"
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
| |
| TypedDict `Animal` in union type `Person | Animal`
|
info: rule `invalid-key` is enabled by default
```

View File

@@ -0,0 +1,42 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Unknown key for one element of a union
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import TypedDict
2 |
3 | class Person(TypedDict):
4 | name: str
5 |
6 | class Animal(TypedDict):
7 | name: str
8 | legs: int
9 |
10 | def _(being: Person | Animal) -> None:
11 | being["legs"] = 4 # error: [invalid-key]
```
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:11:5
|
10 | def _(being: Person | Animal) -> None:
11 | being["legs"] = 4 # error: [invalid-key]
| ----- ^^^^^^ Unknown key "legs"
| |
| TypedDict `Person` in union type `Person | Animal`
|
info: rule `invalid-key` is enabled by default
```

View File

@@ -0,0 +1,32 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Wrong value type for one element of a union
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | def _(config: dict[str, int] | dict[str, str]) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, str].__setitem__(key: str, value: str, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `Literal[3]` on object of type `dict[str, str]`
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | dict[str, str]) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,49 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: assignment_diagnostics.md - Subscript assignment diagnostics - Wrong value type for all elements of a union
mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_diagnostics.md
---
# Python source files
## mdtest_snippet.py
```
1 | def _(config: dict[str, int] | dict[str, str]) -> None:
2 | # error: [invalid-assignment]
3 | # error: [invalid-assignment]
4 | config["retries"] = 3.0
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `float` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:4:5
|
2 | # error: [invalid-assignment]
3 | # error: [invalid-assignment]
4 | config["retries"] = 3.0
| ^^^^^^
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default
```
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, str].__setitem__(key: str, value: str, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `float` on object of type `dict[str, str]`
--> src/mdtest_snippet.py:4:5
|
2 | # error: [invalid-assignment]
3 | # error: [invalid-assignment]
4 | config["retries"] = 3.0
| ^^^^^^
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -89,7 +89,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Person` of type `str`
error[invalid-key]: Invalid key of type `str` for TypedDict `Person`
--> src/mdtest_snippet.py:16:12
|
15 | def access_with_str_key(person: Person, str_key: str):

View File

@@ -0,0 +1,121 @@
# Subscript assignment diagnostics
<!-- snapshot-diagnostics -->
## Invalid value type
```py
config: dict[str, int] = {}
config["retries"] = "three" # error: [invalid-assignment]
```
## Invalid key type
```py
config: dict[str, int] = {}
config[0] = 3 # error: [invalid-assignment]
```
## Invalid value type for `TypedDict`
```py
from typing import TypedDict
class Config(TypedDict):
retries: int
def _(config: Config) -> None:
config["retries"] = "three" # error: [invalid-assignment]
```
## Invalid key type for `TypedDict`
```py
from typing import TypedDict
class Config(TypedDict):
retries: int
def _(config: Config) -> None:
config[0] = 3 # error: [invalid-key]
```
## Misspelled key for `TypedDict`
```py
from typing import TypedDict
class Config(TypedDict):
retries: int
def _(config: Config) -> None:
config["Retries"] = 30.0 # error: [invalid-key]
```
## No `__setitem__` method
```py
class ReadOnlyDict:
def __getitem__(self, key: str) -> int:
return 42
config = ReadOnlyDict()
config["retries"] = 3 # error: [invalid-assignment]
```
## Possibly missing `__setitem__` method
```py
def _(config: dict[str, int] | None) -> None:
config["retries"] = 3 # error: [invalid-assignment]
```
## Unknown key for one element of a union
```py
from typing import TypedDict
class Person(TypedDict):
name: str
class Animal(TypedDict):
name: str
legs: int
def _(being: Person | Animal) -> None:
being["legs"] = 4 # error: [invalid-key]
```
## Unknown key for all elemens of a union
```py
from typing import TypedDict
class Person(TypedDict):
name: str
class Animal(TypedDict):
name: str
legs: int
def _(being: Person | Animal) -> None:
# error: [invalid-key]
# error: [invalid-key]
being["surname"] = "unknown"
```
## Wrong value type for one element of a union
```py
def _(config: dict[str, int] | dict[str, str]) -> None:
config["retries"] = 3 # error: [invalid-assignment]
```
## Wrong value type for all elements of a union
```py
def _(config: dict[str, int] | dict[str, str]) -> None:
# error: [invalid-assignment]
# error: [invalid-assignment]
config["retries"] = 3.0
```

View File

@@ -76,7 +76,7 @@ a[0] = 0
class NoSetitem: ...
a = NoSetitem()
a[0] = 0 # error: "Cannot assign to object of type `NoSetitem` with no `__setitem__` method"
a[0] = 0 # error: "Cannot assign to a subscript on an object of type `NoSetitem` with no `__setitem__` method"
```
## `__setitem__` not callable

View File

@@ -88,8 +88,6 @@ class C:
self.FINAL_C: Final[int] = 1
self.FINAL_D: Final = 1
self.FINAL_E: Final
# TODO: Should not be an error
# error: [invalid-assignment] "Cannot assign to final attribute `FINAL_E` on type `Self@__init__`"
self.FINAL_E = 1
reveal_type(C.FINAL_A) # revealed: int
@@ -186,7 +184,6 @@ class C(metaclass=Meta):
self.INSTANCE_FINAL_A: Final[int] = 1
self.INSTANCE_FINAL_B: Final = 1
self.INSTANCE_FINAL_C: Final[int]
# error: [invalid-assignment] "Cannot assign to final attribute `INSTANCE_FINAL_C` on type `Self@__init__`"
self.INSTANCE_FINAL_C = 1
# error: [invalid-assignment] "Cannot assign to final attribute `META_FINAL_A` on type `<class 'C'>`"
@@ -282,8 +279,6 @@ class C:
def __init__(self):
self.LEGAL_H: Final[int] = 1
self.LEGAL_I: Final[int]
# TODO: Should not be an error
# error: [invalid-assignment]
self.LEGAL_I = 1
# error: [invalid-type-form] "`Final` is not allowed in function parameter annotations"
@@ -392,15 +387,142 @@ class C:
# TODO: This should be an error
NO_ASSIGNMENT_B: Final[int]
# This is okay. `DEFINED_IN_INIT` is defined in `__init__`.
DEFINED_IN_INIT: Final[int]
def __init__(self):
# TODO: should not be an error
# error: [invalid-assignment]
self.DEFINED_IN_INIT = 1
```
## Final attributes with Self annotation in `__init__`
Issue #1409: Final instance attributes should be assignable in `__init__` even when using `Self`
type annotation.
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Final, Self
class ClassA:
ID4: Final[int] # OK because initialized in __init__
def __init__(self: Self):
self.ID4 = 1 # Should be OK
def other_method(self: Self):
# error: [invalid-assignment] "Cannot assign to final attribute `ID4` on type `Self@other_method`"
self.ID4 = 2 # Should still error outside __init__
class ClassB:
ID5: Final[int]
def __init__(self): # Without Self annotation
self.ID5 = 1 # Should also be OK
reveal_type(ClassA().ID4) # revealed: int
reveal_type(ClassB().ID5) # revealed: int
```
## Reassignment to Final in `__init__`
Per PEP 591 and the typing conformance suite, Final attributes can be assigned in `__init__`.
Multiple assignments within `__init__` are allowed (matching mypy and pyright behavior). However,
assignment in `__init__` is not allowed if the attribute already has a value at class level.
```py
from typing import Final
# Case 1: Declared in class, assigned once in __init__ - ALLOWED
class DeclaredAssignedInInit:
attr1: Final[int]
def __init__(self):
self.attr1 = 1 # OK: First and only assignment
# Case 2: Declared and assigned in class body - ALLOWED (no __init__ assignment)
class DeclaredAndAssignedInClass:
attr2: Final[int] = 10
# Case 3: Reassignment when already assigned in class body
class ReassignmentFromClass:
attr3: Final[int] = 10
def __init__(self):
# error: [invalid-assignment]
self.attr3 = 20 # Error: already assigned in class body
# Case 4: Multiple assignments within __init__ itself
# Per conformance suite and PEP 591, all assignments in __init__ are allowed
class MultipleAssignmentsInInit:
attr4: Final[int]
def __init__(self):
self.attr4 = 1 # OK: Assignment in __init__
self.attr4 = 2 # OK: Multiple assignments in __init__ are allowed
class ConditionalAssignment:
X: Final[int]
def __init__(self, cond: bool):
if cond:
self.X = 42 # OK: Assignment in __init__
else:
self.X = 56 # OK: Multiple assignments in __init__ are allowed
# Case 5: Declaration and assignment in __init__ - ALLOWED
class DeclareAndAssignInInit:
def __init__(self):
self.attr5: Final[int] = 1 # OK: Declare and assign in __init__
# Case 6: Assignment outside __init__ should still fail
class AssignmentOutsideInit:
attr6: Final[int]
def other_method(self):
# error: [invalid-assignment] "Cannot assign to final attribute `attr6`"
self.attr6 = 1 # Error: Not in __init__
```
## Final assignment restrictions in `__init__`
`__init__` can only assign Final attributes on the class it's defining, and only to the first
parameter (`self`).
```py
from typing import Final
class C:
x: Final[int] = 100
# Assignment from standalone function (even named __init__)
def _(c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not in C.__init__
def __init__(c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not a method
# Assignment from another class's __init__
class A:
def __init__(self, c: C):
# error: [invalid-assignment] "Cannot assign to final attribute `x`"
c.x = 1 # Error: Not C's __init__
# Assignment to non-self parameter in __init__
class D:
y: Final[int]
def __init__(self, other: "D"):
self.y = 1 # OK: Assigning to self
# TODO: Should error - assigning to non-self parameter
# Requires tracking which parameter the base expression refers to
other.y = 2
```
## Full diagnostics
<!-- snapshot-diagnostics -->

View File

@@ -69,7 +69,7 @@ def name_or_age() -> Literal["name", "age"]:
carol: Person = {NAME: "Carol", AGE: 20}
reveal_type(carol[NAME]) # revealed: str
# error: [invalid-key] "Invalid key for TypedDict `Person` of type `str`"
# error: [invalid-key] "Invalid key of type `str` for TypedDict `Person`"
reveal_type(carol[non_literal()]) # revealed: Unknown
reveal_type(carol[name_or_age()]) # revealed: str | int | None
@@ -526,10 +526,20 @@ class Person(TypedDict):
name: str
age: int | None
class Animal(TypedDict):
name: str
NAME_FINAL: Final = "name"
AGE_FINAL: Final[Literal["age"]] = "age"
def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age", "name"], str_key: str, unknown_key: Any) -> None:
def _(
person: Person,
being: Person | Animal,
literal_key: Literal["age"],
union_of_keys: Literal["age", "name"],
str_key: str,
unknown_key: Any,
) -> None:
reveal_type(person["name"]) # revealed: str
reveal_type(person["age"]) # revealed: int | None
@@ -543,23 +553,35 @@ def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age",
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing""
reveal_type(person["non_existing"]) # revealed: Unknown
# error: [invalid-key] "Invalid key for TypedDict `Person` of type `str`"
# error: [invalid-key] "Invalid key of type `str` for TypedDict `Person`"
reveal_type(person[str_key]) # revealed: Unknown
# No error here:
reveal_type(person[unknown_key]) # revealed: Unknown
reveal_type(being["name"]) # revealed: str
# TODO: A type of `int | None | Unknown` might be better here. The `str` is mixed in
# because `Animal.__getitem__` can only return `str`.
# error: [invalid-key] "Invalid key for TypedDict `Animal`"
reveal_type(being["age"]) # revealed: int | None | str
```
### Writing
```py
from typing_extensions import TypedDict, Final, Literal, LiteralString, Any
from ty_extensions import Intersection
class Person(TypedDict):
name: str
surname: str
age: int | None
class Animal(TypedDict):
name: str
legs: int
NAME_FINAL: Final = "name"
AGE_FINAL: Final[Literal["age"]] = "age"
@@ -580,13 +602,32 @@ def _(person: Person, literal_key: Literal["age"]):
def _(person: Person, union_of_keys: Literal["name", "surname"]):
person[union_of_keys] = "unknown"
# error: [invalid-assignment] "Cannot assign value of type `Literal[1]` to key of type `Literal["name", "surname"]` on TypedDict `Person`"
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `Literal[1]`"
# error: [invalid-assignment] "Invalid assignment to key "surname" with declared type `str` on TypedDict `Person`: value of type `Literal[1]`"
person[union_of_keys] = 1
def _(being: Person | Animal):
being["name"] = "Being"
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `Literal[1]`"
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Animal`: value of type `Literal[1]`"
being["name"] = 1
# error: [invalid-key] "Invalid key for TypedDict `Animal`: Unknown key "surname" - did you mean "name"?"
being["surname"] = "unknown"
def _(centaur: Intersection[Person, Animal]):
centaur["name"] = "Chiron"
centaur["age"] = 100
centaur["legs"] = 4
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "unknown""
centaur["unknown"] = "value"
def _(person: Person, union_of_keys: Literal["name", "age"], unknown_value: Any):
person[union_of_keys] = unknown_value
# error: [invalid-assignment] "Cannot assign value of type `None` to key of type `Literal["name", "age"]` on TypedDict `Person`"
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
person[union_of_keys] = None
def _(person: Person, str_key: str, literalstr_key: LiteralString):

View File

@@ -85,6 +85,7 @@ where
///
/// This method may panic or produce unspecified results if the provided module is from a
/// different file or Salsa revision than the module to which the node belongs.
#[track_caller]
pub fn node<'ast>(&self, module_ref: &'ast ParsedModuleRef) -> &'ast T {
#[cfg(debug_assertions)]
assert_eq!(module_ref.module().addr(), self.module_addr);

View File

@@ -452,15 +452,12 @@ pub(crate) fn dynamic_resolution_paths<'db>(
let site_packages_dir = site_packages_search_path
.as_system_path()
.expect("Expected site package path to be a system path");
let site_packages_dir = system
.canonicalize_path(site_packages_dir)
.unwrap_or_else(|_| site_packages_dir.to_path_buf());
if !existing_paths.insert(Cow::Owned(site_packages_dir.clone())) {
if !existing_paths.insert(Cow::Borrowed(site_packages_dir)) {
continue;
}
let site_packages_root = files.expect_root(db, &site_packages_dir);
let site_packages_root = files.expect_root(db, site_packages_dir);
// This query needs to be re-executed each time a `.pth` file
// is added, modified or removed from the `site-packages` directory.
@@ -477,7 +474,7 @@ pub(crate) fn dynamic_resolution_paths<'db>(
// containing a (relative or absolute) path.
// Each of these paths may point to an editable install of a package,
// so should be considered an additional search path.
let pth_file_iterator = match PthFileIterator::new(db, &site_packages_dir) {
let pth_file_iterator = match PthFileIterator::new(db, site_packages_dir) {
Ok(iterator) => iterator,
Err(error) => {
tracing::warn!(

View File

@@ -1,4 +1,4 @@
use std::iter::FusedIterator;
use std::iter::{FusedIterator, once};
use std::sync::Arc;
use ruff_db::files::File;
@@ -148,29 +148,56 @@ pub(crate) fn attribute_declarations<'db, 's>(
///
/// Only call this when doing type inference on the same file as `class_body_scope`, otherwise it
/// introduces a direct dependency on that file's AST.
pub(crate) fn attribute_scopes<'db, 's>(
pub(crate) fn attribute_scopes<'db>(
db: &'db dyn Db,
class_body_scope: ScopeId<'db>,
) -> impl Iterator<Item = FileScopeId> + use<'s, 'db> {
) -> impl Iterator<Item = FileScopeId> + 'db {
let file = class_body_scope.file(db);
let index = semantic_index(db, file);
let class_scope_id = class_body_scope.file_scope_id(db);
ChildrenIter::new(&index.scopes, class_scope_id)
.filter_map(move |(child_scope_id, scope)| {
let (function_scope_id, function_scope) =
if scope.node().scope_kind() == ScopeKind::TypeParams {
// This could be a generic method with a type-params scope.
// Go one level deeper to find the function scope. The first
// descendant is the (potential) function scope.
let function_scope_id = scope.descendants().start;
(function_scope_id, index.scope(function_scope_id))
} else {
(child_scope_id, scope)
};
function_scope.node().as_function()?;
Some(function_scope_id)
})
.flat_map(move |func_id| {
// Add any descendent scope that is eager and have eager scopes between the scope
// and the method scope. Since attributes can be defined in this scope.
let nested = index.descendent_scopes(func_id).filter_map(move |(id, s)| {
let is_eager = s.kind().is_eager();
let parents_are_eager = {
let mut all_parents_eager = true;
let mut current = Some(id);
ChildrenIter::new(&index.scopes, class_scope_id).filter_map(move |(child_scope_id, scope)| {
let (function_scope_id, function_scope) =
if scope.node().scope_kind() == ScopeKind::TypeParams {
// This could be a generic method with a type-params scope.
// Go one level deeper to find the function scope. The first
// descendant is the (potential) function scope.
let function_scope_id = scope.descendants().start;
(function_scope_id, index.scope(function_scope_id))
} else {
(child_scope_id, scope)
};
while let Some(scope_id) = current {
if scope_id == func_id {
break;
}
let scope = index.scope(scope_id);
if !scope.is_eager() {
all_parents_eager = false;
break;
}
current = scope.parent();
}
function_scope.node().as_function()?;
Some(function_scope_id)
})
all_parents_eager
};
(parents_are_eager && is_eager).then_some(id)
});
once(func_id).chain(nested)
})
}
/// Returns the module global scope of `file`.

View File

@@ -186,29 +186,34 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
self.current_scope_info().file_scope_id
}
/// Returns the scope ID of the surrounding class body scope if the current scope
/// is a method inside a class body. Returns `None` otherwise, e.g. if the current
/// scope is a function body outside of a class, or if the current scope is not a
/// Returns the scope ID of the current scope if the current scope
/// is a method inside a class body or an eagerly executed scope inside a method.
/// Returns `None` otherwise, e.g. if the current scope is a function body outside of a class, or if the current scope is not a
/// function body.
fn is_method_of_class(&self) -> Option<FileScopeId> {
let mut scopes_rev = self.scope_stack.iter().rev();
fn is_method_or_eagerly_executed_in_method(&self) -> Option<FileScopeId> {
let mut scopes_rev = self
.scope_stack
.iter()
.rev()
.skip_while(|scope| self.scopes[scope.file_scope_id].is_eager());
let current = scopes_rev.next()?;
if self.scopes[current.file_scope_id].kind() != ScopeKind::Function {
return None;
}
let maybe_method = current.file_scope_id;
let parent = scopes_rev.next()?;
match self.scopes[parent.file_scope_id].kind() {
ScopeKind::Class => Some(parent.file_scope_id),
ScopeKind::Class => Some(maybe_method),
ScopeKind::TypeParams => {
// If the function is generic, the parent scope is an annotation scope.
// In this case, we need to go up one level higher to find the class scope.
let grandparent = scopes_rev.next()?;
if self.scopes[grandparent.file_scope_id].kind() == ScopeKind::Class {
Some(grandparent.file_scope_id)
Some(maybe_method)
} else {
None
}
@@ -217,6 +222,32 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
}
}
/// Checks if a symbol name is bound in any intermediate eager scopes
/// between the current scope and the specified method scope.
///
fn is_symbol_bound_in_intermediate_eager_scopes(
&self,
symbol_name: &str,
method_scope_id: FileScopeId,
) -> bool {
for scope_info in self.scope_stack.iter().rev() {
let scope_id = scope_info.file_scope_id;
if scope_id == method_scope_id {
break;
}
if let Some(symbol_id) = self.place_tables[scope_id].symbol_id(symbol_name) {
let symbol = self.place_tables[scope_id].symbol(symbol_id);
if symbol.is_bound() {
return true;
}
}
}
false
}
/// Push a new loop, returning the outer loop, if any.
fn push_loop(&mut self) -> Option<Loop> {
self.current_scope_info_mut()
@@ -283,6 +314,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
// Records snapshots of the place states visible from the current eager scope.
fn record_eager_snapshots(&mut self, popped_scope_id: FileScopeId) {
let popped_scope = &self.scopes[popped_scope_id];
let popped_scope_is_annotation_scope = popped_scope.kind().is_annotation();
// If the scope that we just popped off is an eager scope, we need to "lock" our view of
// which bindings reach each of the uses in the scope. Loop through each enclosing scope,
// looking for any that bind each place.
@@ -297,6 +331,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
// ```
for enclosing_scope_info in self.scope_stack.iter().rev() {
let enclosing_scope_id = enclosing_scope_info.file_scope_id;
let is_immediately_enclosing_scope = popped_scope.parent() == Some(enclosing_scope_id);
let enclosing_scope_kind = self.scopes[enclosing_scope_id].kind();
let enclosing_place_table = &self.place_tables[enclosing_scope_id];
@@ -324,6 +359,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
enclosing_place_id,
enclosing_scope_kind,
enclosing_place,
popped_scope_is_annotation_scope && is_immediately_enclosing_scope,
);
self.enclosing_snapshots.insert(key, eager_snapshot);
}
@@ -398,6 +434,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
enclosed_symbol_id.into(),
enclosing_scope_kind,
enclosing_place.into(),
false,
);
self.enclosing_snapshots.insert(key, lazy_snapshot);
}
@@ -1700,7 +1737,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
self.visit_expr(&node.annotation);
if let Some(value) = &node.value {
self.visit_expr(value);
if self.is_method_of_class().is_some() {
if self.is_method_or_eagerly_executed_in_method().is_some() {
// Record the right-hand side of the assignment as a standalone expression
// if we're inside a method. This allows type inference to infer the type
// of the value for annotated assignments like `self.CONSTANT: Final = 1`,
@@ -2372,14 +2409,21 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
| ast::Expr::Attribute(ast::ExprAttribute { ctx, .. })
| ast::Expr::Subscript(ast::ExprSubscript { ctx, .. }) => {
if let Some(mut place_expr) = PlaceExpr::try_from_expr(expr) {
if self.is_method_of_class().is_some() {
if let Some(method_scope_id) = self.is_method_or_eagerly_executed_in_method() {
if let PlaceExpr::Member(member) = &mut place_expr {
if member.is_instance_attribute_candidate() {
// We specifically mark attribute assignments to the first parameter of a method,
// i.e. typically `self` or `cls`.
let accessed_object_refers_to_first_parameter = self
.current_first_parameter_name
.is_some_and(|first| member.symbol_name() == first);
// However, we must check that the symbol hasn't been shadowed by an intermediate
// scope (e.g., a comprehension variable: `for self in [...]`).
let accessed_object_refers_to_first_parameter =
self.current_first_parameter_name.is_some_and(|first| {
member.symbol_name() == first
&& !self.is_symbol_bound_in_intermediate_eager_scopes(
first,
method_scope_id,
)
});
if accessed_object_refers_to_first_parameter {
member.mark_instance_attribute();

View File

@@ -1034,7 +1034,7 @@ impl<'db> AssignmentDefinitionKind<'db> {
self.target_kind
}
pub(crate) fn value<'ast>(&self, module: &'ast ParsedModuleRef) -> &'ast ast::Expr {
pub fn value<'ast>(&self, module: &'ast ParsedModuleRef) -> &'ast ast::Expr {
self.value.node(module)
}

View File

@@ -761,6 +761,7 @@ pub(crate) struct DeclarationsIterator<'map, 'db> {
inner: LiveDeclarationsIterator<'map>,
}
#[derive(Debug)]
pub(crate) struct DeclarationWithConstraint<'db> {
pub(crate) declaration: DefinitionState<'db>,
pub(crate) reachability_constraint: ScopedReachabilityConstraintId,
@@ -1186,17 +1187,21 @@ impl<'db> UseDefMapBuilder<'db> {
pub(super) fn snapshot_enclosing_state(
&mut self,
enclosing_place: ScopedPlaceId,
scope: ScopeKind,
enclosing_scope: ScopeKind,
enclosing_place_expr: PlaceExprRef,
is_parent_of_annotation_scope: bool,
) -> ScopedEnclosingSnapshotId {
let bindings = match enclosing_place {
ScopedPlaceId::Symbol(symbol) => self.symbol_states[symbol].bindings(),
ScopedPlaceId::Member(member) => self.member_states[member].bindings(),
};
// Names bound in class scopes are never visible to nested scopes (but attributes/subscripts are visible),
// so we never need to save eager scope bindings in a class scope.
if (scope.is_class() && enclosing_place.is_symbol()) || !enclosing_place_expr.is_bound() {
let is_class_symbol = enclosing_scope.is_class() && enclosing_place.is_symbol();
// Names bound in class scopes are never visible to nested scopes (but
// attributes/subscripts are visible), so we never need to save eager scope bindings in a
// class scope. There is one exception to this rule: annotation scopes can see names
// defined in an immediately-enclosing class scope.
if (is_class_symbol && !is_parent_of_annotation_scope) || !enclosing_place_expr.is_bound() {
self.enclosing_snapshots.push(EnclosingSnapshot::Constraint(
bindings.unbound_narrowing_constraint(),
))

View File

@@ -869,7 +869,7 @@ impl<'db> Type<'db> {
matches!(self, Type::Dynamic(DynamicType::Todo(_)))
}
pub(crate) const fn is_generic_alias(&self) -> bool {
pub const fn is_generic_alias(&self) -> bool {
matches!(self, Type::GenericAlias(_))
}
@@ -1080,12 +1080,11 @@ impl<'db> Type<'db> {
.expect("Expected a Type::ClassLiteral variant")
}
pub(crate) const fn is_subclass_of(&self) -> bool {
pub const fn is_subclass_of(&self) -> bool {
matches!(self, Type::SubclassOf(..))
}
#[cfg(test)]
pub(crate) const fn is_class_literal(&self) -> bool {
pub const fn is_class_literal(&self) -> bool {
matches!(self, Type::ClassLiteral(..))
}
@@ -1164,6 +1163,10 @@ impl<'db> Type<'db> {
}
}
pub(crate) const fn is_union(&self) -> bool {
matches!(self, Type::Union(_))
}
pub(crate) const fn as_union(self) -> Option<UnionType<'db>> {
match self {
Type::Union(union_type) => Some(union_type),
@@ -1171,7 +1174,6 @@ impl<'db> Type<'db> {
}
}
#[cfg(test)]
#[track_caller]
pub(crate) const fn expect_union(self) -> UnionType<'db> {
self.as_union().expect("Expected a Type::Union variant")
@@ -6587,12 +6589,13 @@ impl<'db> Type<'db> {
}),
KnownInstanceType::UnionType(list) => {
let mut builder = UnionBuilder::new(db);
let inferred_as = list.inferred_as(db);
for element in list.elements(db) {
builder = builder.add(element.in_type_expression(
db,
scope_id,
typevar_binding_context,
)?);
builder = builder.add(if inferred_as.type_expression() {
*element
} else {
element.in_type_expression(db, scope_id, typevar_binding_context)?
});
}
Ok(builder.build())
}
@@ -8585,7 +8588,7 @@ impl<'db> TypeVarInstance<'db> {
self.identity(db).definition(db)
}
pub(crate) fn kind(self, db: &'db dyn Db) -> TypeVarKind {
pub fn kind(self, db: &'db dyn Db) -> TypeVarKind {
self.identity(db).kind(db)
}
@@ -9165,6 +9168,21 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
}
}
/// Whether a given type originates from value expression inference or type expression inference.
/// For example, the symbol `int` would be inferred as `<class 'int'>` in value expression context,
/// and as `int` (i.e. an instance of the class `int`) in type expression context.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)]
pub enum InferredAs {
ValueExpression,
TypeExpression,
}
impl InferredAs {
pub const fn type_expression(self) -> bool {
matches!(self, InferredAs::TypeExpression)
}
}
/// A salsa-interned list of types.
///
/// # Ordering
@@ -9175,6 +9193,7 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
pub struct InternedTypes<'db> {
#[returns(deref)]
elements: Box<[Type<'db>]>,
inferred_as: InferredAs,
}
impl get_size2::GetSize for InternedTypes<'_> {}
@@ -9183,8 +9202,9 @@ impl<'db> InternedTypes<'db> {
pub(crate) fn from_elements(
db: &'db dyn Db,
elements: impl IntoIterator<Item = Type<'db>>,
inferred_as: InferredAs,
) -> InternedTypes<'db> {
InternedTypes::new(db, elements.into_iter().collect::<Box<[_]>>())
InternedTypes::new(db, elements.into_iter().collect::<Box<[_]>>(), inferred_as)
}
pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
@@ -9194,6 +9214,7 @@ impl<'db> InternedTypes<'db> {
.iter()
.map(|ty| ty.normalized_impl(db, visitor))
.collect::<Box<[_]>>(),
self.inferred_as(db),
)
}
}

View File

@@ -66,6 +66,39 @@ impl<'a, 'db> CallArguments<'a, 'db> {
.collect()
}
/// Like [`Self::from_arguments`] but fills as much typing info in as possible.
///
/// This currently only exists for the LSP usecase, and shouldn't be used in normal
/// typechecking.
pub(crate) fn from_arguments_typed(
arguments: &'a ast::Arguments,
mut infer_argument_type: impl FnMut(Option<&ast::Expr>, &ast::Expr) -> Type<'db>,
) -> Self {
arguments
.arguments_source_order()
.map(|arg_or_keyword| match arg_or_keyword {
ast::ArgOrKeyword::Arg(arg) => match arg {
ast::Expr::Starred(ast::ExprStarred { value, .. }) => {
let ty = infer_argument_type(Some(arg), value);
(Argument::Variadic, Some(ty))
}
_ => {
let ty = infer_argument_type(None, arg);
(Argument::Positional, Some(ty))
}
},
ast::ArgOrKeyword::Keyword(ast::Keyword { arg, value, .. }) => {
let ty = infer_argument_type(None, value);
if let Some(arg) = arg {
(Argument::Keyword(&arg.id), Some(ty))
} else {
(Argument::Keywords, Some(ty))
}
}
})
.collect()
}
/// Create a [`CallArguments`] with no arguments.
pub(crate) fn none() -> Self {
Self::default()

View File

@@ -3119,30 +3119,47 @@ impl<'db> ClassLiteral<'db> {
union_of_inferred_types = union_of_inferred_types.add(Type::unknown());
}
for (attribute_assignments, method_scope_id) in
for (attribute_assignments, attribute_binding_scope_id) in
attribute_assignments(db, class_body_scope, &name)
{
let method_scope = index.scope(method_scope_id);
if !is_valid_scope(method_scope) {
let binding_scope = index.scope(attribute_binding_scope_id);
if !is_valid_scope(binding_scope) {
continue;
}
// The attribute assignment inherits the reachability of the method which contains it
let is_method_reachable = if let Some(method_def) = method_scope.node().as_function() {
let method = index.expect_single_definition(method_def);
let method_place = class_table
.symbol_id(&method_def.node(&module).name)
.unwrap();
class_map
.all_reachable_symbol_bindings(method_place)
.find_map(|bind| {
(bind.binding.is_defined_and(|def| def == method))
.then(|| class_map.binding_reachability(db, &bind))
})
.unwrap_or(Truthiness::AlwaysFalse)
} else {
Truthiness::AlwaysFalse
let scope_for_reachability_analysis = {
if binding_scope.node().as_function().is_some() {
binding_scope
} else if binding_scope.is_eager() {
let mut eager_scope_parent = binding_scope;
while eager_scope_parent.is_eager()
&& let Some(parent) = eager_scope_parent.parent()
{
eager_scope_parent = index.scope(parent);
}
eager_scope_parent
} else {
binding_scope
}
};
// The attribute assignment inherits the reachability of the method which contains it
let is_method_reachable =
if let Some(method_def) = scope_for_reachability_analysis.node().as_function() {
let method = index.expect_single_definition(method_def);
let method_place = class_table
.symbol_id(&method_def.node(&module).name)
.unwrap();
class_map
.all_reachable_symbol_bindings(method_place)
.find_map(|bind| {
(bind.binding.is_defined_and(|def| def == method))
.then(|| class_map.binding_reachability(db, &bind))
})
.unwrap_or(Truthiness::AlwaysFalse)
} else {
Truthiness::AlwaysFalse
};
if is_method_reachable.is_always_false() {
continue;
}

View File

@@ -3063,6 +3063,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
typed_dict_node: AnyNodeRef,
key_node: AnyNodeRef,
typed_dict_ty: Type<'db>,
full_object_ty: Option<Type<'db>>,
key_ty: Type<'db>,
items: &FxOrderMap<Name, Field<'db>>,
) {
@@ -3077,11 +3078,21 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
"Invalid key for TypedDict `{typed_dict_name}`",
));
diagnostic.annotate(
diagnostic.annotate(if let Some(full_object_ty) = full_object_ty {
context.secondary(typed_dict_node).message(format_args!(
"TypedDict `{typed_dict_name}` in {kind} type `{full_object_ty}`",
kind = if full_object_ty.is_union() {
"union"
} else {
"intersection"
},
full_object_ty = full_object_ty.display(db)
))
} else {
context
.secondary(typed_dict_node)
.message(format_args!("TypedDict `{typed_dict_name}`")),
);
.message(format_args!("TypedDict `{typed_dict_name}`"))
});
let existing_keys = items.iter().map(|(name, _)| name.as_str());
@@ -3093,15 +3104,22 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
String::new()
}
));
diagnostic
}
_ => builder.into_diagnostic(format_args!(
"Invalid key for TypedDict `{}` of type `{}`",
typed_dict_ty.display(db),
key_ty.display(db),
)),
};
_ => {
let mut diagnostic = builder.into_diagnostic(format_args!(
"Invalid key of type `{}` for TypedDict `{}`",
key_ty.display(db),
typed_dict_ty.display(db),
));
if let Some(full_object_ty) = full_object_ty {
diagnostic.info(format_args!(
"The full type of the subscripted object is `{}`",
full_object_ty.display(db)
));
}
}
}
}
}

View File

@@ -16,6 +16,7 @@ use rustc_hash::{FxHashMap, FxHashSet};
use crate::Db;
use crate::module_resolver::file_to_module;
use crate::semantic_index::definition::Definition;
use crate::semantic_index::{scope::ScopeKind, semantic_index};
use crate::types::class::{ClassLiteral, ClassType, GenericAlias};
use crate::types::function::{FunctionType, OverloadLiteral};
@@ -40,6 +41,9 @@ pub struct DisplaySettings<'db> {
pub qualified: Rc<FxHashMap<&'db str, QualificationLevel>>,
/// Whether long unions and literals are displayed in full
pub preserve_full_unions: bool,
/// Disallow Signature printing to introduce a name
/// (presumably because we rendered one already)
pub disallow_signature_name: bool,
}
impl<'db> DisplaySettings<'db> {
@@ -59,6 +63,14 @@ impl<'db> DisplaySettings<'db> {
}
}
#[must_use]
pub fn disallow_signature_name(&self) -> Self {
Self {
disallow_signature_name: true,
..self.clone()
}
}
#[must_use]
pub fn truncate_long_unions(self) -> Self {
Self {
@@ -473,7 +485,7 @@ impl Display for DisplayRepresentation<'_> {
type_parameters = type_parameters,
signature = signature
.bind_self(self.db, Some(typing_self_ty))
.display_with(self.db, self.settings.clone())
.display_with(self.db, self.settings.disallow_signature_name())
)
}
signatures => {
@@ -768,7 +780,7 @@ impl Display for DisplayOverloadLiteral<'_> {
"def {name}{type_parameters}{signature}",
name = self.literal.name(self.db),
type_parameters = type_parameters,
signature = signature.display_with(self.db, self.settings.clone())
signature = signature.display_with(self.db, self.settings.disallow_signature_name())
)
}
}
@@ -810,7 +822,8 @@ impl Display for DisplayFunctionType<'_> {
"def {name}{type_parameters}{signature}",
name = self.ty.name(self.db),
type_parameters = type_parameters,
signature = signature.display_with(self.db, self.settings.clone())
signature =
signature.display_with(self.db, self.settings.disallow_signature_name())
)
}
signatures => {
@@ -1081,6 +1094,7 @@ impl<'db> Signature<'db> {
settings: DisplaySettings<'db>,
) -> DisplaySignature<'db> {
DisplaySignature {
definition: self.definition(),
parameters: self.parameters(),
return_ty: self.return_ty,
db,
@@ -1090,6 +1104,7 @@ impl<'db> Signature<'db> {
}
pub(crate) struct DisplaySignature<'db> {
definition: Option<Definition<'db>>,
parameters: &'db Parameters<'db>,
return_ty: Option<Type<'db>>,
db: &'db dyn Db,
@@ -1111,6 +1126,18 @@ impl DisplaySignature<'_> {
/// Internal method to write signature with the signature writer
fn write_signature(&self, writer: &mut SignatureWriter) -> fmt::Result {
let multiline = self.settings.multiline && self.parameters.len() > 1;
// If we're multiline printing and a name hasn't been emitted, try to
// make one up to make things more pretty
if multiline && !self.settings.disallow_signature_name {
writer.write_str("def ")?;
if let Some(definition) = self.definition
&& let Some(name) = definition.name(self.db)
{
writer.write_str(&name)?;
} else {
writer.write_str("_")?;
}
}
// Opening parenthesis
writer.write_char('(')?;
if multiline {
@@ -1979,7 +2006,7 @@ mod tests {
Some(Type::none(&db))
),
@r"
(
def _(
x=int,
y: str = str
) -> None
@@ -1997,7 +2024,7 @@ mod tests {
Some(Type::none(&db))
),
@r"
(
def _(
x,
y,
/
@@ -2016,7 +2043,7 @@ mod tests {
Some(Type::none(&db))
),
@r"
(
def _(
x,
/,
y
@@ -2035,7 +2062,7 @@ mod tests {
Some(Type::none(&db))
),
@r"
(
def _(
*,
x,
y
@@ -2054,7 +2081,7 @@ mod tests {
Some(Type::none(&db))
),
@r"
(
def _(
x,
*,
y
@@ -2093,7 +2120,7 @@ mod tests {
Some(KnownClass::Bytes.to_instance(&db))
),
@r"
(
def _(
a,
b: int,
c=Literal[1],

View File

@@ -10,14 +10,14 @@ use crate::semantic_index::scope::ScopeId;
use crate::semantic_index::{
attribute_scopes, global_scope, place_table, semantic_index, use_def_map,
};
use crate::types::CallDunderError;
use crate::types::call::{CallArguments, MatchedArgument};
use crate::types::signatures::Signature;
use crate::types::{CallDunderError, UnionType};
use crate::types::{
ClassBase, ClassLiteral, DynamicType, KnownClass, KnownInstanceType, Type, TypeContext,
TypeVarBoundOrConstraints, class::CodeGeneratorKind,
};
use crate::{Db, HasType, NameKind, SemanticModel};
use crate::{Db, DisplaySettings, HasType, NameKind, SemanticModel};
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::parsed_module;
use ruff_python_ast::name::Name;
@@ -477,32 +477,17 @@ pub fn all_members<'db>(db: &'db dyn Db, ty: Type<'db>) -> FxHashSet<Member<'db>
/// Get the primary definition kind for a name expression within a specific file.
/// Returns the first definition kind that is reachable for this name in its scope.
/// This is useful for IDE features like semantic tokens.
pub fn definition_kind_for_name<'db>(
pub fn definition_for_name<'db>(
db: &'db dyn Db,
file: File,
name: &ast::ExprName,
) -> Option<DefinitionKind<'db>> {
let index = semantic_index(db, file);
let name_str = name.id.as_str();
// Get the scope for this name expression
let file_scope = index.expression_scope_id(&ast::ExprRef::from(name));
// Get the place table for this scope
let place_table = index.place_table(file_scope);
// Look up the place by name
let symbol_id = place_table.symbol_id(name_str)?;
// Get the use-def map and look up definitions for this place
let declarations = index
.use_def_map(file_scope)
.all_reachable_symbol_declarations(symbol_id);
) -> Option<Definition<'db>> {
let definitions = definitions_for_name(db, file, name);
// Find the first valid definition and return its kind
for declaration in declarations {
if let Some(def) = declaration.declaration.definition() {
return Some(def.kind(db).clone());
for declaration in definitions {
if let Some(def) = declaration.definition() {
return Some(def);
}
}
@@ -617,8 +602,34 @@ pub fn definitions_for_name<'db>(
// If we didn't find any definitions in scopes, fallback to builtins
if resolved_definitions.is_empty() {
let Some(builtins_scope) = builtins_module_scope(db) else {
return Vec::new();
return resolved_definitions;
};
// Special cases for `float` and `complex` in type annotation positions.
// We don't know whether we're in a type annotation position, so we'll just ask `Name`'s type,
// which resolves to `int | float` or `int | float | complex` if `float` or `complex` is used in
// a type annotation position and `float` or `complex` otherwise.
//
// https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex
if matches!(name_str, "float" | "complex")
&& let Some(union) = name.inferred_type(&SemanticModel::new(db, file)).as_union()
&& is_float_or_complex_annotation(db, union, name_str)
{
return union
.elements(db)
.iter()
// Use `rev` so that `complex` and `float` come first.
// This is required for hover to pick up the docstring of `complex` and `float`
// instead of `int` (hover only shows the docstring of the first definition).
.rev()
.filter_map(|ty| ty.as_nominal_instance())
.map(|instance| {
let definition = instance.class_literal(db).definition(db);
ResolvedDefinition::Definition(definition)
})
.collect();
}
find_symbol_in_scope(db, builtins_scope, name_str)
.into_iter()
.filter(|def| def.is_reexported(db))
@@ -636,6 +647,30 @@ pub fn definitions_for_name<'db>(
}
}
fn is_float_or_complex_annotation(db: &dyn Db, ty: UnionType, name: &str) -> bool {
let float_or_complex_ty = match name {
"float" => UnionType::from_elements(
db,
[
KnownClass::Int.to_instance(db),
KnownClass::Float.to_instance(db),
],
),
"complex" => UnionType::from_elements(
db,
[
KnownClass::Int.to_instance(db),
KnownClass::Float.to_instance(db),
KnownClass::Complex.to_instance(db),
],
),
_ => return false,
}
.expect_union();
ty == float_or_complex_ty
}
/// Returns all resolved definitions for an attribute expression `x.y`.
/// This function duplicates much of the functionality in the semantic
/// analyzer, but it has somewhat different behavior so we've decided
@@ -938,6 +973,65 @@ pub fn call_signature_details<'db>(
}
}
/// Given a call expression that has overloads, and whose overload is resolved to a
/// single option by its arguments, return the type of the Signature.
///
/// This is only used for simplifying complex call types, so if we ever detect that
/// the given callable type *is* simple, or that our answer *won't* be simple, we
/// bail at out and return None, so that the original type can be used.
///
/// We do this because `Type::Signature` intentionally loses a lot of context, and
/// so it has a "worse" display than say `Type::FunctionLiteral` or `Type::BoundMethod`,
/// which this analysis would naturally wipe away. The contexts this function
/// succeeds in are those where we would print a complicated/ugly type anyway.
pub fn call_type_simplified_by_overloads<'db>(
db: &'db dyn Db,
model: &SemanticModel<'db>,
call_expr: &ast::ExprCall,
) -> Option<String> {
let func_type = call_expr.func.inferred_type(model);
// Use into_callable to handle all the complex type conversions
let callable_type = func_type.try_upcast_to_callable(db)?;
let bindings = callable_type.bindings(db);
// If the callable is trivial this analysis is useless, bail out
if let Some(binding) = bindings.single_element()
&& binding.overloads().len() < 2
{
return None;
}
// Hand the overload resolution system as much type info as we have
let args = CallArguments::from_arguments_typed(&call_expr.arguments, |_, splatted_value| {
splatted_value.inferred_type(model)
});
// Try to resolve overloads with the arguments/types we have
let mut resolved = bindings
.match_parameters(db, &args)
.check_types(db, &args, TypeContext::default(), &[])
// Only use the Ok
.iter()
.flatten()
.flat_map(|binding| {
binding.matching_overloads().map(|(_, overload)| {
overload
.signature
.display_with(db, DisplaySettings::default().multiline())
.to_string()
})
})
.collect::<Vec<_>>();
// If at the end of this we still got multiple signatures (or no signatures), give up
if resolved.len() != 1 {
return None;
}
resolved.pop()
}
/// Returns the definitions of the binary operation along with its callable type.
pub fn definitions_for_bin_op<'db>(
db: &'db dyn Db,
@@ -1196,6 +1290,14 @@ mod resolve_definition {
}
impl<'db> ResolvedDefinition<'db> {
pub(crate) fn definition(&self) -> Option<Definition<'db>> {
match self {
ResolvedDefinition::Definition(definition) => Some(*definition),
ResolvedDefinition::Module(_) => None,
ResolvedDefinition::FileWithRange(_) => None,
}
}
fn file(&self, db: &'db dyn Db) -> File {
match self {
ResolvedDefinition::Definition(definition) => definition.file(db),

View File

@@ -101,11 +101,11 @@ use crate::types::typed_dict::{
use crate::types::visitor::any_over_type;
use crate::types::{
CallDunderError, CallableBinding, CallableType, ClassLiteral, ClassType, DataclassParams,
DynamicType, InternedType, InternedTypes, IntersectionBuilder, IntersectionType, KnownClass,
KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, Parameter,
ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, Truthiness,
Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers,
TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity,
DynamicType, InferredAs, InternedType, InternedTypes, IntersectionBuilder, IntersectionType,
KnownClass, KnownInstanceType, LintDiagnosticGuard, MemberLookupPolicy, MetaclassCandidate,
PEP695TypeAliasType, Parameter, ParameterForm, Parameters, SpecialFormType, SubclassOfType,
TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext,
TypeQualifiers, TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity,
TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType,
binding_type, todo_type,
};
@@ -3538,142 +3538,305 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
/// Make sure that the subscript assignment `obj[slice] = value` is valid.
/// Validate a subscript assignment of the form `object[key] = rhs_value`.
fn validate_subscript_assignment(
&mut self,
target: &ast::ExprSubscript,
rhs: &ast::Expr,
assigned_ty: Type<'db>,
rhs_value: &ast::Expr,
rhs_value_ty: Type<'db>,
) -> bool {
let ast::ExprSubscript {
range: _,
node_index: _,
value,
value: object,
slice,
ctx: _,
} = target;
let value_ty = self.infer_expression(value, TypeContext::default());
let object_ty = self.infer_expression(object, TypeContext::default());
let slice_ty = self.infer_expression(slice, TypeContext::default());
self.validate_subscript_assignment_impl(
object.as_ref(),
None,
object_ty,
slice.as_ref(),
slice_ty,
rhs_value,
rhs_value_ty,
true,
)
}
#[expect(clippy::too_many_arguments)]
fn validate_subscript_assignment_impl(
&self,
object_node: &'ast ast::Expr,
full_object_ty: Option<Type<'db>>,
object_ty: Type<'db>,
slice_node: &'ast ast::Expr,
slice_ty: Type<'db>,
rhs_value_node: &'ast ast::Expr,
rhs_value_ty: Type<'db>,
emit_diagnostic: bool,
) -> bool {
/// Given a string literal or a union of string literals, return an iterator over the contained
/// strings, or `None`, if the type is neither.
fn key_literals<'db>(
db: &'db dyn Db,
slice_ty: Type<'db>,
) -> Option<impl Iterator<Item = &'db str> + 'db> {
if let Some(literal) = slice_ty.as_string_literal() {
Some(Either::Left(std::iter::once(literal.value(db))))
} else {
slice_ty.as_union().map(|union| {
Either::Right(
union
.elements(db)
.iter()
.filter_map(|ty| ty.as_string_literal().map(|lit| lit.value(db))),
)
})
}
}
let db = self.db();
let context = &self.context;
match value_ty.try_call_dunder(
db,
"__setitem__",
CallArguments::positional([slice_ty, assigned_ty]),
TypeContext::default(),
) {
Ok(_) => true,
Err(err) => match err {
CallDunderError::PossiblyUnbound { .. } => {
if let Some(builder) =
context.report_lint(&POSSIBLY_MISSING_IMPLICIT_CALL, &**value)
{
builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` may be missing",
value_ty.display(db),
));
}
false
let attach_original_type_info = |mut diagnostic: LintDiagnosticGuard| {
if let Some(full_object_ty) = full_object_ty {
diagnostic.info(format_args!(
"The full type of the subscripted object is `{}`",
full_object_ty.display(db)
));
}
};
match object_ty {
Type::Union(union) => {
// Note that we use a loop here instead of .all(…) to avoid short-circuiting.
// We need to keep iterating to emit all diagnostics.
let mut valid = true;
for element_ty in union.elements(db) {
valid &= self.validate_subscript_assignment_impl(
object_node,
full_object_ty.or(Some(object_ty)),
*element_ty,
slice_node,
slice_ty,
rhs_value_node,
rhs_value_ty,
emit_diagnostic,
);
}
CallDunderError::CallError(call_error_kind, bindings) => {
match call_error_kind {
CallErrorKind::NotCallable => {
if let Some(builder) = context.report_lint(&CALL_NON_CALLABLE, &**value)
{
builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` is not callable \
on object of type `{}`",
bindings.callable_type().display(db),
value_ty.display(db),
));
}
}
CallErrorKind::BindingError => {
let assigned_d = assigned_ty.display(db);
let value_d = value_ty.display(db);
valid
}
if let Some(typed_dict) = value_ty.as_typed_dict() {
if let Some(key) = slice_ty.as_string_literal() {
let key = key.value(self.db());
validate_typed_dict_key_assignment(
&self.context,
typed_dict,
key,
assigned_ty,
value.as_ref(),
slice.as_ref(),
rhs,
TypedDictAssignmentKind::Subscript,
);
} else {
// Check if the key has a valid type. We only allow string literals, a union of string literals,
// or a dynamic type like `Any`. We can do this by checking assignability to `LiteralString`,
// but we need to exclude `LiteralString` itself. This check would technically allow weird key
// types like `LiteralString & Any` to pass, but it does not need to be perfect. We would just
// fail to provide the "Only string literals are allowed" hint in that case.
if slice_ty.is_assignable_to(db, Type::LiteralString)
&& !slice_ty.is_equivalent_to(db, Type::LiteralString)
Type::Intersection(intersection) => {
let check_positive_elements = |emit_diagnostic_and_short_circuit| {
let mut valid = false;
for element_ty in intersection.positive(db) {
valid |= self.validate_subscript_assignment_impl(
object_node,
full_object_ty.or(Some(object_ty)),
*element_ty,
slice_node,
slice_ty,
rhs_value_node,
rhs_value_ty,
emit_diagnostic_and_short_circuit,
);
if !valid && emit_diagnostic_and_short_circuit {
break;
}
}
valid
};
// Perform an initial check of all elements. If the assignment is valid
// for at least one element, we do not emit any diagnostics. Otherwise,
// we re-run the check and emit a diagnostic on the first failing element.
let valid = check_positive_elements(false);
if !valid {
check_positive_elements(true);
}
valid
}
Type::TypedDict(typed_dict) => {
// As an optimization, prevent calling `__setitem__` on (unions of) large `TypedDict`s, and
// validate the assignment ourselves. This also allows us to emit better diagnostics.
let mut valid = true;
let Some(keys) = key_literals(db, slice_ty) else {
// Check if the key has a valid type. We only allow string literals, a union of string literals,
// or a dynamic type like `Any`. We can do this by checking assignability to `LiteralString`,
// but we need to exclude `LiteralString` itself. This check would technically allow weird key
// types like `LiteralString & Any` to pass, but it does not need to be perfect. We would just
// fail to provide the "Only string literals are allowed" hint in that case.
if slice_ty.is_dynamic() {
return true;
}
let assigned_d = rhs_value_ty.display(db);
let value_d = object_ty.display(db);
if slice_ty.is_assignable_to(db, Type::LiteralString)
&& !slice_ty.is_equivalent_to(db, Type::LiteralString)
{
if let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, slice_node)
{
let diagnostic = builder.into_diagnostic(format_args!(
"Cannot assign value of type `{assigned_d}` to key of type `{}` on TypedDict `{value_d}`",
slice_ty.display(db)
));
attach_original_type_info(diagnostic);
}
} else {
if let Some(builder) = self.context.report_lint(&INVALID_KEY, slice_node) {
let diagnostic = builder.into_diagnostic(format_args!(
"Cannot access `{value_d}` with a key of type `{}`. Only string literals are allowed as keys on TypedDicts.",
slice_ty.display(db)
));
attach_original_type_info(diagnostic);
}
}
return false;
};
for key in keys {
valid &= validate_typed_dict_key_assignment(
&self.context,
typed_dict,
full_object_ty,
key,
rhs_value_ty,
object_node,
slice_node,
rhs_value_node,
TypedDictAssignmentKind::Subscript,
emit_diagnostic,
);
}
valid
}
_ => {
match object_ty.try_call_dunder(
db,
"__setitem__",
CallArguments::positional([slice_ty, rhs_value_ty]),
TypeContext::default(),
) {
Ok(_) => true,
Err(err) => match err {
CallDunderError::PossiblyUnbound { .. } => {
if emit_diagnostic
&& let Some(builder) = self
.context
.report_lint(&POSSIBLY_MISSING_IMPLICIT_CALL, rhs_value_node)
{
let diagnostic = builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` may be missing",
object_ty.display(db),
));
attach_original_type_info(diagnostic);
}
false
}
CallDunderError::CallError(call_error_kind, bindings) => {
match call_error_kind {
CallErrorKind::NotCallable => {
if emit_diagnostic
&& let Some(builder) = self
.context
.report_lint(&CALL_NON_CALLABLE, object_node)
{
if let Some(builder) =
context.report_lint(&INVALID_ASSIGNMENT, &**slice)
{
builder.into_diagnostic(format_args!(
"Cannot assign value of type `{assigned_d}` to key of type `{}` on TypedDict `{value_d}`",
slice_ty.display(db)
));
let diagnostic = builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` is not callable \
on object of type `{}`",
bindings.callable_type().display(db),
object_ty.display(db),
));
attach_original_type_info(diagnostic);
}
}
CallErrorKind::BindingError => {
if let Some(typed_dict) = object_ty.as_typed_dict() {
if let Some(key) = slice_ty.as_string_literal() {
let key = key.value(db);
validate_typed_dict_key_assignment(
&self.context,
typed_dict,
full_object_ty,
key,
rhs_value_ty,
object_node,
slice_node,
rhs_value_node,
TypedDictAssignmentKind::Subscript,
true,
);
}
} else {
if let Some(builder) =
context.report_lint(&INVALID_KEY, &**slice)
if emit_diagnostic
&& let Some(builder) = self
.context
.report_lint(&INVALID_ASSIGNMENT, object_node)
{
builder.into_diagnostic(format_args!(
"Cannot access `{value_d}` with a key of type `{}`. Only string literals are allowed as keys on TypedDicts.",
slice_ty.display(db)
let assigned_d = rhs_value_ty.display(db);
let value_d = object_ty.display(db);
let diagnostic = builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` cannot be called with \
a key of type `{}` and a value of type `{assigned_d}` on object of type `{value_d}`",
bindings.callable_type().display(db),
slice_ty.display(db),
));
attach_original_type_info(diagnostic);
}
}
}
} else {
if let Some(builder) =
context.report_lint(&INVALID_ASSIGNMENT, &**value)
{
builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` cannot be called with \
a key of type `{}` and a value of type `{assigned_d}` on object of type `{value_d}`",
bindings.callable_type().display(db),
slice_ty.display(db),
));
CallErrorKind::PossiblyNotCallable => {
if emit_diagnostic
&& let Some(builder) = self
.context
.report_lint(&CALL_NON_CALLABLE, object_node)
{
let diagnostic = builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` may not be callable on object of type `{}`",
bindings.callable_type().display(db),
object_ty.display(db),
));
attach_original_type_info(diagnostic);
}
}
}
false
}
CallErrorKind::PossiblyNotCallable => {
if let Some(builder) = context.report_lint(&CALL_NON_CALLABLE, &**value)
CallDunderError::MethodNotAvailable => {
if emit_diagnostic
&& let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, object_node)
{
builder.into_diagnostic(format_args!(
"Method `__setitem__` of type `{}` may not be \
callable on object of type `{}`",
bindings.callable_type().display(db),
value_ty.display(db),
let diagnostic = builder.into_diagnostic(format_args!(
"Cannot assign to a subscript on an object of type `{}` with no `__setitem__` method",
object_ty.display(db),
));
attach_original_type_info(diagnostic);
}
false
}
}
false
},
}
CallDunderError::MethodNotAvailable => {
if let Some(builder) = context.report_lint(&INVALID_ASSIGNMENT, &**value) {
builder.into_diagnostic(format_args!(
"Cannot assign to object of type `{}` with no `__setitem__` method",
value_ty.display(db),
));
}
false
}
},
}
}
}
@@ -3741,23 +3904,77 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
assignable
};
let emit_invalid_final = |builder: &Self| {
if emit_diagnostics {
if let Some(builder) = builder.context.report_lint(&INVALID_ASSIGNMENT, target) {
builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` on type `{}`",
object_ty.display(db)
));
}
}
};
// Return true (and emit a diagnostic) if this is an invalid assignment to a `Final` attribute.
// Per PEP 591 and the typing conformance suite, Final instance attributes can be assigned
// in __init__ methods. Multiple assignments within __init__ are allowed (matching mypy
// and pyright behavior), as long as the attribute doesn't have a class-level value.
let invalid_assignment_to_final = |builder: &Self, qualifiers: TypeQualifiers| -> bool {
if qualifiers.contains(TypeQualifiers::FINAL) {
if emit_diagnostics {
if let Some(builder) = builder.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` \
on type `{}`",
object_ty.display(db)
));
// Check if it's a Final attribute
if !qualifiers.contains(TypeQualifiers::FINAL) {
return false;
}
// Check if we're in an __init__ method (where Final attributes can be initialized).
let is_in_init = builder
.current_function_definition()
.is_some_and(|func| func.name.id == "__init__");
// Not in __init__ - always disallow
if !is_in_init {
emit_invalid_final(builder);
return true;
}
// We're in __init__ - verify we're in a method of the class being mutated
let Some(class_ty) = builder.class_context_of_current_method() else {
// Not a method (standalone function named __init__)
emit_invalid_final(builder);
return true;
};
// Check that object_ty is an instance of the class we're in
if !object_ty.is_subtype_of(builder.db(), Type::instance(builder.db(), class_ty)) {
// Assigning to a different class's Final attribute
emit_invalid_final(builder);
return true;
}
// Check if class-level attribute already has a value
{
let class_definition = class_ty.class_literal(db).0;
let class_scope_id = class_definition.body_scope(db).file_scope_id(db);
let place_table = builder.index.place_table(class_scope_id);
if let Some(symbol) = place_table.symbol_by_name(attribute) {
if symbol.is_bound() {
if emit_diagnostics {
if let Some(diag_builder) =
builder.context.report_lint(&INVALID_ASSIGNMENT, target)
{
diag_builder.into_diagnostic(format_args!(
"Cannot assign to final attribute `{attribute}` in `__init__` \
because it already has a value at class level"
));
}
}
return true;
}
}
true
} else {
false
}
// In __init__ and no class-level value - allow
false
};
match object_ty {
@@ -7628,6 +7845,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
first_arg.into(),
first_arg.into(),
Type::TypedDict(typed_dict_ty),
None,
key_ty,
&items,
);
@@ -8265,6 +8483,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let mut nonlocal_union_builder = UnionBuilder::new(db);
let mut found_some_definition = false;
for (enclosing_scope_file_id, _) in self.index.ancestor_scopes(file_scope_id).skip(1) {
// If the current enclosing scope is global, no place lookup is performed here,
// instead falling back to the module's explicit global lookup below.
if enclosing_scope_file_id.is_global() {
break;
}
// Class scopes are not visible to nested scopes, and we need to handle global
// scope differently (because an unbound name there falls back to builtins), so
// check only function-like scopes.
@@ -8295,6 +8519,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// registering eager bindings for nested scopes that are actually eager, and for
// enclosing scopes that actually contain bindings that we should use when
// resolving the reference.)
let mut eagerly_resolved_place = None;
if !self.is_deferred() {
match self.index.enclosing_snapshot(
enclosing_scope_file_id,
@@ -8306,6 +8531,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
enclosing_scope_file_id,
ConstraintKey::NarrowingConstraint(constraint),
));
// If the current scope is eager, it is certain that the place is undefined in the current scope.
// Do not call the `place` query below as a fallback.
if scope.scope(db).is_eager() {
eagerly_resolved_place = Some(Place::Undefined.into());
}
}
EnclosingSnapshotResult::FoundBindings(bindings) => {
let place = place_from_bindings(db, bindings).map_type(|ty| {
@@ -8367,18 +8597,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// `nonlocal` variable, but we don't enforce that here. See the
// `ast::Stmt::AnnAssign` handling in `SemanticIndexBuilder::visit_stmt`.)
if enclosing_place.is_bound() || enclosing_place.is_declared() {
let local_place_and_qualifiers = place(
db,
enclosing_scope_id,
place_expr,
ConsideredDefinitions::AllReachable,
)
.map_type(|ty| {
self.narrow_place_with_applicable_constraints(
let local_place_and_qualifiers = eagerly_resolved_place.unwrap_or_else(|| {
place(
db,
enclosing_scope_id,
place_expr,
ty,
&constraint_keys,
ConsideredDefinitions::AllReachable,
)
.map_type(|ty| {
self.narrow_place_with_applicable_constraints(
place_expr,
ty,
&constraint_keys,
)
})
});
// We could have `Place::Undefined` here, despite the checks above, for example if
// this scope contains a `del` statement but no binding or declaration.
@@ -8421,6 +8653,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
FileScopeId::global(),
ConstraintKey::NarrowingConstraint(constraint),
));
// Reaching here means that no bindings are found in any scope.
// Since `explicit_global_symbol` may return a cycle initial value, we return `Place::Undefined` here.
return Place::Undefined.into();
}
EnclosingSnapshotResult::FoundBindings(bindings) => {
let place = place_from_bindings(db, bindings).map_type(|ty| {
@@ -9180,7 +9415,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
Some(left_ty)
} else {
Some(Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [left_ty, right_ty]),
InternedTypes::from_elements(
self.db(),
[left_ty, right_ty],
InferredAs::ValueExpression,
),
)))
}
}
@@ -9205,7 +9444,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
&& instance.has_known_class(self.db(), KnownClass::NoneType) =>
{
Some(Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [left_ty, right_ty]),
InternedTypes::from_elements(
self.db(),
[left_ty, right_ty],
InferredAs::ValueExpression,
),
)))
}
@@ -10422,9 +10665,46 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
return Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [ty, Type::none(self.db())]),
InternedTypes::from_elements(
self.db(),
[ty, Type::none(self.db())],
InferredAs::ValueExpression,
),
));
}
Type::SpecialForm(SpecialFormType::Union) => {
let db = self.db();
match **slice {
ast::Expr::Tuple(ref tuple) => {
let mut elements = tuple
.elts
.iter()
.map(|elt| self.infer_type_expression(elt))
.peekable();
let is_empty = elements.peek().is_none();
let union_type = Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(db, elements, InferredAs::TypeExpression),
));
if is_empty {
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, subscript)
{
builder.into_diagnostic(
"`typing.Union` requires at least one type argument",
);
}
}
return union_type;
}
_ => {
return self.infer_expression(slice, TypeContext::default());
}
}
}
_ => {}
}
@@ -10792,6 +11072,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
value_node.into(),
slice_node.into(),
value_ty,
None,
slice_ty,
&typed_dict.items(db),
);

View File

@@ -11,9 +11,9 @@ use crate::types::enums::{enum_member_literals, enum_metadata};
use crate::types::function::KnownFunction;
use crate::types::infer::infer_same_file_expression_type;
use crate::types::{
ClassLiteral, ClassType, IntersectionBuilder, KnownClass, KnownInstanceType, SpecialFormType,
SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext, TypeVarBoundOrConstraints,
UnionBuilder, infer_expression_types,
CallableType, ClassLiteral, ClassType, IntersectionBuilder, KnownClass, KnownInstanceType,
SpecialFormType, SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext,
TypeVarBoundOrConstraints, UnionBuilder, infer_expression_types,
};
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
@@ -229,6 +229,18 @@ impl ClassInfoConstraintFunction {
)
}
// We don't have a good meta-type for `Callable`s right now,
// so only apply `isinstance()` narrowing, not `issubclass()`
Type::SpecialForm(SpecialFormType::Callable)
if self == ClassInfoConstraintFunction::IsInstance =>
{
Some(CallableType::unknown(db).top_materialization(db))
}
Type::SpecialForm(special_form) => special_form
.aliased_stdlib_class()
.and_then(|class| self.generate_constraint(db, class.to_class_literal(db))),
Type::AlwaysFalsy
| Type::AlwaysTruthy
| Type::BooleanLiteral(_)
@@ -244,7 +256,6 @@ impl ClassInfoConstraintFunction {
| Type::FunctionLiteral(_)
| Type::ProtocolInstance(_)
| Type::PropertyInstance(_)
| Type::SpecialForm(_)
| Type::LiteralString
| Type::StringLiteral(_)
| Type::IntLiteral(_)

View File

@@ -328,6 +328,61 @@ impl SpecialFormType {
}
}
/// Return `Some(KnownClass)` if this special form is an alias
/// to a standard library class.
pub(super) const fn aliased_stdlib_class(self) -> Option<KnownClass> {
match self {
Self::List => Some(KnownClass::List),
Self::Dict => Some(KnownClass::Dict),
Self::Set => Some(KnownClass::Set),
Self::FrozenSet => Some(KnownClass::FrozenSet),
Self::ChainMap => Some(KnownClass::ChainMap),
Self::Counter => Some(KnownClass::Counter),
Self::DefaultDict => Some(KnownClass::DefaultDict),
Self::Deque => Some(KnownClass::Deque),
Self::OrderedDict => Some(KnownClass::OrderedDict),
Self::Tuple => Some(KnownClass::Tuple),
Self::Type => Some(KnownClass::Type),
Self::AlwaysFalsy
| Self::AlwaysTruthy
| Self::Annotated
| Self::Bottom
| Self::CallableTypeOf
| Self::ClassVar
| Self::Concatenate
| Self::Final
| Self::Intersection
| Self::Literal
| Self::LiteralString
| Self::Never
| Self::NoReturn
| Self::Not
| Self::ReadOnly
| Self::Required
| Self::TypeAlias
| Self::TypeGuard
| Self::NamedTuple
| Self::NotRequired
| Self::Optional
| Self::Top
| Self::TypeIs
| Self::TypedDict
| Self::TypingSelf
| Self::Union
| Self::Unknown
| Self::TypeOf
| Self::Any
// `typing.Callable` is an alias to `collections.abc.Callable`,
// but they're both the same `SpecialFormType` in our model,
// and neither is a class in typeshed (even though the `collections.abc` one is at runtime)
| Self::Callable
| Self::Protocol
| Self::Generic
| Self::Unpack => None,
}
}
/// Return `true` if this special form is valid as the second argument
/// to `issubclass()` and `isinstance()` calls.
pub(super) const fn is_valid_isinstance_target(self) -> bool {

View File

@@ -143,30 +143,57 @@ impl TypedDictAssignmentKind {
pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
context: &InferContext<'db, 'ast>,
typed_dict: TypedDictType<'db>,
full_object_ty: Option<Type<'db>>,
key: &str,
value_ty: Type<'db>,
typed_dict_node: impl Into<AnyNodeRef<'ast>>,
typed_dict_node: impl Into<AnyNodeRef<'ast>> + Copy,
key_node: impl Into<AnyNodeRef<'ast>>,
value_node: impl Into<AnyNodeRef<'ast>>,
assignment_kind: TypedDictAssignmentKind,
emit_diagnostic: bool,
) -> bool {
let db = context.db();
let items = typed_dict.items(db);
// Check if key exists in `TypedDict`
let Some((_, item)) = items.iter().find(|(name, _)| *name == key) else {
report_invalid_key_on_typed_dict(
context,
typed_dict_node.into(),
key_node.into(),
Type::TypedDict(typed_dict),
Type::string_literal(db, key),
&items,
);
if emit_diagnostic {
report_invalid_key_on_typed_dict(
context,
typed_dict_node.into(),
key_node.into(),
Type::TypedDict(typed_dict),
full_object_ty,
Type::string_literal(db, key),
&items,
);
}
return false;
};
let add_object_type_annotation =
|diagnostic: &mut Diagnostic| {
if let Some(full_object_ty) = full_object_ty {
diagnostic.annotate(context.secondary(typed_dict_node.into()).message(
format_args!(
"TypedDict `{}` in {kind} type `{}`",
Type::TypedDict(typed_dict).display(db),
full_object_ty.display(db),
kind = if full_object_ty.is_union() {
"union"
} else {
"intersection"
},
),
));
} else {
diagnostic.annotate(context.secondary(typed_dict_node.into()).message(
format_args!("TypedDict `{}`", Type::TypedDict(typed_dict).display(db)),
));
}
};
let add_item_definition_subdiagnostic = |diagnostic: &mut Diagnostic, message| {
if let Some(declaration) = item.single_declaration {
let file = declaration.file(db);
@@ -184,8 +211,9 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
};
if assignment_kind.is_subscript() && item.is_read_only() {
if let Some(builder) =
context.report_lint(assignment_kind.diagnostic_type(), key_node.into())
if emit_diagnostic
&& let Some(builder) =
context.report_lint(assignment_kind.diagnostic_type(), key_node.into())
{
let typed_dict_ty = Type::TypedDict(typed_dict);
let typed_dict_d = typed_dict_ty.display(db);
@@ -195,13 +223,7 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
));
diagnostic.set_primary_message(format_args!("key is marked read-only"));
diagnostic.annotate(
context
.secondary(typed_dict_node.into())
.message(format_args!("TypedDict `{typed_dict_d}`")),
);
add_object_type_annotation(&mut diagnostic);
add_item_definition_subdiagnostic(&mut diagnostic, "Read-only item declared here");
}
@@ -219,7 +241,9 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
}
// Invalid assignment - emit diagnostic
if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node) {
if emit_diagnostic
&& let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node)
{
let typed_dict_ty = Type::TypedDict(typed_dict);
let typed_dict_d = typed_dict_ty.display(db);
let value_d = value_ty.display(db);
@@ -232,12 +256,6 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
diagnostic.set_primary_message(format_args!("value of type `{value_d}`"));
diagnostic.annotate(
context
.secondary(typed_dict_node.into())
.message(format_args!("TypedDict `{typed_dict_d}`")),
);
diagnostic.annotate(
context
.secondary(key_node.into())
@@ -245,6 +263,7 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
);
add_item_definition_subdiagnostic(&mut diagnostic, "Item declared here");
add_object_type_annotation(&mut diagnostic);
}
false
@@ -343,12 +362,14 @@ fn validate_from_dict_literal<'db, 'ast>(
validate_typed_dict_key_assignment(
context,
typed_dict,
None,
key_str,
value_type,
error_node,
key_expr,
&dict_item.value,
TypedDictAssignmentKind::Constructor,
true,
);
}
}
@@ -380,12 +401,14 @@ fn validate_from_keywords<'db, 'ast>(
validate_typed_dict_key_assignment(
context,
typed_dict,
None,
arg_name.as_str(),
arg_type,
error_node,
keyword,
&keyword.value,
TypedDictAssignmentKind::Constructor,
true,
);
}
}
@@ -418,12 +441,14 @@ pub(super) fn validate_typed_dict_dict_literal<'db>(
valid &= validate_typed_dict_key_assignment(
context,
typed_dict,
None,
key_str,
value_type,
error_node,
key_expr,
&item.value,
TypedDictAssignmentKind::Constructor,
true,
);
}
}

View File

@@ -70,13 +70,16 @@ export default function Editor({
const serverRef = useRef<PlaygroundServer | null>(null);
if (serverRef.current != null) {
serverRef.current.update({
files,
workspace,
onOpenFile,
onVendoredFileChange,
onBackToUserFile,
});
serverRef.current.update(
{
files,
workspace,
onOpenFile,
onVendoredFileChange,
onBackToUserFile,
},
isViewingVendoredFile,
);
}
// Update the diagnostics in the editor.
@@ -200,6 +203,7 @@ class PlaygroundServer
private rangeSemanticTokensDisposable: IDisposable;
private signatureHelpDisposable: IDisposable;
private documentHighlightDisposable: IDisposable;
private inVendoredFileCondition: editor.IContextKey<boolean>;
// Cache for vendored file handles
private vendoredFileHandles = new Map<string, FileHandle>();
@@ -249,8 +253,16 @@ class PlaygroundServer
this.documentHighlightDisposable =
monaco.languages.registerDocumentHighlightProvider("python", this);
this.inVendoredFileCondition = editor.createContextKey<boolean>(
"inVendoredFile",
false,
);
// Register Esc key command
editor.addCommand(monaco.KeyCode.Escape, this.props.onBackToUserFile);
editor.addCommand(
monaco.KeyCode.Escape,
() => this.props.onBackToUserFile(),
"inVendoredFile",
);
}
triggerCharacters: string[] = ["."];
@@ -452,8 +464,9 @@ class PlaygroundServer
return undefined;
}
update(props: PlaygroundServerProps) {
update(props: PlaygroundServerProps, isViewingVendoredFile: boolean) {
this.props = props;
this.inVendoredFileCondition.set(isViewingVendoredFile);
}
private getOrCreateVendoredFileHandle(vendoredPath: string): FileHandle {