Compare commits

..

26 Commits

Author SHA1 Message Date
Brent Westbrook
e0b0b54b25 try a different approach 2025-11-13 10:09:37 -05:00
Brent Westbrook
9bee558c3a add more variations on issue test case 2025-11-13 09:37:15 -05:00
Brent Westbrook
f49fda317e Revert "try only dangling"
This reverts commit 4c8540ede798f544a895225b541f46552649fd7b.
2025-11-12 15:33:48 -05:00
Brent Westbrook
1cf5eff9f4 try only dangling 2025-11-12 15:33:48 -05:00
Brent Westbrook
ecccfd2a56 dangling operator comment
fix leading comments
2025-11-12 15:33:48 -05:00
Brent Westbrook
a303d1aa49 leading operand comment 2025-11-12 15:33:48 -05:00
Brent Westbrook
6eebc35eec add failing test 2025-11-12 15:33:48 -05:00
Brent Westbrook
923b4dd01e Fix panic when formatting comments in unary expressions
Summary
--

This is a second attempt at fixing #19226 based on the feedback in https://github.com/astral-sh/ruff/pull/20494#issuecomment-3467920065.

We currently mark the comment in an expression like this:

```py
if '' and (not #
0):
    pass
```

as a leading comment on `not`, eventually causing a panic in
`Operand::has_unparenthesized_leading_comments` because the end of the comment
is greater than the start of the expression.

a1d9cb5830/crates/ruff_python_formatter/src/expression/binary_like.rs (L843)

This PR fixes the issue by instead making such a comment a dangling comment on
the unary expression.

In the third commit, I instead tried making the comment a leading comment on the
operand, which also looks pretty reasonable to me. Making it a dangling comment
seems more in line with the docs on `handle_unary_op_comments`, though.

I also tried deleting the leading comment logic in favor of the new dangling
logic in the fifth commit before reverting in the sixth. This looks okay to me
too, but the current state of the PR seems like the least invasive fix.

Test Plan
--

A new, minimized test case based on the issue. I also checked that the original
snippet from the report works now.

Co-authored-by: Takayuki Maeda <takoyaki0316@gmail.com>
2025-11-12 15:31:49 -05: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
62 changed files with 2646 additions and 1234 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,
3000,
);
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

@@ -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();
}
}
}

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

@@ -1,8 +0,0 @@
[
{
"preview": "disabled"
},
{
"preview": "enabled"
}
]

View File

@@ -125,13 +125,6 @@ lambda a, /, c: a
*x: x
)
(
lambda
# comment
*x,
**y: x
)
(
lambda
# comment 1
@@ -142,17 +135,6 @@ lambda a, /, c: a
x
)
(
lambda
# comment 1
*
# comment 2
x,
**y:
# comment 3
x
)
(
lambda # comment 1
* # comment 2
@@ -160,14 +142,6 @@ lambda a, /, c: a
x
)
(
lambda # comment 1
* # comment 2
x,
y: # comment 3
x
)
lambda *x\
:x
@@ -222,17 +196,6 @@ lambda: ( # comment
x
)
(
lambda # 1
# 2
x, # 3
# 4
y
: # 5
# 6
x
)
(
lambda
x,
@@ -241,71 +204,6 @@ lambda: ( # comment
z
)
# Leading
lambda x: (
lambda y: lambda z: x
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ z # Trailing
) # Trailing
# Leading
lambda x: lambda y: lambda z: [
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z
] # Trailing
# Trailing
lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs), e=1, f=2, g=2: d
# Regression tests for https://github.com/astral-sh/ruff/issues/8179

View File

@@ -193,3 +193,19 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass
# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if '' and (not #
0):
pass
if '' and (not #
(0)
):
pass
if '' and (not
( #
0
)):
pass

View File

@@ -836,7 +836,8 @@ impl<'a> Operand<'a> {
let leading = comments.leading(*expression);
if is_expression_parenthesized((*expression).into(), comments.ranges(), source) {
leading.iter().any(|comment| {
!comment.is_formatted()
comment.end() <= expression.start()
&& !comment.is_formatted()
&& matches!(
SimpleTokenizer::new(
source,
@@ -922,7 +923,8 @@ impl Format<PyFormatContext<'_>> for Operand<'_> {
let leading_before_parentheses_end = leading
.iter()
.rposition(|comment| {
comment.is_unformatted()
comment.end() <= expression.start()
&& comment.is_unformatted()
&& matches!(
SimpleTokenizer::new(
f.context().source(),

View File

@@ -4,7 +4,6 @@ use ruff_python_ast::ExprLambda;
use ruff_text_size::Ranged;
use crate::comments::dangling_comments;
use crate::comments::leading_comments;
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses};
use crate::other::parameters::ParametersParentheses;
use crate::prelude::*;
@@ -34,45 +33,24 @@ impl FormatNodeRule<ExprLambda> for FormatExprLambda {
if dangling_before_parameters.is_empty() {
write!(f, [space()])?;
} else {
write!(f, [dangling_comments(dangling_before_parameters)])?;
}
group(&format_with(|f: &mut PyFormatter| {
if f.context().node_level().is_parenthesized()
&& (parameters.len() > 1 || !dangling_before_parameters.is_empty())
{
let end_of_line_start = dangling_before_parameters
.partition_point(|comment| comment.line_position().is_end_of_line());
let (same_line_comments, own_line_comments) =
dangling_before_parameters.split_at(end_of_line_start);
write!(
f,
[parameters
.format()
.with_options(ParametersParentheses::Never)]
)?;
dangling_comments(same_line_comments).fmt(f)?;
write!(f, [token(":")])?;
write![
f,
[
soft_line_break(),
leading_comments(own_line_comments),
parameters
.format()
.with_options(ParametersParentheses::Never),
]
]
} else {
parameters
.format()
.with_options(ParametersParentheses::Never)
.fmt(f)
}?;
write!(f, [token(":")])?;
if dangling_after_parameters.is_empty() {
write!(f, [space()])
} else {
write!(f, [dangling_comments(dangling_after_parameters)])
}
}))
.fmt(f)?;
if dangling_after_parameters.is_empty() {
write!(f, [space()])?;
} else {
write!(f, [dangling_comments(dangling_after_parameters)])?;
}
} else {
write!(f, [token(":")])?;

View File

@@ -241,7 +241,7 @@ impl FormatNodeRule<Parameters> for FormatParameters {
let num_parameters = item.len();
if self.parentheses == ParametersParentheses::Never {
write!(f, [format_inner, dangling_comments(dangling)])
write!(f, [group(&format_inner), dangling_comments(dangling)])
} else if num_parameters == 0 {
let mut f = WithNodeLevel::new(NodeLevel::ParenthesizedExpression, f);
// No parameters, format any dangling comments between `()`

View File

@@ -1,6 +1,7 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/lambda.py
snapshot_kind: text
---
## Input
```python
@@ -131,13 +132,6 @@ lambda a, /, c: a
*x: x
)
(
lambda
# comment
*x,
**y: x
)
(
lambda
# comment 1
@@ -148,17 +142,6 @@ lambda a, /, c: a
x
)
(
lambda
# comment 1
*
# comment 2
x,
**y:
# comment 3
x
)
(
lambda # comment 1
* # comment 2
@@ -166,14 +149,6 @@ lambda a, /, c: a
x
)
(
lambda # comment 1
* # comment 2
x,
y: # comment 3
x
)
lambda *x\
:x
@@ -228,17 +203,6 @@ lambda: ( # comment
x
)
(
lambda # 1
# 2
x, # 3
# 4
y
: # 5
# 6
x
)
(
lambda
x,
@@ -247,71 +211,6 @@ lambda: ( # comment
z
)
# Leading
lambda x: (
lambda y: lambda z: x
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ z # Trailing
) # Trailing
# Leading
lambda x: lambda y: lambda z: [
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z
] # Trailing
# Trailing
lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs), e=1, f=2, g=2: d
# Regression tests for https://github.com/astral-sh/ruff/issues/8179
@@ -338,22 +237,7 @@ def a():
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Disabled
target_version = 3.10
source_type = Python
```
## Output
```python
# Leading
lambda x: x # Trailing
@@ -417,8 +301,7 @@ a = (
)
a = (
lambda
x, # Dangling
lambda x, # Dangling
y: 1
)
@@ -484,13 +367,6 @@ lambda a, /, c: a
*x: x
)
(
lambda
# comment
*x,
**y: x
)
(
lambda
# comment 1
@@ -500,16 +376,6 @@ lambda a, /, c: a
x
)
(
lambda
# comment 1
# comment 2
*x,
**y:
# comment 3
x
)
(
lambda # comment 1
# comment 2
@@ -517,14 +383,6 @@ lambda a, /, c: a
x
)
(
lambda # comment 1
# comment 2
*x,
y: # comment 3
x
)
lambda *x: x
(
@@ -577,87 +435,11 @@ lambda: ( # comment
)
(
lambda # 1
# 2
x, # 3
# 4
y: # 5
# 6
x
)
(
lambda
x,
lambda x,
# comment
y: z
)
# Leading
lambda x: (
lambda y: lambda z: x
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ z # Trailing
) # Trailing
# Leading
lambda x: lambda y: lambda z: [
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z,
] # Trailing
# Trailing
lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
*args, **kwargs
), e=1, f=2, g=2: d
@@ -669,8 +451,7 @@ def a():
c,
d,
e,
f=lambda
self,
f=lambda self,
*args,
**kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs),
)
@@ -681,365 +462,7 @@ def a():
c,
d,
e,
f=lambda
self,
araa,
kkkwargs,
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
args,
kwargs,
e=1,
f=2,
g=2: d,
g=10,
)
```
### Output 2
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = 3.10
source_type = Python
```
```python
# Leading
lambda x: x # Trailing
# Trailing
# Leading
lambda x, y: x # Trailing
# Trailing
# Leading
lambda x, y: x, y # Trailing
# Trailing
# Leading
lambda x, /, y: x # Trailing
# Trailing
# Leading
lambda x: lambda y: lambda z: x # Trailing
# Trailing
# Leading
lambda x: lambda y: lambda z: (x, y, z) # Trailing
# Trailing
# Leading
lambda x: lambda y: lambda z: (x, y, z) # Trailing
# Trailing
# Leading
lambda x: lambda y: lambda z: (
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z,
) # Trailing
# Trailing
a = (
lambda: # Dangling
1
)
a = (
lambda
x, # Dangling
y: 1
)
# Regression test: lambda empty arguments ranges were too long, leading to unstable
# formatting
(
lambda: ( #
),
)
# lambda arguments don't have parentheses, so we never add a magic trailing comma ...
def f(
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = lambda x: y,
):
pass
# ...but we do preserve a trailing comma after the arguments
a = lambda b,: 0
lambda a,: 0
lambda *args,: 0
lambda **kwds,: 0
lambda a, *args,: 0
lambda a, **kwds,: 0
lambda *args, b,: 0
lambda *, b,: 0
lambda *args, **kwds,: 0
lambda a, *args, b,: 0
lambda a, *, b,: 0
lambda a, *args, **kwds,: 0
lambda *args, b, **kwds,: 0
lambda *, b, **kwds,: 0
lambda a, *args, b, **kwds,: 0
lambda a, *, b, **kwds,: 0
lambda a, /: a
lambda a, /, c: a
# Dangling comments without parameters.
(
lambda: # 3
None
)
(
lambda:
# 3
None
)
(
lambda: # 1
# 2
# 3
# 4
None # 5
)
(
lambda
# comment
*x: x
)
(
lambda
# comment
*x,
**y: x
)
(
lambda
# comment 1
# comment 2
*x:
# comment 3
x
)
(
lambda
# comment 1
# comment 2
*x,
**y:
# comment 3
x
)
(
lambda # comment 1
# comment 2
*x: # comment 3
x
)
(
lambda # comment 1
# comment 2
*x,
y: # comment 3
x
)
lambda *x: x
(
lambda
# comment
*x: x
)
lambda: ( # comment
x
)
(
lambda: # comment
x
)
(
lambda:
# comment
x
)
(
lambda: # comment
x
)
(
lambda:
# comment
x
)
(
lambda: # comment
( # comment
x
)
)
(
lambda # 1
# 2
x: # 3
# 4
# 5
# 6
x
)
(
lambda # 1
# 2
x, # 3
# 4
y: # 5
# 6
x
)
(
lambda
x,
# comment
y: z
)
# Leading
lambda x: (
lambda y: lambda z: x
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ z # Trailing
) # Trailing
# Leading
lambda x: lambda y: lambda z: [
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z,
] # Trailing
# Trailing
lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
*args, **kwargs
), e=1, f=2, g=2: d
# Regression tests for https://github.com/astral-sh/ruff/issues/8179
def a():
return b(
c,
d,
e,
f=lambda
self,
*args,
**kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs),
)
def a():
return b(
c,
d,
e,
f=lambda
self,
f=lambda self,
araa,
kkkwargs,
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/unary.py
snapshot_kind: text
---
## Input
```python
@@ -200,6 +199,22 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass
# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if '' and (not #
0):
pass
if '' and (not #
(0)
):
pass
if '' and (not
( #
0
)):
pass
```
## Output
@@ -415,4 +430,23 @@ def foo():
not (aaaaaaaaaaaaaaaaaaaaa[bbbbbbbb, ccccccc]) and dddddddddd < eeeeeeeeeeeeeee
):
pass
# Regression tests for https://github.com/astral-sh/ruff/issues/19226
if "" and ( #
not 0
):
pass
if "" and ( #
not (0)
):
pass
if "" and (
not ( #
0
)
):
pass
```

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

@@ -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

@@ -2634,6 +2634,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

@@ -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

@@ -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

@@ -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 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,31 @@
---
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: [possibly-missing-implicit-call]
```
# Diagnostics
```
warning[possibly-missing-implicit-call]: Method `__setitem__` of type `dict[str, int] | None` may be missing
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | None) -> None:
2 | config["retries"] = 3 # error: [possibly-missing-implicit-call]
| ^^^^^^
|
info: rule `possibly-missing-implicit-call` is enabled by default
```

View File

@@ -0,0 +1,40 @@
---
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 | being["surname"] = "unknown" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `(key: Literal["name"], value: str, /) -> None` cannot be called with a key of type `Literal["surname"]` and a value of type `Literal["unknown"]` on object of type `Person | Animal`
--> src/mdtest_snippet.py:11:5
|
10 | def _(being: Person | Animal) -> None:
11 | being["surname"] = "unknown" # error: [invalid-assignment]
| ^^^^^
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,40 @@
---
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-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `(key: Literal["name"], value: str, /) -> None` cannot be called with a key of type `Literal["legs"]` and a value of type `Literal[4]` on object of type `Person | Animal`
--> src/mdtest_snippet.py:11:5
|
10 | def _(being: Person | Animal) -> None:
11 | being["legs"] = 4 # error: [invalid-assignment]
| ^^^^^
|
info: rule `invalid-assignment` 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 - 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, int].__setitem__(key: str, value: int, /) -> None) | (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, int] | 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: rule `invalid-assignment` 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 - 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 | config["retries"] = 3.0 # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `(bound method dict[str, int].__setitem__(key: str, value: int, /) -> None) | (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, int] | dict[str, str]`
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | dict[str, str]) -> None:
2 | config["retries"] = 3.0 # error: [invalid-assignment]
| ^^^^^^
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,117 @@
# 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: [possibly-missing-implicit-call]
```
## 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-assignment]
```
## 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:
being["surname"] = "unknown" # error: [invalid-assignment]
```
## 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:
config["retries"] = 3.0 # error: [invalid-assignment]
```

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

@@ -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
@@ -548,18 +558,30 @@ def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age",
# 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"
@@ -583,6 +605,23 @@ def _(person: Person, union_of_keys: Literal["name", "surname"]):
# error: [invalid-assignment] "Cannot assign value of type `Literal[1]` to key of type `Literal["name", "surname"]` on TypedDict `Person`"
person[union_of_keys] = 1
def _(being: Person | Animal):
being["name"] = "Being"
# error: [invalid-assignment] "Method `__setitem__` of type `(Overload[(key: Literal["name"], value: str, /) -> None, (key: Literal["surname"], value: str, /) -> None, (key: Literal["age"], value: int | None, /) -> None]) | (Overload[(key: Literal["name"], value: str, /) -> None, (key: Literal["legs"], value: int, /) -> None])` cannot be called with a key of type `Literal["name"]` and a value of type `Literal[1]` on object of type `Person | Animal`"
being["name"] = 1
# error: [invalid-assignment] "Method `__setitem__` of type `(Overload[(key: Literal["name"], value: str, /) -> None, (key: Literal["surname"], value: str, /) -> None, (key: Literal["age"], value: int | None, /) -> None]) | (Overload[(key: Literal["name"], value: str, /) -> None, (key: Literal["legs"], value: int, /) -> None])` cannot be called with a key of type `Literal["surname"]` and a value of type `Literal["unknown"]` on object of type `Person | Animal`"
being["surname"] = "unknown"
def _(centaur: Intersection[Person, Animal]):
centaur["name"] = "Chiron"
centaur["age"] = 100
centaur["legs"] = 4
# TODO: This should be an `invalid-key` error
centaur["unknown"] = "value"
def _(person: Person, union_of_keys: Literal["name", "age"], unknown_value: Any):
person[union_of_keys] = unknown_value

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

@@ -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()
@@ -1700,7 +1731,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 +2403,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,

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(..))
}
@@ -1171,7 +1170,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 +6585,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 +8584,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 +9164,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 +9189,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 +9198,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 +9210,7 @@ impl<'db> InternedTypes<'db> {
.iter()
.map(|ty| ty.normalized_impl(db, visitor))
.collect::<Box<[_]>>(),
self.inferred_as(db),
)
}
}

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

@@ -10,9 +10,9 @@ 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,
@@ -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
@@ -1196,6 +1231,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,10 +101,10 @@ 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,
DynamicType, InferredAs, InternedType, InternedTypes, IntersectionBuilder, IntersectionType,
KnownClass, KnownInstanceType, 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,
@@ -3741,23 +3741,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 {
@@ -9180,7 +9234,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 +9263,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 +10484,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());
}
}
}
_ => {}
}

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

@@ -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 {