Compare commits

..

26 Commits

Author SHA1 Message Date
Douglas Creager
f33ca3a622 minimize more 2025-10-17 16:23:47 -04:00
Douglas Creager
a79ba2036b propagate impossible deeply 2025-10-17 16:23:46 -04:00
Douglas Creager
d6f28b7428 keep all minimizations 2025-10-17 16:23:46 -04:00
Douglas Creager
9df9adae1e minimize before display 2025-10-17 16:23:46 -04:00
Douglas Creager
eec4e2ed11 add impossible terminal 2025-10-17 16:23:46 -04:00
Douglas Creager
a44fbd6658 debug display simplification 2025-10-17 16:23:46 -04:00
Douglas Creager
22075d5ed7 debug 2025-10-17 16:23:46 -04:00
Douglas Creager
5d451979c4 shannon 2025-10-17 16:23:46 -04:00
Douglas Creager
73773b4ea4 more intersection replacements 2025-10-17 16:23:46 -04:00
Douglas Creager
7cfdc4a550 replace with intersection 2025-10-17 16:23:46 -04:00
Douglas Creager
2f0e7d6af7 fix pos/neg implication 2025-10-17 16:23:46 -04:00
Douglas Creager
8d44f8b7b5 remove old simplify 2025-10-17 16:23:46 -04:00
Douglas Creager
c0faa2dc3d use simplify_new 2025-10-17 16:23:46 -04:00
Douglas Creager
f4fff7fb24 new simplification 2025-10-17 16:23:46 -04:00
Douglas Creager
a6bd68886f xor 2025-10-17 16:23:46 -04:00
Douglas Creager
5c2c3f00ff constraint implication check 2025-10-17 16:23:46 -04:00
Douglas Creager
5affc120b3 order typevars near each other 2025-10-17 16:23:46 -04:00
Douglas Creager
1e284933ec normalize bounds 2025-10-17 16:23:46 -04:00
Douglas Creager
c529ee4f80 simplify individual clauses for display 2025-10-17 16:23:46 -04:00
Douglas Creager
f88ff62da5 add BDD graph display 2025-10-17 16:23:46 -04:00
Brent Westbrook
0115fd3757 Avoid reusing nested, interpolated quotes before Python 3.12 (#20930)
## Summary

Fixes #20774 by tracking whether an `InterpolatedStringState` element is
nested inside of another interpolated element. This feels like kind of a
naive fix, so I'm welcome to other ideas. But it resolves the problem in
the issue and clears up the syntax error in the black compatibility
test, without affecting many other cases.

The other affected case is actually interesting too because the
[input](96b156303b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py (L707))
is invalid, but the previous quote selection fixed the invalid syntax:

```pycon
Python 3.11.13 (main, Sep  2 2025, 14:20:25) [Clang 20.1.4 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> f'{1: abcd "{'aa'}" }'  # input
  File "<stdin>", line 1
    f'{1: abcd "{'aa'}" }'
                  ^^
SyntaxError: f-string: expecting '}'
>>> f'{1: abcd "{"aa"}" }'  # old output
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Invalid format specifier ' abcd "aa" ' for object of type 'int'
>>> f'{1: abcd "{'aa'}" }'  # new output
  File "<stdin>", line 1
    f'{1: abcd "{'aa'}" }'
                  ^^
SyntaxError: f-string: expecting '}'
```

We now preserve the invalid syntax in the input.

Unfortunately, this also seems to be another edge case I didn't consider
in https://github.com/astral-sh/ruff/pull/20867 because we don't flag
this as a syntax error after 0.14.1:

<details><summary>Shell output</summary>
<p>

```
> uvx ruff@0.14.0 check --ignore ALL --target-version py311 - <<EOF
f'{1: abcd "{'aa'}" }'
EOF
invalid-syntax: Cannot reuse outer quote character in f-strings on Python 3.11 (syntax was added in Python 3.12)
 --> -:1:14
  |
1 | f'{1: abcd "{'aa'}" }'
  |              ^
  |

Found 1 error.
> uvx ruff@0.14.1 check --ignore ALL --target-version py311 - <<EOF
f'{1: abcd "{'aa'}" }'
EOF
All checks passed!
> uvx python@3.11 -m ast <<EOF
f'{1: abcd "{'aa'}" }'
EOF
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1752, in <module>
    main()
  File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 1748, in main
    tree = parse(source, args.infile.name, args.mode, type_comments=args.no_type_comments)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/brent/.local/share/uv/python/cpython-3.11.13-linux-x86_64-gnu/lib/python3.11/ast.py", line 50, in parse
    return compile(source, filename, mode, flags,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<stdin>", line 1
    f'{1: abcd "{'aa'}" }'
                  ^^
SyntaxError: f-string: expecting '}'
```

</p>
</details> 


I assumed that was the same `ParseError` as the one caused by
`f"{1:""}"`, but this is a nested interpolation inside of the format
spec.

## Test Plan

New test copied from the black compatibility test. I guess this is a
duplicate now, I started working on this branch before the new black
tests were imported, so I could delete the separate test in our fixtures
if that's preferable.
2025-10-17 08:49:16 -04:00
David Peter
cfbd42c22a [ty] Support dataclass_transform for base class models (#20783)
## Summary

Support `dataclass_transform` when used on a (base) class.

## Typing conformance

* The changes in `dataclasses_transform_class.py` look good, just a few
mistakes due to missing `alias` support.
* I didn't look closely at the changes in
`dataclasses_transform_converter.py` since we don't support `converter`
yet.

## Ecosystem impact

The impact looks huge, but it's concentrated on a single project (ibis).
Their setup looks more or less like this:

* the real `Annotatable`:
d7083c2c96/ibis/common/grounds.py (L100-L101)
* the real `DataType`:
d7083c2c96/ibis/expr/datatypes/core.py (L161-L179)
* the real `Array`:
d7083c2c96/ibis/expr/datatypes/core.py (L1003-L1006)


```py
from typing import dataclass_transform

@dataclass_transform()
class Annotatable:
    pass

class DataType(Annotatable):
    nullable: bool = True

class Array[T](DataType):
    value_type: T
```

They expect something like `Array([1, 2])` to work, but ty, pyright,
mypy, and pyrefly would all expect there to be a first argument for the
`nullable` field on `DataType`. I don't really understand on what
grounds they expect the `nullable` field to be excluded from the
signature, but this seems to be the main reason for the new diagnostics
here. Not sure if related, but it looks like their typing setup is not
really complete
(https://github.com/ibis-project/ibis/issues/6844#issuecomment-1868274770,
this thread also mentions `dataclass_transform`).

## Test Plan

Update pre-existing tests.
2025-10-17 14:04:31 +02:00
Mark Z. Ding
fc3b341529 [ty] Truncate Literal type display in some situations (#20928) 2025-10-17 11:50:58 +00:00
Alex Waygood
baaa8dad3a [ty] Re-enable fuzzer seeds that are no longer slow (#20937) 2025-10-17 12:29:13 +01:00
Micha Reiser
a21cde8a5a [ty] Fix playground crash for very large files (#20934) 2025-10-17 09:15:33 +02:00
Aria Desires
64edfb6ef6 [ty] add legacy namespace package support (#20897)
Detect legacy namespace packages and treat them like namespace packages
when looking them up as the *parent* of the module we're interested in.
In all other cases treat them like a regular package.

(This PR is coauthored by @MichaReiser in a shared coding session)

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

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-10-17 03:16:37 +00:00
22 changed files with 1399 additions and 1091 deletions

View File

@@ -666,7 +666,7 @@ jobs:
- determine_changes
# Only runs on pull requests, since that is the only we way we can find the base version for comparison.
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && github.event_name == 'pull_request' && (needs.determine_changes.outputs.ty == 'true' || needs.determine_changes.outputs.py-fuzzer == 'true') }}
timeout-minutes: 20
timeout-minutes: 5
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:

View File

@@ -748,3 +748,7 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
print(f"{ {}, 1, }")
# The inner quotes should not be changed to double quotes before Python 3.12
f"{f'''{'nested'} inner'''} outer"

View File

@@ -144,6 +144,12 @@ pub(crate) enum InterpolatedStringState {
///
/// The containing `FStringContext` is the surrounding f-string context.
InsideInterpolatedElement(InterpolatedStringContext),
/// The formatter is inside more than one nested f-string, such as in `nested` in:
///
/// ```py
/// f"{f'''{'nested'} inner'''} outer"
/// ```
NestedInterpolatedElement(InterpolatedStringContext),
/// The formatter is outside an f-string.
#[default]
Outside,
@@ -152,12 +158,18 @@ pub(crate) enum InterpolatedStringState {
impl InterpolatedStringState {
pub(crate) fn can_contain_line_breaks(self) -> Option<bool> {
match self {
InterpolatedStringState::InsideInterpolatedElement(context) => {
InterpolatedStringState::InsideInterpolatedElement(context)
| InterpolatedStringState::NestedInterpolatedElement(context) => {
Some(context.is_multiline())
}
InterpolatedStringState::Outside => None,
}
}
/// Returns `true` if the interpolated string state is [`NestedInterpolatedElement`].
pub(crate) fn is_nested(self) -> bool {
matches!(self, Self::NestedInterpolatedElement(..))
}
}
/// The position of a top-level statement in the module.

View File

@@ -181,10 +181,16 @@ impl Format<PyFormatContext<'_>> for FormatInterpolatedElement<'_> {
let item = format_with(|f: &mut PyFormatter| {
// Update the context to be inside the f-string expression element.
let f = &mut WithInterpolatedStringState::new(
InterpolatedStringState::InsideInterpolatedElement(self.context),
f,
);
let state = match f.context().interpolated_string_state() {
InterpolatedStringState::InsideInterpolatedElement(_)
| InterpolatedStringState::NestedInterpolatedElement(_) => {
InterpolatedStringState::NestedInterpolatedElement(self.context)
}
InterpolatedStringState::Outside => {
InterpolatedStringState::InsideInterpolatedElement(self.context)
}
};
let f = &mut WithInterpolatedStringState::new(state, f);
write!(f, [bracket_spacing, expression.format()])?;

View File

@@ -46,8 +46,15 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
.unwrap_or(self.context.options().quote_style());
let supports_pep_701 = self.context.options().target_version().supports_pep_701();
// Preserve the existing quote style for nested interpolations more than one layer deep, if
// PEP 701 isn't supported.
if !supports_pep_701 && self.context.interpolated_string_state().is_nested() {
return QuoteStyle::Preserve;
}
// For f-strings and t-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't.
if let InterpolatedStringState::InsideInterpolatedElement(parent_context) =
if let InterpolatedStringState::InsideInterpolatedElement(parent_context)
| InterpolatedStringState::NestedInterpolatedElement(parent_context) =
self.context.interpolated_string_state()
{
let parent_flags = parent_context.flags();

View File

@@ -28,12 +28,11 @@ but none started with prefix {parentdir_prefix}"
f'{{NOT \'a\' "formatted" "value"}}'
f"some f-string with {a} {few():.2f} {formatted.values!r}"
-f'some f-string with {a} {few(""):.2f} {formatted.values!r}'
-f"{f'''{'nested'} inner'''} outer"
+f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
f"{f'''{'nested'} inner'''} outer"
-f"\"{f'{nested} inner'}\" outer"
-f"space between opening braces: { {a for a in (1, 2, 3)}}"
-f'Hello \'{tricky + "example"}\''
+f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
+f"{f'''{"nested"} inner'''} outer"
+f'"{f"{nested} inner"}" outer'
+f"space between opening braces: { {a for a in (1, 2, 3)} }"
+f"Hello '{tricky + 'example'}'"
@@ -49,7 +48,7 @@ f"{{NOT a formatted value}}"
f'{{NOT \'a\' "formatted" "value"}}'
f"some f-string with {a} {few():.2f} {formatted.values!r}"
f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
f"{f'''{"nested"} inner'''} outer"
f"{f'''{'nested'} inner'''} outer"
f'"{f"{nested} inner"}" outer'
f"space between opening braces: { {a for a in (1, 2, 3)} }"
f"Hello '{tricky + 'example'}'"
@@ -72,17 +71,3 @@ f'Hello \'{tricky + "example"}\''
f"Tried directories {str(rootdirs)} \
but none started with prefix {parentdir_prefix}"
```
## New Unsupported Syntax Errors
error[invalid-syntax]: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12)
--> fstring.py:6:9
|
4 | f"some f-string with {a} {few():.2f} {formatted.values!r}"
5 | f"some f-string with {a} {few(''):.2f} {formatted.values!r}"
6 | f"{f'''{"nested"} inner'''} outer"
| ^
7 | f'"{f"{nested} inner"}" outer'
8 | f"space between opening braces: { {a for a in (1, 2, 3)} }"
|
warning: Only accept new syntax errors if they are also present in the input. The formatter should not introduce syntax errors.

View File

@@ -754,6 +754,10 @@ print(f"{ # Tuple with multiple elements that doesn't fit on a single line gets
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
print(f"{ {}, 1, }")
# The inner quotes should not be changed to double quotes before Python 3.12
f"{f'''{'nested'} inner'''} outer"
```
## Outputs
@@ -1532,7 +1536,7 @@ f'{f"""other " """}'
f'{1: hy "user"}'
f'{1:hy "user"}'
f'{1: abcd "{1}" }'
f'{1: abcd "{"aa"}" }'
f'{1: abcd "{'aa'}" }'
f'{1=: "abcd {'aa'}}'
f"{x:a{z:hy \"user\"}} '''"
@@ -1581,6 +1585,10 @@ print(
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
print(f"{ {}, 1 }")
# The inner quotes should not be changed to double quotes before Python 3.12
f"{f'''{'nested'} inner'''} outer"
```
@@ -2359,7 +2367,7 @@ f'{f"""other " """}'
f'{1: hy "user"}'
f'{1:hy "user"}'
f'{1: abcd "{1}" }'
f'{1: abcd "{"aa"}" }'
f'{1: abcd "{'aa'}" }'
f'{1=: "abcd {'aa'}}'
f"{x:a{z:hy \"user\"}} '''"
@@ -2408,6 +2416,10 @@ print(
# Regression tests for https://github.com/astral-sh/ruff/issues/15536
print(f"{ {}, 1 }")
# The inner quotes should not be changed to double quotes before Python 3.12
f"{f'''{'nested'} inner'''} outer"
```

View File

@@ -122,9 +122,6 @@ class CustomerModel(ModelBase):
id: int
name: str
# TODO: this is not supported yet
# error: [unknown-argument]
# error: [unknown-argument]
CustomerModel(id=1, name="Test")
```
@@ -216,11 +213,7 @@ class OrderedModelBase: ...
class TestWithBase(OrderedModelBase):
inner: int
# TODO: No errors here, should reveal `bool`
# error: [too-many-positional-arguments]
# error: [too-many-positional-arguments]
# error: [unsupported-operator]
reveal_type(TestWithBase(1) < TestWithBase(2)) # revealed: Unknown
reveal_type(TestWithBase(1) < TestWithBase(2)) # revealed: bool
```
### `kw_only_default`
@@ -277,8 +270,7 @@ class ModelBase: ...
class TestBase(ModelBase):
name: str
# TODO: This should be `(self: TestBase, *, name: str) -> None`
reveal_type(TestBase.__init__) # revealed: def __init__(self) -> None
reveal_type(TestBase.__init__) # revealed: (self: TestBase, *, name: str) -> None
```
### `frozen_default`
@@ -333,12 +325,9 @@ class ModelBase: ...
class TestMeta(ModelBase):
name: str
# TODO: no error here
# error: [unknown-argument]
t = TestMeta(name="test")
# TODO: this should be an `invalid-assignment` error
t.name = "new"
t.name = "new" # error: [invalid-assignment]
```
### Combining parameters
@@ -437,19 +426,15 @@ class DefaultFrozenModel:
class Frozen(DefaultFrozenModel):
name: str
# TODO: no error here
# error: [unknown-argument]
f = Frozen(name="test")
# TODO: this should be an `invalid-assignment` error
f.name = "new"
f.name = "new" # error: [invalid-assignment]
class Mutable(DefaultFrozenModel, frozen=False):
name: str
# TODO: no error here
# error: [unknown-argument]
m = Mutable(name="test")
m.name = "new" # No error
# TODO: This should not be an error
m.name = "new" # error: [invalid-assignment]
```
## `field_specifiers`
@@ -532,12 +517,8 @@ class Person(FancyBase):
name: str = fancy_field()
age: int | None = fancy_field(kw_only=True)
# TODO: should be (self: Person, name: str = Unknown, *, age: int | None = Unknown) -> None
reveal_type(Person.__init__) # revealed: def __init__(self) -> None
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
# TODO: shouldn't be an error
# error: [too-many-positional-arguments]
# error: [unknown-argument]
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int

View File

@@ -138,3 +138,27 @@ def _(n: int):
# error: [unknown-argument]
y = f("foo", name="bar", unknown="quux")
```
### Truncation for long unions and literals
This test demonstrates a call where the expected type is a large mixed union. The diagnostic must
therefore truncate the long expected union type to avoid overwhelming output.
```py
from typing import Literal, Union
class A: ...
class B: ...
class C: ...
class D: ...
class E: ...
class F: ...
def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int:
return 0
def _(n: int):
x = n
# error: [invalid-argument-type]
f1(x)
```

View File

@@ -0,0 +1,221 @@
# Legacy namespace packages
## `__import__("pkgutil").extend_path`
```toml
[environment]
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
```
`/airflow-core/src/airflow/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
__version__ = "3.2.0"
```
`/providers/amazon/src/airflow/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
```py
__version__ = "9.15.0"
```
`test.py`:
```py
from airflow import __version__ as airflow_version
from airflow.providers.amazon import __version__ as amazon_provider_version
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
```
## `pkgutil.extend_path`
```toml
[environment]
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
```
`/airflow-core/src/airflow/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
__version__ = "3.2.0"
```
`/providers/amazon/src/airflow/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
```py
__version__ = "9.15.0"
```
`test.py`:
```py
from airflow import __version__ as airflow_version
from airflow.providers.amazon import __version__ as amazon_provider_version
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
```
## `extend_path` with keyword arguments
```toml
[environment]
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
```
`/airflow-core/src/airflow/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
__version__ = "3.2.0"
```
`/providers/amazon/src/airflow/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
```
`/providers/amazon/src/airflow/providers/__init__.py`:
```py
import pkgutil
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
```
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
```py
__version__ = "9.15.0"
```
`test.py`:
```py
from airflow import __version__ as airflow_version
from airflow.providers.amazon import __version__ as amazon_provider_version
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
```
## incorrect `__import__` arguments
```toml
[environment]
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
```
`/airflow-core/src/airflow/__init__.py`:
```py
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
__version__ = "3.2.0"
```
`/providers/amazon/src/airflow/__init__.py`:
```py
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/__init__.py`:
```py
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
```
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
```py
__version__ = "9.15.0"
```
`test.py`:
```py
from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import]
from airflow import __version__ as airflow_version
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
```
## incorrect `extend_path` arguments
```toml
[environment]
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
```
`/airflow-core/src/airflow/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
__version__ = "3.2.0"
```
`/providers/amazon/src/airflow/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
```
`/providers/amazon/src/airflow/providers/__init__.py`:
```py
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
```
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
```py
__version__ = "9.15.0"
```
`test.py`:
```py
from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import]
from airflow import __version__ as airflow_version
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
```

View File

@@ -0,0 +1,56 @@
---
source: crates/ty_test/src/lib.rs
assertion_line: 427
expression: snapshot
---
---
mdtest name: union_call.md - Calling a union of function types - Try to cover all possible reasons - Truncation for long unions and literals
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/union_call.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import Literal, Union
2 |
3 | class A: ...
4 | class B: ...
5 | class C: ...
6 | class D: ...
7 | class E: ...
8 | class F: ...
9 |
10 | def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int:
11 | return 0
12 |
13 | def _(n: int):
14 | x = n
15 | # error: [invalid-argument-type]
16 | f1(x)
```
# Diagnostics
```
error[invalid-argument-type]: Argument to function `f1` is incorrect
--> src/mdtest_snippet.py:16:8
|
14 | x = n
15 | # error: [invalid-argument-type]
16 | f1(x)
| ^ Expected `Literal[1, 2, 3, 4, 5, ... omitted 3 literals] | A | B | ... omitted 4 union elements`, found `int`
|
info: Function defined here
--> src/mdtest_snippet.py:10:5
|
8 | class F: ...
9 |
10 | def f1(x: Union[Literal[1, 2, 3, 4, 5, 6, 7, 8], A, B, C, D, E, F]) -> int:
| ^^ ----------------------------------------------------------- Parameter declared here
11 | return 0
|
info: rule `invalid-argument-type` is enabled by default
```

View File

@@ -19,7 +19,10 @@ use rustc_hash::{FxBuildHasher, FxHashSet};
use ruff_db::files::{File, FilePath, FileRootKind};
use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf};
use ruff_db::vendored::VendoredFileSystem;
use ruff_python_ast::{PySourceType, PythonVersion};
use ruff_python_ast::{
self as ast, PySourceType, PythonVersion,
visitor::{Visitor, walk_body},
};
use crate::db::Db;
use crate::module_name::ModuleName;
@@ -1002,7 +1005,12 @@ where
let is_regular_package = package_path.is_regular_package(resolver_state);
if is_regular_package {
in_namespace_package = false;
// This is the only place where we need to consider the existence of legacy namespace
// packages, as we are explicitly searching for the *parent* package of the module
// we actually want. Here, such a package should be treated as a PEP-420 ("modern")
// namespace package. In all other contexts it acts like a normal package and needs
// no special handling.
in_namespace_package = is_legacy_namespace_package(&package_path, resolver_state);
} else if package_path.is_directory(resolver_state)
// Pure modules hide namespace packages with the same name
&& resolve_file_module(&package_path, resolver_state).is_none()
@@ -1039,6 +1047,62 @@ where
})
}
/// Determines whether a package is a legacy namespace package.
///
/// Before PEP 420 introduced implicit namespace packages, the ecosystem developed
/// its own form of namespace packages. These legacy namespace packages continue to persist
/// in modern codebases because they work with ancient Pythons and if it ain't broke, don't fix it.
///
/// A legacy namespace package is distinguished by having an `__init__.py` that contains an
/// expression to the effect of:
///
/// ```python
/// __path__ = __import__("pkgutil").extend_path(__path__, __name__)
/// ```
///
/// The resulting package simultaneously has properties of both regular packages and namespace ones:
///
/// * Like regular packages, `__init__.py` is defined and can contain items other than submodules
/// * Like implicit namespace packages, multiple copies of the package may exist with different
/// submodules, and they will be merged into one namespace at runtime by the interpreter
///
/// Now, you may rightly wonder: "What if the `__init__.py` files have different contents?"
/// The apparent official answer is: "Don't do that!"
/// And the reality is: "Of course people do that!"
///
/// In practice we think it's fine to, just like with regular packages, use the first one
/// we find on the search paths. To the extent that the different copies "need" to have the same
/// contents, they all "need" to have the legacy namespace idiom (we do nothing to enforce that,
/// we will just get confused if you mess it up).
fn is_legacy_namespace_package(
package_path: &ModulePath,
resolver_state: &ResolverContext,
) -> bool {
// Just an optimization, the stdlib and typeshed are never legacy namespace packages
if package_path.search_path().is_standard_library() {
return false;
}
let mut package_path = package_path.clone();
package_path.push("__init__");
let Some(init) = resolve_file_module(&package_path, resolver_state) else {
return false;
};
// This is all syntax-only analysis so it *could* be fooled but it's really unlikely.
//
// The benefit of being syntax-only is speed and avoiding circular dependencies
// between module resolution and semantic analysis.
//
// The downside is if you write slightly different syntax we will fail to detect the idiom,
// but hey, this is better than nothing!
let parsed = ruff_db::parsed::parsed_module(resolver_state.db, init);
let mut visitor = LegacyNamespacePackageVisitor::default();
visitor.visit_body(parsed.load(resolver_state.db).suite());
visitor.is_legacy_namespace_package
}
#[derive(Debug)]
struct ResolvedPackage {
path: ModulePath,
@@ -1148,6 +1212,124 @@ impl fmt::Display for RelaxedModuleName {
}
}
/// Detects if a module contains a statement of the form:
/// ```python
/// __path__ = pkgutil.extend_path(__path__, __name__)
/// ```
/// or
/// ```python
/// __path__ = __import__("pkgutil").extend_path(__path__, __name__)
/// ```
#[derive(Default)]
struct LegacyNamespacePackageVisitor {
is_legacy_namespace_package: bool,
in_body: bool,
}
impl Visitor<'_> for LegacyNamespacePackageVisitor {
fn visit_body(&mut self, body: &[ruff_python_ast::Stmt]) {
if self.is_legacy_namespace_package {
return;
}
// Don't traverse into nested bodies.
if self.in_body {
return;
}
self.in_body = true;
walk_body(self, body);
}
fn visit_stmt(&mut self, stmt: &ast::Stmt) {
if self.is_legacy_namespace_package {
return;
}
let ast::Stmt::Assign(ast::StmtAssign { value, targets, .. }) = stmt else {
return;
};
let [ast::Expr::Name(maybe_path)] = &**targets else {
return;
};
if &*maybe_path.id != "__path__" {
return;
}
let ast::Expr::Call(ast::ExprCall {
func: extend_func,
arguments: extend_arguments,
..
}) = &**value
else {
return;
};
let ast::Expr::Attribute(ast::ExprAttribute {
value: maybe_pkg_util,
attr: maybe_extend_path,
..
}) = &**extend_func
else {
return;
};
// Match if the left side of the attribute access is either `__import__("pkgutil")` or `pkgutil`
match &**maybe_pkg_util {
// __import__("pkgutil").extend_path(__path__, __name__)
ast::Expr::Call(ruff_python_ast::ExprCall {
func: maybe_import,
arguments: import_arguments,
..
}) => {
let ast::Expr::Name(maybe_import) = &**maybe_import else {
return;
};
if maybe_import.id() != "__import__" {
return;
}
let Some(ast::Expr::StringLiteral(name)) =
import_arguments.find_argument_value("name", 0)
else {
return;
};
if name.value.to_str() != "pkgutil" {
return;
}
}
// "pkgutil.extend_path(__path__, __name__)"
ast::Expr::Name(name) => {
if name.id() != "pkgutil" {
return;
}
}
_ => {
return;
}
}
// Test that this is an `extend_path(__path__, __name__)` call
if maybe_extend_path != "extend_path" {
return;
}
let Some(ast::Expr::Name(path)) = extend_arguments.find_argument_value("path", 0) else {
return;
};
let Some(ast::Expr::Name(name)) = extend_arguments.find_argument_value("name", 1) else {
return;
};
self.is_legacy_namespace_package = path.id() == "__path__" && name.id() == "__name__";
}
}
#[cfg(test)]
mod tests {
#![expect(

View File

@@ -23,6 +23,7 @@ use crate::unpack::{Unpack, UnpackPosition};
/// before this `Definition`. However, the ID can be considered stable and it is okay to use
/// `Definition` in cross-module` salsa queries or as a field on other salsa tracked structs.
#[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)]
#[derive(Ord, PartialOrd)]
pub struct Definition<'db> {
/// The file in which the definition occurs.
pub file: File,

View File

@@ -8390,7 +8390,9 @@ fn lazy_bound_cycle_initial<'db>(
}
/// Where a type variable is bound and usable.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)]
#[derive(
Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, salsa::Update, get_size2::GetSize,
)]
pub enum BindingContext<'db> {
/// The definition of the generic class, function, or type alias that binds this typevar.
Definition(Definition<'db>),
@@ -8424,7 +8426,9 @@ impl<'db> BindingContext<'db> {
/// independent of the typevar's bounds or constraints. Two bound typevars have the same identity
/// if they represent the same logical typevar bound in the same context, even if their bounds
/// have been materialized differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)]
#[derive(
Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd, get_size2::GetSize, salsa::Update,
)]
pub struct BoundTypeVarIdentity<'db> {
pub(crate) identity: TypeVarIdentity<'db>,
pub(crate) binding_context: BindingContext<'db>,

View File

@@ -190,7 +190,11 @@ pub(crate) enum CodeGeneratorKind<'db> {
}
impl<'db> CodeGeneratorKind<'db> {
pub(crate) fn from_class(db: &'db dyn Db, class: ClassLiteral<'db>) -> Option<Self> {
pub(crate) fn from_class(
db: &'db dyn Db,
class: ClassLiteral<'db>,
specialization: Option<Specialization<'db>>,
) -> Option<Self> {
#[salsa::tracked(
cycle_fn=code_generator_of_class_recover,
cycle_initial=code_generator_of_class_initial,
@@ -199,11 +203,20 @@ impl<'db> CodeGeneratorKind<'db> {
fn code_generator_of_class<'db>(
db: &'db dyn Db,
class: ClassLiteral<'db>,
specialization: Option<Specialization<'db>>,
) -> Option<CodeGeneratorKind<'db>> {
if class.dataclass_params(db).is_some() {
Some(CodeGeneratorKind::DataclassLike(None))
} else if let Ok((_, Some(transformer_params))) = class.try_metaclass(db) {
Some(CodeGeneratorKind::DataclassLike(Some(transformer_params)))
} else if let Some(transformer_params) =
class.iter_mro(db, specialization).skip(1).find_map(|base| {
base.into_class().and_then(|class| {
class.class_literal(db).0.dataclass_transformer_params(db)
})
})
{
Some(CodeGeneratorKind::DataclassLike(Some(transformer_params)))
} else if class
.explicit_bases(db)
.contains(&Type::SpecialForm(SpecialFormType::NamedTuple))
@@ -219,6 +232,7 @@ impl<'db> CodeGeneratorKind<'db> {
fn code_generator_of_class_initial<'db>(
_db: &'db dyn Db,
_class: ClassLiteral<'db>,
_specialization: Option<Specialization<'db>>,
) -> Option<CodeGeneratorKind<'db>> {
None
}
@@ -229,21 +243,37 @@ impl<'db> CodeGeneratorKind<'db> {
_value: &Option<CodeGeneratorKind<'db>>,
_count: u32,
_class: ClassLiteral<'db>,
_specialization: Option<Specialization<'db>>,
) -> salsa::CycleRecoveryAction<Option<CodeGeneratorKind<'db>>> {
salsa::CycleRecoveryAction::Iterate
}
code_generator_of_class(db, class)
code_generator_of_class(db, class, specialization)
}
pub(super) fn matches(self, db: &'db dyn Db, class: ClassLiteral<'db>) -> bool {
pub(super) fn matches(
self,
db: &'db dyn Db,
class: ClassLiteral<'db>,
specialization: Option<Specialization<'db>>,
) -> bool {
matches!(
(CodeGeneratorKind::from_class(db, class), self),
(
CodeGeneratorKind::from_class(db, class, specialization),
self
),
(Some(Self::DataclassLike(_)), Self::DataclassLike(_))
| (Some(Self::NamedTuple), Self::NamedTuple)
| (Some(Self::TypedDict), Self::TypedDict)
)
}
pub(super) fn dataclass_transformer_params(self) -> Option<DataclassTransformerParams<'db>> {
match self {
Self::DataclassLike(params) => params,
Self::NamedTuple | Self::TypedDict => None,
}
}
}
/// A specialization of a generic class with a particular assignment of types to typevars.
@@ -2200,7 +2230,7 @@ impl<'db> ClassLiteral<'db> {
};
}
if CodeGeneratorKind::NamedTuple.matches(db, self) {
if CodeGeneratorKind::NamedTuple.matches(db, self, specialization) {
if let Some(field) = self
.own_fields(db, specialization, CodeGeneratorKind::NamedTuple)
.get(name)
@@ -2262,7 +2292,7 @@ impl<'db> ClassLiteral<'db> {
) -> Option<Type<'db>> {
let dataclass_params = self.dataclass_params(db);
let field_policy = CodeGeneratorKind::from_class(db, self)?;
let field_policy = CodeGeneratorKind::from_class(db, self, specialization)?;
let transformer_params =
if let CodeGeneratorKind::DataclassLike(Some(transformer_params)) = field_policy {
@@ -2808,7 +2838,7 @@ impl<'db> ClassLiteral<'db> {
.filter_map(|superclass| {
if let Some(class) = superclass.into_class() {
let (class_literal, specialization) = class.class_literal(db);
if field_policy.matches(db, class_literal) {
if field_policy.matches(db, class_literal, specialization) {
Some((class_literal, specialization))
} else {
None
@@ -3623,7 +3653,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> {
.map(|class| class.variance_of(db, typevar));
let default_attribute_variance = {
let is_namedtuple = CodeGeneratorKind::NamedTuple.matches(db, self);
let is_namedtuple = CodeGeneratorKind::NamedTuple.matches(db, self, None);
// Python 3.13 introduced a synthesized `__replace__` method on dataclasses which uses
// their field types in contravariant position, thus meaning a frozen dataclass must
// still be invariant in its field types. Other synthesized methods on dataclasses are

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ pub struct DisplaySettings<'db> {
/// Class names that should be displayed fully qualified
/// (e.g., `module.ClassName` instead of just `ClassName`)
pub qualified: Rc<FxHashMap<&'db str, QualificationLevel>>,
/// Whether long unions are displayed in full
/// Whether long unions and literals are displayed in full
pub preserve_full_unions: bool,
}
@@ -1328,6 +1328,44 @@ impl Display for DisplayParameter<'_> {
}
}
#[derive(Debug, Copy, Clone)]
struct TruncationPolicy {
max: usize,
max_when_elided: usize,
}
impl TruncationPolicy {
fn display_limit(self, total: usize, preserve_full: bool) -> usize {
if preserve_full {
return total;
}
let limit = if total > self.max {
self.max_when_elided
} else {
self.max
};
limit.min(total)
}
}
#[derive(Debug)]
struct DisplayOmitted {
count: usize,
singular: &'static str,
plural: &'static str,
}
impl Display for DisplayOmitted {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let noun = if self.count == 1 {
self.singular
} else {
self.plural
};
write!(f, "... omitted {} {}", self.count, noun)
}
}
impl<'db> UnionType<'db> {
fn display_with(
&'db self,
@@ -1348,8 +1386,10 @@ struct DisplayUnionType<'db> {
settings: DisplaySettings<'db>,
}
const MAX_DISPLAYED_UNION_ITEMS: usize = 5;
const MAX_DISPLAYED_UNION_ITEMS_WHEN_ELIDED: usize = 3;
const UNION_POLICY: TruncationPolicy = TruncationPolicy {
max: 5,
max_when_elided: 3,
};
impl Display for DisplayUnionType<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
@@ -1379,16 +1419,8 @@ impl Display for DisplayUnionType<'_> {
let mut join = f.join(" | ");
let display_limit = if self.settings.preserve_full_unions {
total_entries
} else {
let limit = if total_entries > MAX_DISPLAYED_UNION_ITEMS {
MAX_DISPLAYED_UNION_ITEMS_WHEN_ELIDED
} else {
MAX_DISPLAYED_UNION_ITEMS
};
limit.min(total_entries)
};
let display_limit =
UNION_POLICY.display_limit(total_entries, self.settings.preserve_full_unions);
let mut condensed_types = Some(condensed_types);
let mut displayed_entries = 0usize;
@@ -1420,8 +1452,10 @@ impl Display for DisplayUnionType<'_> {
if !self.settings.preserve_full_unions {
let omitted_entries = total_entries.saturating_sub(displayed_entries);
if omitted_entries > 0 {
join.entry(&DisplayUnionOmitted {
join.entry(&DisplayOmitted {
count: omitted_entries,
singular: "union element",
plural: "union elements",
});
}
}
@@ -1437,38 +1471,45 @@ impl fmt::Debug for DisplayUnionType<'_> {
Display::fmt(self, f)
}
}
struct DisplayUnionOmitted {
count: usize,
}
impl Display for DisplayUnionOmitted {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let plural = if self.count == 1 {
"element"
} else {
"elements"
};
write!(f, "... omitted {} union {}", self.count, plural)
}
}
struct DisplayLiteralGroup<'db> {
literals: Vec<Type<'db>>,
db: &'db dyn Db,
settings: DisplaySettings<'db>,
}
const LITERAL_POLICY: TruncationPolicy = TruncationPolicy {
max: 7,
max_when_elided: 5,
};
impl Display for DisplayLiteralGroup<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("Literal[")?;
f.join(", ")
.entries(
self.literals
.iter()
.map(|ty| ty.representation(self.db, self.settings.singleline())),
)
.finish()?;
let total_entries = self.literals.len();
let display_limit =
LITERAL_POLICY.display_limit(total_entries, self.settings.preserve_full_unions);
let mut join = f.join(", ");
for lit in self.literals.iter().take(display_limit) {
let rep = lit.representation(self.db, self.settings.singleline());
join.entry(&rep);
}
if !self.settings.preserve_full_unions {
let omitted_entries = total_entries.saturating_sub(display_limit);
if omitted_entries > 0 {
join.entry(&DisplayOmitted {
count: omitted_entries,
singular: "literal",
plural: "literals",
});
}
}
join.finish()?;
f.write_str("]")
}
}

View File

@@ -122,7 +122,7 @@ impl<'db> AllMembers<'db> {
self.extend_with_instance_members(db, ty, class_literal);
// If this is a NamedTuple instance, include members from NamedTupleFallback
if CodeGeneratorKind::NamedTuple.matches(db, class_literal) {
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db));
}
}
@@ -142,7 +142,7 @@ impl<'db> AllMembers<'db> {
Type::ClassLiteral(class_literal) => {
self.extend_with_class_members(db, ty, class_literal);
if CodeGeneratorKind::NamedTuple.matches(db, class_literal) {
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db));
}
@@ -153,7 +153,7 @@ impl<'db> AllMembers<'db> {
Type::GenericAlias(generic_alias) => {
let class_literal = generic_alias.origin(db);
if CodeGeneratorKind::NamedTuple.matches(db, class_literal) {
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db));
}
self.extend_with_class_members(db, ty, class_literal);
@@ -164,7 +164,7 @@ impl<'db> AllMembers<'db> {
let class_literal = class_type.class_literal(db).0;
self.extend_with_class_members(db, ty, class_literal);
if CodeGeneratorKind::NamedTuple.matches(db, class_literal) {
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(
db,
KnownClass::NamedTupleFallback.to_class_literal(db),

View File

@@ -577,7 +577,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
continue;
}
let is_named_tuple = CodeGeneratorKind::NamedTuple.matches(self.db(), class);
let is_named_tuple = CodeGeneratorKind::NamedTuple.matches(self.db(), class, None);
// (2) If it's a `NamedTuple` class, check that no field without a default value
// appears after a field with a default value.
@@ -898,7 +898,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// (7) Check that a dataclass does not have more than one `KW_ONLY`.
if let Some(field_policy @ CodeGeneratorKind::DataclassLike(_)) =
CodeGeneratorKind::from_class(self.db(), class)
CodeGeneratorKind::from_class(self.db(), class, None)
{
let specialization = None;
@@ -4569,11 +4569,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
.dataclass_params(db)
.map(|params| SmallVec::from(params.field_specifiers(db)))
.or_else(|| {
class_literal
.try_metaclass(db)
.ok()
.and_then(|(_, params)| params)
.map(|params| SmallVec::from(params.field_specifiers(db)))
Some(SmallVec::from(
CodeGeneratorKind::from_class(db, class_literal, None)?
.dataclass_transformer_params()?
.field_specifiers(db),
))
})
}

View File

@@ -79,6 +79,15 @@ export function persistLocal({
settingsSource: string;
pythonSource: string;
}) {
const totalLength = settingsSource.length + pythonSource.length;
// Don't persist large files to local storage because they can exceed the local storage quota
// The number here is picked rarely arbitrarily. Also note, JS uses UTF 16:
// that means the limit here is strings larger than 1MB (because UTf 16 uses 2 bytes per character)
if (totalLength > 500_000) {
return;
}
localStorage.setItem(
"source",
JSON.stringify([settingsSource, pythonSource]),

View File

@@ -40,6 +40,18 @@ export async function restore(): Promise<Workspace | null> {
}
export function persistLocal(workspace: Workspace) {
let totalLength = 0;
for (const fileContent of Object.values(workspace.files)) {
totalLength += fileContent.length;
// Don't persist large files to local storage because they can exceed the local storage quota
// The number here is picked rarely arbitrarily. Also note, JS uses UTF 16:
// that means the limit here is strings larger than 1MB (because UTf 16 uses 2 bytes per character)
if (totalLength > 500_000) {
return;
}
}
localStorage.setItem("workspace", JSON.stringify(workspace));
}

View File

@@ -152,16 +152,13 @@ class FuzzResult:
def fuzz_code(seed: Seed, args: ResolvedCliArgs) -> FuzzResult:
"""Return a `FuzzResult` instance describing the fuzzing result from this seed."""
# TODO debug slowness of these seeds
skip_check = seed in {32, 56, 208}
code = generate_random_code(seed)
bug_found = False
minimizer_callback: Callable[[str], bool] | None = None
if args.baseline_executable_path is None:
only_new_bugs = False
if not skip_check and contains_bug(
if contains_bug(
code, executable=args.executable, executable_path=args.test_executable_path
):
bug_found = True
@@ -172,7 +169,7 @@ def fuzz_code(seed: Seed, args: ResolvedCliArgs) -> FuzzResult:
)
else:
only_new_bugs = True
if not skip_check and contains_new_bug(
if contains_new_bug(
code,
executable=args.executable,
test_executable_path=args.test_executable_path,