Compare commits

...

38 Commits

Author SHA1 Message Date
Aria Desires
6caa37fb27 support rendering __doc__ on hover 2025-12-18 01:11:15 -05:00
Shantanu
cf8d2e35a8 New rule to prevent implicit string concatenation in collections (#21972)
This is a common footgun, see the example in
https://github.com/astral-sh/ruff/issues/13014#issuecomment-3411496519

Fixes #13014 , fixes #13031
2025-12-17 17:37:01 -05:00
Brent Westbrook
0290f5dc3b [flake8-bandit] Fix broken link (S704) (#22039)
Summary
--

While going through all the rules I noticed a broken link in the S704
docs. (I then got really confused because it appeared to be correct in
the _other_ `unsafe_markup_use.rs` file for the removed RUF version of
the rule).

Test Plan
--

[Before](https://docs.astral.sh/ruff/rules/unsafe-markup-use/):

<img width="537" height="171" alt="image"
src="https://github.com/user-attachments/assets/01007cab-6673-48e5-b3a5-6006bc78a027"
/>


After:

<img width="451" height="189" alt="image"
src="https://github.com/user-attachments/assets/4e5d0e0d-76be-4f66-b747-e209f11ab11a"
/>

I also did at least a cursory grep for other cases with escaped link
brackets (`\[`) and only turned up this rule on main.
2025-12-17 17:35:04 -05:00
Charlie Marsh
5bb9ee2a9d [ty] Respect deferred values in keyword arguments et al for .pyi files (#22029)
## Summary

Closes https://github.com/astral-sh/ty/issues/2019.
2025-12-17 14:02:10 -08:00
Aria Desires
638f230910 [ty] improve rendering of signatures in hovers (#22007)
This is the return of #21438 because we never found anything better and
I think it would be good to have this for the beta.
2025-12-17 20:09:31 +00:00
Douglas Creager
b36ff75a24 [ty] Don't add identical lower/upper bounds multiple times when inferring specializations (#22030)
When inferring a specialization of a `Callable` type, we use the new
constraint set implementation. In the example in
https://github.com/astral-sh/ty/issues/1968, we end up with a constraint
set that includes all of the following clauses:

```
     U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M1 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M2 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M3 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M4 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M5 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M6 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
M7 ≤ U_co ≤ M1 | M2 | M3 | M4 | M5 | M6 | M7
```

In general, we take the upper bounds of those constraints to get the
specialization. However, the upper bounds of those constraints are not
all guaranteed to be the same, and so first we need to intersect them
all together. In this case, the upper bounds are all identical, so their
intersection is trivial:

```
U_co = M1 | M2 | M3 | M4 | M5 | M6 | M7
```

But we were still doing the work of calculating that trivial
intersection 7 times. And each time we have to do 7^2 comparisons of the
`M*` classes, ending up with O(n^3) overall work.

This pattern is common enough that we can put in a quick heuristic to
prune identical copies of the same type before performing the
intersection.

Fixes https://github.com/astral-sh/ty/issues/1968
2025-12-17 13:35:52 -05:00
Charlie Marsh
30c3f9aafe [ty] Apply narrowing to len calls based on argument size (#22026)
## Summary

Closes https://github.com/astral-sh/ty/issues/1983.
2025-12-17 13:15:58 -05:00
chiri
883701ae88 [flake8-use-pathlib] Make fixes unsafe when types change in compound statements (PTH104, PTH105, PTH109, PTH115) (#22009)
## Summary

Fixes https://github.com/astral-sh/ruff/issues/21794

## Test Plan

`cargo nextest run flake8_use_pathlib`
2025-12-17 12:31:27 -05:00
Alex Waygood
0bd7a94c27 [ty] Improve unsupported-base and invalid-super-argument diagnostics to avoid extremely long lines when encountering verbose types (#22022) 2025-12-17 14:43:11 +00:00
Bhuminjay Soni
421f88bb32 [refurb] Extend support for Path.open (FURB101, FURB103) (#21080)
## Summary

<!-- What's the purpose of the change? What does it do, and why? -->
This PR fixes https://github.com/astral-sh/ruff/issues/18409

## Test Plan

<!-- How was it tested? -->
I have added tests in FURB103.

---------

Signed-off-by: 11happy <soni5happy@gmail.com>
Signed-off-by: 11happy <bhuminjaysoni@gmail.com>
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
2025-12-17 09:18:13 -05:00
David Peter
b0eb39d112 [ty] ecosystem-analyzer: Flush full stderr output in case of panics (#22023)
Pulls in
2e1816eac0
2025-12-17 15:02:59 +01:00
charliecloudberry
260f463edd Update setup.md (#22024) 2025-12-17 14:56:25 +01:00
Bhuminjay Soni
52849a5e68 [syntax-errors] Annotated name cannot be global (#20868)
## Summary

<!-- What's the purpose of the change? What does it do, and why? -->
This PR implements a new semantic syntax error where annotated name
can't be global
example
```
x: int = 1

def f():
    global x
    x: str = "foo"  # SyntaxError: annotated name 'x' can't be global
 ```

## Test Plan

<!-- How was it tested? -->
I have written tests as directed in #17412

---------

Signed-off-by: 11happy <soni5happy@gmail.com>
Signed-off-by: 11happy <bhuminjaysoni@gmail.com>
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
2025-12-17 08:39:47 -05:00
David Peter
2a61fe2353 [ty] Handle field specifier functions that accept **kwargs and recognize metaclass-based transformers as instances of DataclassInstance (#22018)
## Summary

This contains two bug fixes:

- [Handle field specifier functions that accept
`**kwargs`](ad6918d505)
- [Recognize metaclass-based transformers as instances of
`DataclassInstance`](1a8e29b23c)

closes https://github.com/astral-sh/ty/issues/1987

## Test Plan

* New Markdown tests
* Made sure that the example in 1987 checks without errors
2025-12-17 14:22:16 +01:00
Alex Waygood
764ad8b29b [ty] Improve disambiguation of types in many cases (#22019) 2025-12-17 11:41:07 +00:00
mahiro
85af715880 Fix playground Share button showing "Copied!" before clipboard copy completes (#21942)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-17 12:16:01 +01:00
Phong Do
b0bc990cbf [pyupgrade] Fix parsing named Unicode escape sequences (UP032) (#21901)
## Summary

Fixes https://github.com/astral-sh/ruff/issues/19771

Fixes incorrect parsing of Unicode named escape sequences like `Hey
\N{snowman}` in `FormatString`, which were being incorrectly split into
separate literal and field parts instead of being treated as a single
literal unit.

## Problem

The `FormatString` parser incorrectly handles Unicode named escape
sequences:
- **Current**: `Hey \N{snowman}` is parsed into 2 parts `Literal("Hey
\N")` & `Field("snowman")`
- **Expected**: `Hey \N{snowman}` should be parsed into 1 part
`Literal("Hey \N{snowman}")`

This affects f-string conversion rules when fixing `UP032` that rely on
proper format string parsing.

## Solution

I modified `parse_literal` to detect and handle Unicode named escape
sequences before parsing single characters:
- Introduced a flag to track when a backslash is "available" to escape
something.
- When the flag is `true`, and the text starts with `N{`, try to parse
the complete Unicode escape sequence as one unit, and set the flag to
`false` after parsing successfully.
- Set the flag to `false` when the backslash is already consumed.

## Manual Verification

`"\N{angle}AOB = {angle}°".format(angle=180)` 

**Result**

```bash
 def foo():
-    "\N{angle}AOB = {angle}°".format(angle=180)
+    f"\N{angle}AOB = {180}°"

Would fix 1 error.
```

`"\N{snowman} {snowman}".format(snowman=1)`

**Result**
```bash
 def foo():
-    "\N{snowman} {snowman}".format(snowman=1)
+    f"\N{snowman} {1}"

Would fix 1 error.
```

`"\\N{snowman} {snowman}".format(snowman=1)`

**Result**
```bash
 def foo():
-    "\\N{snowman} {snowman}".format(snowman=1)
+    f"\\N{1} {1}"

Would fix 1 error.
```

## Test Plan

- Added test cases (happy case, invalid case, edge case) for
`FormatString` when parsing Unicode escape sequence.
- Updated snapshots.
2025-12-16 16:33:39 -05:00
Aria Desires
ad3de4e488 [ty] Improve rendering of default values for function args (#22010)
## Summary

We're actually quite good at computing this but the main issue is just
that we compute it at the type-level and so wrap it in `Literal[...]`.
So just special-case the rendering of these to omit `Literal[...]` and
fallback to `...` in cases where the thing we'll show is probably
useless (i.e. `x: str = str`).

Fixes https://github.com/astral-sh/ty/issues/1882
2025-12-16 13:39:19 -05:00
Douglas Creager
2214a46139 [ty] Don't use implicit superclass annotation when converting a class constructor into a Callable (#22011)
This fixes a bug @zsol found running ty against pyx. His original repro
is:

```py
class Base:
    def __init__(self) -> None: pass

class A(Base):
    pass

def foo[T](callable: Callable[..., T]) -> T:
    return callable()

a: A = foo(A)
```

The call at the bottom would fail, since we would infer `() -> Base` as
the callable type of `A`, when it should be `() -> A`.

The issue was how we add implicit annotations to `self` parameters.
Typically, we turn it into `self: Self`. But in cases where we don't
need to introduce a full typevar, we turn it into `self: [the class
itself]` — in this case, `self: Base`. Then, when turning the class
constructor into a callable, we would see this non-`Self` annotation and
think that it was important and load-bearing.

The fix is that we skip all implicit annotations when determining
whether the `self` annotation should take precedence in the callable's
return type.
2025-12-16 13:37:11 -05:00
Douglas Creager
c02bd11b93 [ty] Infer typevar specializations for Callable types (#21551)
This is a first stab at solving
https://github.com/astral-sh/ty/issues/500, at least in part, with the
old solver. We add a new `TypeRelation` that lets us opt into using
constraint sets to describe when a typevar is assignability to some
type, and then use that to calculate a constraint set that describes
when two callable types are assignable. If the callable types contain
typevars, that constraint set will describe their valid specializations.
We can then walk through all of the ways the constraint set can be
satisfied, and record a type mapping in the old solver for each one.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-12-16 09:16:49 -08:00
Carl Meyer
eeaaa8e9fe [ty] propagate classmethod-ness through decorators returning Callables (#21958)
Fixes https://github.com/astral-sh/ty/issues/1787

## Summary

Allow method decorators returning Callables to presumptively propagate
"classmethod-ness" in the same way that they already presumptively
propagate "function-like-ness". We can't actually be sure that this is
the case, based on the decorator's annotations, but (along with other
type checkers) we heuristically assume it to be the case for decorators
applied via decorator syntax.

## Test Plan

Added mdtest.
2025-12-16 09:16:40 -08:00
Rob Hand
7f7485d608 [ty] Fixed benchmark markdown auto-linenumbers (#22008) 2025-12-16 16:38:11 +01:00
Aria Desires
d755f3b522 [ty] Improve syntax-highlighting of constants (#22006)
## Summary

BLAH1 getting different highlighting/classification from BLAH is very
distracting and undesirable.
2025-12-16 09:23:40 -05:00
Aria Desires
83168a1bb1 [ty] highlight special type syntax in hovers as xml (#22005)
## Summary

These types look better rendered as XML than python

## Test Plan

<img width="532" height="299" alt="Screenshot 2025-12-16 at 8 40 56 AM"
src="https://github.com/user-attachments/assets/42d9abfa-3f4a-44ba-b6b4-6700ab06832d"
/>
2025-12-16 14:20:35 +00:00
Andrew Gallant
0f373603eb [ty] Suppress keyword argument completions unless we're in the "arguments"
Otherwise, given a case like this:

```
(lambda foo: (<CURSOR> + 1))(2)
```

we'll offer _argument_ completions for `foo` at the cursor position.
While we do actually want to offer completions for `foo` in this
context, it is currently difficult to do so. But we definitely don't
want to offer completions for `foo` as an argument to a function here.
Which is what we were doing.

We also add an end-to-end test here to verify that the actual label we
offer in completion suggestions includes the `=` suffix.

Closes https://github.com/astral-sh/ruff/pull/21970
2025-12-16 08:00:04 -05:00
Andrew Gallant
cc23af944f [ty] Tweak how we show qualified auto-import completions
Specifically, we make two changes:

1. We only show `import ...` when there is an actual import edit.
2. We now show the text we will insert. This means that when we
insert a qualified symbol, the qualification will show in the
completions suggested.

Ref https://github.com/astral-sh/ty/issues/1274#issuecomment-3352233790
2025-12-16 08:00:04 -05:00
Andrew Gallant
0589700ca1 [ty] Prefer unqualified imports over qualified imports
It seems like this is perhaps a better default:
https://github.com/astral-sh/ty/issues/1274#issuecomment-3352233790

For me personally, I think I'd prefer the qualified
variant. But I can easily see this changing based on
the specific scenario. I think the thing that pushes
me toward prioritizing the unqualified variant is that
the user could have typed the qualified variant themselves,
but they didn't. So we should perhaps prioritize the
form they typed, which is unqualified.
2025-12-16 08:00:04 -05:00
Andrew Gallant
43d983ecae [ty] Add tests for how qualified auto-imports are shown
Specifically, we want to test that something like `import typing`
should only be shown when we are actually going to insert an import.
*And* that when we insert a qualified name, then we should show it
as such in the completion suggestions.
2025-12-16 08:00:04 -05:00
Andrew Gallant
5c69bb564c [ty] Add a test capturing sub-optimal auto-import heuristic
Specifically, here, we'd probably like to add `TypedDict` to
the existing `from typing import ...` statement instead of
using the fully qualified `typing.TypedDict` form.

To test this, we add another snapshot mode for including imports
that a completion will insert when selected.

Ref https://github.com/astral-sh/ty/issues/1274#issuecomment-3352233790
2025-12-16 08:00:04 -05:00
Andrew Gallant
89fed85a8d [ty] Switch completion tests to use "insertion" text in snapshots
I think this gives us better tests overall, since it
tells us what is actually going to be inserted. Plus,
we'll want this in the next commit.
2025-12-16 08:00:04 -05:00
Zanie Blue
051f6896ac [ty] Remove extra headings and split examples in the overrides configuration docs (#21994)
Having these as markdown headings ends up being weird in the reference
documentation, e.g., before:

<img width="1071" height="779" alt="Screenshot 2025-12-15 at 8 45 25 PM"
src="https://github.com/user-attachments/assets/2118d4f1-f557-46f3-a4b6-56c406cf9aca"
/>
2025-12-16 06:57:06 -06:00
David Peter
5b1d3ac9b9 [ty] Document TY_CONFIG_FILE (#22001) 2025-12-16 13:15:24 +01:00
Micha Reiser
b2b0ad38ea [ty] Cache KnownClass::to_class_literal (#22000) 2025-12-16 13:04:12 +01:00
Micha Reiser
01c0a3e960 [ty] Fix benchmark assertion (#22003) 2025-12-16 12:24:54 +01:00
Zanie Blue
5c942119f8 Add uv and ty to the Ruff README (#21996)
Matching the style of the uv README
2025-12-16 10:37:15 +01:00
David Peter
2acf1cc0fd [ty] Infer precise types for isinstance(…) calls involving typevars (#21999)
## Summary

Infer `Literal[True]` for `isinstance(x, C)` calls when `x: T` and `T`
has a bound `B` that satisfies the `isinstance` check against `C`.
Similar for constrained typevars.

closes https://github.com/astral-sh/ty/issues/1895

## Test Plan

* New Markdown tests
* Verified the the example in the linked ticket checks without errors
2025-12-16 10:34:30 +01:00
Micha Reiser
4fdbe26445 [ty] Use FxHashMap in Signature::has_relation_to (#21997) 2025-12-16 10:10:45 +01:00
Charlie Marsh
682d29c256 [ty] Avoid enforcing standalone expression for tests in f-strings (#21967)
## Summary

Based on what we do elsewhere and my understanding of "standalone"
here...

Closes https://github.com/astral-sh/ty/issues/1865.
2025-12-15 22:31:04 -05:00
140 changed files with 4680 additions and 1094 deletions

View File

@@ -67,7 +67,7 @@ jobs:
cd ..
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@55df3c868f3fa9ab34cff0498dd6106722aac205"
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@2e1816eac09c90140b1ba51d19afc5f59da460f5"
ecosystem-analyzer \
--repository ruff \

View File

@@ -52,7 +52,7 @@ jobs:
cd ..
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@55df3c868f3fa9ab34cff0498dd6106722aac205"
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@2e1816eac09c90140b1ba51d19afc5f59da460f5"
ecosystem-analyzer \
--verbose \

View File

@@ -57,8 +57,11 @@ Ruff is extremely actively developed and used in major open-source projects like
...and [many more](#whos-using-ruff).
Ruff is backed by [Astral](https://astral.sh). Read the [launch post](https://astral.sh/blog/announcing-astral-the-company-behind-ruff),
or the original [project announcement](https://notes.crmarsh.com/python-tooling-could-be-much-much-faster).
Ruff is backed by [Astral](https://astral.sh), the creators of
[uv](https://github.com/astral-sh/uv) and [ty](https://github.com/astral-sh/ty).
Read the [launch post](https://astral.sh/blog/announcing-astral-the-company-behind-ruff), or the
original [project announcement](https://notes.crmarsh.com/python-tooling-could-be-much-much-faster).
## Testimonials

View File

@@ -4,6 +4,7 @@ extend-exclude = [
"crates/ty_vendored/vendor/**/*",
"**/resources/**/*",
"**/snapshots/**/*",
"crates/ruff_linter/src/rules/flake8_implicit_str_concat/rules/collection_literal.rs",
# Completion tests tend to have a lot of incomplete
# words naturally. It's annoying to have to make all
# of them actually words. So just ignore typos here.

View File

@@ -194,7 +194,7 @@ static SYMPY: Benchmark = Benchmark::new(
max_dep_date: "2025-06-17",
python_version: PythonVersion::PY312,
},
13030,
13100,
);
static TANJUN: Benchmark = Benchmark::new(
@@ -223,7 +223,7 @@ static STATIC_FRAME: Benchmark = Benchmark::new(
max_dep_date: "2025-08-09",
python_version: PythonVersion::PY311,
},
950,
1100,
);
#[track_caller]

View File

@@ -0,0 +1,66 @@
facts = (
"Lobsters have blue blood.",
"The liver is the only human organ that can fully regenerate itself.",
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon.",
)
facts = [
"Lobsters have blue blood.",
"The liver is the only human organ that can fully regenerate itself.",
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon.",
]
facts = {
"Lobsters have blue blood.",
"The liver is the only human organ that can fully regenerate itself.",
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon.",
}
facts = {
(
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon."
),
}
facts = (
"Octopuses have three hearts."
# Missing comma here.
"Honey never spoils.",
)
facts = [
"Octopuses have three hearts."
# Missing comma here.
"Honey never spoils.",
]
facts = {
"Octopuses have three hearts."
# Missing comma here.
"Honey never spoils.",
}
facts = (
(
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon."
),
)
facts = [
(
"Clarinets are made almost entirely out of wood from the mpingo tree."
"In 1971, astronaut Alan Shepard played golf on the moon."
),
]
facts = (
"Lobsters have blue blood.\n"
"The liver is the only human organ that can fully regenerate itself.\n"
"Clarinets are made almost entirely out of wood from the mpingo tree.\n"
"In 1971, astronaut Alan Shepard played golf on the moon.\n"
)

View File

@@ -136,4 +136,38 @@ os.chmod("pth1_file", 0o700, None, True, 1, *[1], **{"x": 1}, foo=1)
os.rename("pth1_file", "pth1_file1", None, None, 1, *[1], **{"x": 1}, foo=1)
os.replace("pth1_file1", "pth1_file", None, None, 1, *[1], **{"x": 1}, foo=1)
os.path.samefile("pth1_file", "pth1_link", 1, *[1], **{"x": 1}, foo=1)
os.path.samefile("pth1_file", "pth1_link", 1, *[1], **{"x": 1}, foo=1)
# See: https://github.com/astral-sh/ruff/issues/21794
import sys
if os.rename("pth1.py", "pth1.py.bak"):
print("rename: truthy")
else:
print("rename: falsey")
if os.replace("pth1.py.bak", "pth1.py"):
print("replace: truthy")
else:
print("replace: falsey")
try:
for _ in os.getcwd():
print("getcwd: iterable")
break
except TypeError as e:
print("getcwd: not iterable")
try:
for _ in os.getcwdb():
print("getcwdb: iterable")
break
except TypeError as e:
print("getcwdb: not iterable")
try:
for _ in os.readlink(sys.executable):
print("readlink: iterable")
break
except TypeError as e:
print("readlink: not iterable")

View File

@@ -132,7 +132,6 @@ async def c():
# Non-errors
###
# False-negative: RustPython doesn't parse the `\N{snowman}`.
"\N{snowman} {}".format(a)
"{".format(a)
@@ -276,3 +275,6 @@ if __name__ == "__main__":
number = 0
string = "{}".format(number := number + 1)
print(string)
# Unicode escape
"\N{angle}AOB = {angle}°".format(angle=180)

View File

@@ -138,5 +138,6 @@ with open("file.txt", encoding="utf-8") as f:
with open("file.txt", encoding="utf-8") as f:
contents = process_contents(f.read())
with open("file.txt", encoding="utf-8") as f:
with open("file1.txt", encoding="utf-8") as f:
contents: str = process_contents(f.read())

View File

@@ -0,0 +1,8 @@
from pathlib import Path
with Path("file.txt").open() as f:
contents = f.read()
with Path("file.txt").open("r") as f:
contents = f.read()

View File

@@ -0,0 +1,26 @@
from pathlib import Path
with Path("file.txt").open("w") as f:
f.write("test")
with Path("file.txt").open("wb") as f:
f.write(b"test")
with Path("file.txt").open(mode="w") as f:
f.write("test")
with Path("file.txt").open("w", encoding="utf8") as f:
f.write("test")
with Path("file.txt").open("w", errors="ignore") as f:
f.write("test")
with Path(foo()).open("w") as f:
f.write("test")
p = Path("file.txt")
with p.open("w") as f:
f.write("test")
with Path("foo", "bar", "baz").open("w") as f:
f.write("test")

View File

@@ -0,0 +1,38 @@
a: int = 1
def f1():
global a
a: str = "foo" # error
b: int = 1
def outer():
def inner():
global b
b: str = "nested" # error
c: int = 1
def f2():
global c
c: list[str] = [] # error
d: int = 1
def f3():
global d
d: str # error
e: int = 1
def f4():
e: str = "happy" # okay
global f
f: int = 1 # okay
g: int = 1
global g # error
class C:
x: str
global x # error
class D:
global x # error
x: str

View File

@@ -214,6 +214,13 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
range: _,
node_index: _,
}) => {
if checker.is_rule_enabled(Rule::ImplicitStringConcatenationInCollectionLiteral) {
flake8_implicit_str_concat::rules::implicit_string_concatenation_in_collection_literal(
checker,
expr,
elts,
);
}
if ctx.is_store() {
let check_too_many_expressions =
checker.is_rule_enabled(Rule::ExpressionsInStarAssignment);
@@ -1329,6 +1336,13 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
}
}
Expr::Set(set) => {
if checker.is_rule_enabled(Rule::ImplicitStringConcatenationInCollectionLiteral) {
flake8_implicit_str_concat::rules::implicit_string_concatenation_in_collection_literal(
checker,
expr,
&set.elts,
);
}
if checker.is_rule_enabled(Rule::DuplicateValue) {
flake8_bugbear::rules::duplicate_value(checker, set);
}

View File

@@ -454,6 +454,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8ImplicitStrConcat, "001") => rules::flake8_implicit_str_concat::rules::SingleLineImplicitStringConcatenation,
(Flake8ImplicitStrConcat, "002") => rules::flake8_implicit_str_concat::rules::MultiLineImplicitStringConcatenation,
(Flake8ImplicitStrConcat, "003") => rules::flake8_implicit_str_concat::rules::ExplicitStringConcatenation,
(Flake8ImplicitStrConcat, "004") => rules::flake8_implicit_str_concat::rules::ImplicitStringConcatenationInCollectionLiteral,
// flake8-print
(Flake8Print, "1") => rules::flake8_print::rules::Print,

View File

@@ -1001,6 +1001,7 @@ mod tests {
#[test_case(Path::new("write_to_debug.py"), PythonVersion::PY310)]
#[test_case(Path::new("invalid_expression.py"), PythonVersion::PY312)]
#[test_case(Path::new("global_parameter.py"), PythonVersion::PY310)]
#[test_case(Path::new("annotated_global.py"), PythonVersion::PY314)]
fn test_semantic_errors(path: &Path, python_version: PythonVersion) -> Result<()> {
let snapshot = format!(
"semantic_syntax_error_{}_{}",

View File

@@ -70,7 +70,7 @@ fn is_open_call(func: &Expr, semantic: &SemanticModel) -> bool {
}
/// Returns `true` if an expression resolves to a call to `pathlib.Path.open`.
fn is_open_call_from_pathlib(func: &Expr, semantic: &SemanticModel) -> bool {
pub(crate) fn is_open_call_from_pathlib(func: &Expr, semantic: &SemanticModel) -> bool {
let Expr::Attribute(ast::ExprAttribute { attr, value, .. }) = func else {
return false;
};

View File

@@ -18,7 +18,7 @@ mod async_zero_sleep;
mod blocking_http_call;
mod blocking_http_call_httpx;
mod blocking_input;
mod blocking_open_call;
pub(crate) mod blocking_open_call;
mod blocking_path_methods;
mod blocking_process_invocation;
mod blocking_sleep;

View File

@@ -12,7 +12,7 @@ use crate::{checkers::ast::Checker, settings::LinterSettings};
/// Checks for non-literal strings being passed to [`markupsafe.Markup`][markupsafe-markup].
///
/// ## Why is this bad?
/// [`markupsafe.Markup`] does not perform any escaping, so passing dynamic
/// [`markupsafe.Markup`][markupsafe-markup] does not perform any escaping, so passing dynamic
/// content, like f-strings, variables or interpolated strings will potentially
/// lead to XSS vulnerabilities.
///

View File

@@ -32,6 +32,10 @@ mod tests {
Path::new("ISC_syntax_error_2.py")
)]
#[test_case(Rule::ExplicitStringConcatenation, Path::new("ISC.py"))]
#[test_case(
Rule::ImplicitStringConcatenationInCollectionLiteral,
Path::new("ISC004.py")
)]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(

View File

@@ -0,0 +1,103 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::token::parenthesized_range;
use ruff_python_ast::{Expr, StringLike};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::{Edit, Fix, FixAvailability, Violation};
/// ## What it does
/// Checks for implicitly concatenated strings inside list, tuple, and set literals.
///
/// ## Why is this bad?
/// In collection literals, implicit string concatenation is often the result of
/// a missing comma between elements, which can silently merge items together.
///
/// ## Example
/// ```python
/// facts = (
/// "Lobsters have blue blood.",
/// "The liver is the only human organ that can fully regenerate itself.",
/// "Clarinets are made almost entirely out of wood from the mpingo tree."
/// "In 1971, astronaut Alan Shepard played golf on the moon.",
/// )
/// ```
///
/// Instead, you likely intended:
/// ```python
/// facts = (
/// "Lobsters have blue blood.",
/// "The liver is the only human organ that can fully regenerate itself.",
/// "Clarinets are made almost entirely out of wood from the mpingo tree.",
/// "In 1971, astronaut Alan Shepard played golf on the moon.",
/// )
/// ```
///
/// If the concatenation is intentional, wrap it in parentheses to make it
/// explicit:
/// ```python
/// facts = (
/// "Lobsters have blue blood.",
/// "The liver is the only human organ that can fully regenerate itself.",
/// (
/// "Clarinets are made almost entirely out of wood from the mpingo tree."
/// "In 1971, astronaut Alan Shepard played golf on the moon."
/// ),
/// )
/// ```
///
/// ## Fix safety
/// The fix is safe in that it does not change the semantics of your code.
/// However, the issue is that you may often want to change semantics
/// by adding a missing comma.
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.10")]
pub(crate) struct ImplicitStringConcatenationInCollectionLiteral;
impl Violation for ImplicitStringConcatenationInCollectionLiteral {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
#[derive_message_formats]
fn message(&self) -> String {
"Unparenthesized implicit string concatenation in collection".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Wrap implicitly concatenated strings in parentheses".to_string())
}
}
/// ISC004
pub(crate) fn implicit_string_concatenation_in_collection_literal(
checker: &Checker,
expr: &Expr,
elements: &[Expr],
) {
for element in elements {
let Ok(string_like) = StringLike::try_from(element) else {
continue;
};
if !string_like.is_implicit_concatenated() {
continue;
}
if parenthesized_range(
string_like.as_expression_ref(),
expr.into(),
checker.tokens(),
)
.is_some()
{
continue;
}
let mut diagnostic = checker.report_diagnostic(
ImplicitStringConcatenationInCollectionLiteral,
string_like.range(),
);
diagnostic.help("Did you forget a comma?");
diagnostic.set_fix(Fix::unsafe_edits(
Edit::insertion("(".to_string(), string_like.range().start()),
[Edit::insertion(")".to_string(), string_like.range().end())],
));
}
}

View File

@@ -1,5 +1,7 @@
pub(crate) use collection_literal::*;
pub(crate) use explicit::*;
pub(crate) use implicit::*;
mod collection_literal;
mod explicit;
mod implicit;

View File

@@ -0,0 +1,149 @@
---
source: crates/ruff_linter/src/rules/flake8_implicit_str_concat/mod.rs
---
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:4:5
|
2 | "Lobsters have blue blood.",
3 | "The liver is the only human organ that can fully regenerate itself.",
4 | / "Clarinets are made almost entirely out of wood from the mpingo tree."
5 | | "In 1971, astronaut Alan Shepard played golf on the moon.",
| |______________________________________________________________^
6 | )
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
1 | facts = (
2 | "Lobsters have blue blood.",
3 | "The liver is the only human organ that can fully regenerate itself.",
- "Clarinets are made almost entirely out of wood from the mpingo tree."
- "In 1971, astronaut Alan Shepard played golf on the moon.",
4 + ("Clarinets are made almost entirely out of wood from the mpingo tree."
5 + "In 1971, astronaut Alan Shepard played golf on the moon."),
6 | )
7 |
8 | facts = [
note: This is an unsafe fix and may change runtime behavior
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:11:5
|
9 | "Lobsters have blue blood.",
10 | "The liver is the only human organ that can fully regenerate itself.",
11 | / "Clarinets are made almost entirely out of wood from the mpingo tree."
12 | | "In 1971, astronaut Alan Shepard played golf on the moon.",
| |______________________________________________________________^
13 | ]
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
8 | facts = [
9 | "Lobsters have blue blood.",
10 | "The liver is the only human organ that can fully regenerate itself.",
- "Clarinets are made almost entirely out of wood from the mpingo tree."
- "In 1971, astronaut Alan Shepard played golf on the moon.",
11 + ("Clarinets are made almost entirely out of wood from the mpingo tree."
12 + "In 1971, astronaut Alan Shepard played golf on the moon."),
13 | ]
14 |
15 | facts = {
note: This is an unsafe fix and may change runtime behavior
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:18:5
|
16 | "Lobsters have blue blood.",
17 | "The liver is the only human organ that can fully regenerate itself.",
18 | / "Clarinets are made almost entirely out of wood from the mpingo tree."
19 | | "In 1971, astronaut Alan Shepard played golf on the moon.",
| |______________________________________________________________^
20 | }
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
15 | facts = {
16 | "Lobsters have blue blood.",
17 | "The liver is the only human organ that can fully regenerate itself.",
- "Clarinets are made almost entirely out of wood from the mpingo tree."
- "In 1971, astronaut Alan Shepard played golf on the moon.",
18 + ("Clarinets are made almost entirely out of wood from the mpingo tree."
19 + "In 1971, astronaut Alan Shepard played golf on the moon."),
20 | }
21 |
22 | facts = {
note: This is an unsafe fix and may change runtime behavior
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:30:5
|
29 | facts = (
30 | / "Octopuses have three hearts."
31 | | # Missing comma here.
32 | | "Honey never spoils.",
| |_________________________^
33 | )
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
27 | }
28 |
29 | facts = (
- "Octopuses have three hearts."
30 + ("Octopuses have three hearts."
31 | # Missing comma here.
- "Honey never spoils.",
32 + "Honey never spoils."),
33 | )
34 |
35 | facts = [
note: This is an unsafe fix and may change runtime behavior
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:36:5
|
35 | facts = [
36 | / "Octopuses have three hearts."
37 | | # Missing comma here.
38 | | "Honey never spoils.",
| |_________________________^
39 | ]
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
33 | )
34 |
35 | facts = [
- "Octopuses have three hearts."
36 + ("Octopuses have three hearts."
37 | # Missing comma here.
- "Honey never spoils.",
38 + "Honey never spoils."),
39 | ]
40 |
41 | facts = {
note: This is an unsafe fix and may change runtime behavior
ISC004 [*] Unparenthesized implicit string concatenation in collection
--> ISC004.py:42:5
|
41 | facts = {
42 | / "Octopuses have three hearts."
43 | | # Missing comma here.
44 | | "Honey never spoils.",
| |_________________________^
45 | }
|
help: Wrap implicitly concatenated strings in parentheses
help: Did you forget a comma?
39 | ]
40 |
41 | facts = {
- "Octopuses have three hearts."
42 + ("Octopuses have three hearts."
43 | # Missing comma here.
- "Honey never spoils.",
44 + "Honey never spoils."),
45 | }
46 |
47 | facts = (
note: This is an unsafe fix and may change runtime behavior

View File

@@ -210,6 +210,7 @@ pub(crate) fn is_argument_non_default(arguments: &Arguments, name: &str, positio
/// Returns `true` if the given call is a top-level expression in its statement.
/// This means the call's return value is not used, so return type changes don't matter.
pub(crate) fn is_top_level_expression_call(checker: &Checker) -> bool {
pub(crate) fn is_top_level_expression_in_statement(checker: &Checker) -> bool {
checker.semantic().current_expression_parent().is_none()
&& checker.semantic().current_statement().is_expr_stmt()
}

View File

@@ -6,7 +6,7 @@ use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::importer::ImportRequest;
use crate::preview::is_fix_os_getcwd_enabled;
use crate::rules::flake8_use_pathlib::helpers::is_top_level_expression_call;
use crate::rules::flake8_use_pathlib::helpers::is_top_level_expression_in_statement;
use crate::{FixAvailability, Violation};
/// ## What it does
@@ -89,7 +89,7 @@ pub(crate) fn os_getcwd(checker: &Checker, call: &ExprCall, segments: &[&str]) {
// Unsafe when the fix would delete comments or change a used return value
let applicability = if checker.comment_ranges().intersects(range)
|| !is_top_level_expression_call(checker)
|| !is_top_level_expression_in_statement(checker)
{
Applicability::Unsafe
} else {

View File

@@ -6,7 +6,7 @@ use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_readlink_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_single_arg_calls, is_keyword_only_argument_non_default,
is_top_level_expression_call,
is_top_level_expression_in_statement,
};
use crate::{FixAvailability, Violation};
@@ -86,7 +86,7 @@ pub(crate) fn os_readlink(checker: &Checker, call: &ExprCall, segments: &[&str])
return;
}
let applicability = if !is_top_level_expression_call(checker) {
let applicability = if !is_top_level_expression_in_statement(checker) {
// Unsafe because the return type changes (str/bytes -> Path)
Applicability::Unsafe
} else {

View File

@@ -6,7 +6,7 @@ use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_rename_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_two_arg_calls, has_unknown_keywords_or_starred_expr,
is_keyword_only_argument_non_default, is_top_level_expression_call,
is_keyword_only_argument_non_default, is_top_level_expression_in_statement,
};
use crate::{FixAvailability, Violation};
@@ -92,7 +92,7 @@ pub(crate) fn os_rename(checker: &Checker, call: &ExprCall, segments: &[&str]) {
);
// Unsafe when the fix would delete comments or change a used return value
let applicability = if !is_top_level_expression_call(checker) {
let applicability = if !is_top_level_expression_in_statement(checker) {
// Unsafe because the return type changes (None -> Path)
Applicability::Unsafe
} else {

View File

@@ -6,7 +6,7 @@ use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_replace_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_two_arg_calls, has_unknown_keywords_or_starred_expr,
is_keyword_only_argument_non_default, is_top_level_expression_call,
is_keyword_only_argument_non_default, is_top_level_expression_in_statement,
};
use crate::{FixAvailability, Violation};
@@ -95,7 +95,7 @@ pub(crate) fn os_replace(checker: &Checker, call: &ExprCall, segments: &[&str])
);
// Unsafe when the fix would delete comments or change a used return value
let applicability = if !is_top_level_expression_call(checker) {
let applicability = if !is_top_level_expression_in_statement(checker) {
// Unsafe because the return type changes (None -> Path)
Applicability::Unsafe
} else {

View File

@@ -567,5 +567,64 @@ PTH121 `os.path.samefile()` should be replaced by `Path.samefile()`
138 |
139 | os.path.samefile("pth1_file", "pth1_link", 1, *[1], **{"x": 1}, foo=1)
| ^^^^^^^^^^^^^^^^
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
|
help: Replace with `Path(...).samefile()`
PTH104 `os.rename()` should be replaced by `Path.rename()`
--> full_name.py:144:4
|
142 | import sys
143 |
144 | if os.rename("pth1.py", "pth1.py.bak"):
| ^^^^^^^^^
145 | print("rename: truthy")
146 | else:
|
help: Replace with `Path(...).rename(...)`
PTH105 `os.replace()` should be replaced by `Path.replace()`
--> full_name.py:149:4
|
147 | print("rename: falsey")
148 |
149 | if os.replace("pth1.py.bak", "pth1.py"):
| ^^^^^^^^^^
150 | print("replace: truthy")
151 | else:
|
help: Replace with `Path(...).replace(...)`
PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
--> full_name.py:155:14
|
154 | try:
155 | for _ in os.getcwd():
| ^^^^^^^^^
156 | print("getcwd: iterable")
157 | break
|
help: Replace with `Path.cwd()`
PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
--> full_name.py:162:14
|
161 | try:
162 | for _ in os.getcwdb():
| ^^^^^^^^^^
163 | print("getcwdb: iterable")
164 | break
|
help: Replace with `Path.cwd()`
PTH115 `os.readlink()` should be replaced by `Path.readlink()`
--> full_name.py:169:14
|
168 | try:
169 | for _ in os.readlink(sys.executable):
| ^^^^^^^^^^^
170 | print("readlink: iterable")
171 | break
|
help: Replace with `Path(...).readlink()`

View File

@@ -1037,5 +1037,142 @@ PTH121 `os.path.samefile()` should be replaced by `Path.samefile()`
138 |
139 | os.path.samefile("pth1_file", "pth1_link", 1, *[1], **{"x": 1}, foo=1)
| ^^^^^^^^^^^^^^^^
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
|
help: Replace with `Path(...).samefile()`
PTH104 [*] `os.rename()` should be replaced by `Path.rename()`
--> full_name.py:144:4
|
142 | import sys
143 |
144 | if os.rename("pth1.py", "pth1.py.bak"):
| ^^^^^^^^^
145 | print("rename: truthy")
146 | else:
|
help: Replace with `Path(...).rename(...)`
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
142 | import sys
143 + import pathlib
144 |
- if os.rename("pth1.py", "pth1.py.bak"):
145 + if pathlib.Path("pth1.py").rename("pth1.py.bak"):
146 | print("rename: truthy")
147 | else:
148 | print("rename: falsey")
note: This is an unsafe fix and may change runtime behavior
PTH105 [*] `os.replace()` should be replaced by `Path.replace()`
--> full_name.py:149:4
|
147 | print("rename: falsey")
148 |
149 | if os.replace("pth1.py.bak", "pth1.py"):
| ^^^^^^^^^^
150 | print("replace: truthy")
151 | else:
|
help: Replace with `Path(...).replace(...)`
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
142 | import sys
143 + import pathlib
144 |
145 | if os.rename("pth1.py", "pth1.py.bak"):
146 | print("rename: truthy")
147 | else:
148 | print("rename: falsey")
149 |
- if os.replace("pth1.py.bak", "pth1.py"):
150 + if pathlib.Path("pth1.py.bak").replace("pth1.py"):
151 | print("replace: truthy")
152 | else:
153 | print("replace: falsey")
note: This is an unsafe fix and may change runtime behavior
PTH109 [*] `os.getcwd()` should be replaced by `Path.cwd()`
--> full_name.py:155:14
|
154 | try:
155 | for _ in os.getcwd():
| ^^^^^^^^^
156 | print("getcwd: iterable")
157 | break
|
help: Replace with `Path.cwd()`
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
142 | import sys
143 + import pathlib
144 |
145 | if os.rename("pth1.py", "pth1.py.bak"):
146 | print("rename: truthy")
--------------------------------------------------------------------------------
153 | print("replace: falsey")
154 |
155 | try:
- for _ in os.getcwd():
156 + for _ in pathlib.Path.cwd():
157 | print("getcwd: iterable")
158 | break
159 | except TypeError as e:
note: This is an unsafe fix and may change runtime behavior
PTH109 [*] `os.getcwd()` should be replaced by `Path.cwd()`
--> full_name.py:162:14
|
161 | try:
162 | for _ in os.getcwdb():
| ^^^^^^^^^^
163 | print("getcwdb: iterable")
164 | break
|
help: Replace with `Path.cwd()`
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
142 | import sys
143 + import pathlib
144 |
145 | if os.rename("pth1.py", "pth1.py.bak"):
146 | print("rename: truthy")
--------------------------------------------------------------------------------
160 | print("getcwd: not iterable")
161 |
162 | try:
- for _ in os.getcwdb():
163 + for _ in pathlib.Path.cwd():
164 | print("getcwdb: iterable")
165 | break
166 | except TypeError as e:
note: This is an unsafe fix and may change runtime behavior
PTH115 [*] `os.readlink()` should be replaced by `Path.readlink()`
--> full_name.py:169:14
|
168 | try:
169 | for _ in os.readlink(sys.executable):
| ^^^^^^^^^^^
170 | print("readlink: iterable")
171 | break
|
help: Replace with `Path(...).readlink()`
140 |
141 | # See: https://github.com/astral-sh/ruff/issues/21794
142 | import sys
143 + import pathlib
144 |
145 | if os.rename("pth1.py", "pth1.py.bak"):
146 | print("rename: truthy")
--------------------------------------------------------------------------------
167 | print("getcwdb: not iterable")
168 |
169 | try:
- for _ in os.readlink(sys.executable):
170 + for _ in pathlib.Path(sys.executable).readlink():
171 | print("readlink: iterable")
172 | break
173 | except TypeError as e:
note: This is an unsafe fix and may change runtime behavior

View File

@@ -902,56 +902,76 @@ help: Convert to f-string
132 | # Non-errors
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:160:1
--> UP032_0.py:135:1
|
158 | r'"\N{snowman} {}".format(a)'
159 |
160 | / "123456789 {}".format(
161 | | 11111111111111111111111111111111111111111111111111111111111111111111111111,
162 | | )
| |_^
163 |
164 | """
133 | ###
134 |
135 | "\N{snowman} {}".format(a)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
136 |
137 | "{".format(a)
|
help: Convert to f-string
157 |
158 | r'"\N{snowman} {}".format(a)'
159 |
132 | # Non-errors
133 | ###
134 |
- "\N{snowman} {}".format(a)
135 + f"\N{snowman} {a}"
136 |
137 | "{".format(a)
138 |
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:159:1
|
157 | r'"\N{snowman} {}".format(a)'
158 |
159 | / "123456789 {}".format(
160 | | 11111111111111111111111111111111111111111111111111111111111111111111111111,
161 | | )
| |_^
162 |
163 | """
|
help: Convert to f-string
156 |
157 | r'"\N{snowman} {}".format(a)'
158 |
- "123456789 {}".format(
- 11111111111111111111111111111111111111111111111111111111111111111111111111,
- )
160 + f"123456789 {11111111111111111111111111111111111111111111111111111111111111111111111111}"
161 |
162 | """
163 | {}
159 + f"123456789 {11111111111111111111111111111111111111111111111111111111111111111111111111}"
160 |
161 | """
162 | {}
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:164:1
--> UP032_0.py:163:1
|
162 | )
163 |
164 | / """
161 | )
162 |
163 | / """
164 | | {}
165 | | {}
166 | | {}
167 | | {}
168 | | """.format(
169 | | 1,
170 | | 2,
171 | | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
172 | | )
167 | | """.format(
168 | | 1,
169 | | 2,
170 | | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
171 | | )
| |_^
173 |
174 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
172 |
173 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
|
help: Convert to f-string
161 | 11111111111111111111111111111111111111111111111111111111111111111111111111,
162 | )
163 |
164 + f"""
165 + {1}
166 + {2}
167 + {111111111111111111111111111111111111111111111111111111111111111111111111111111111111111}
168 | """
160 | 11111111111111111111111111111111111111111111111111111111111111111111111111,
161 | )
162 |
163 + f"""
164 + {1}
165 + {2}
166 + {111111111111111111111111111111111111111111111111111111111111111111111111111111111111111}
167 | """
- {}
- {}
- {}
@@ -960,392 +980,408 @@ help: Convert to f-string
- 2,
- 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
- )
169 |
170 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
171 | """.format(
168 |
169 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
170 | """.format(
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:174:84
--> UP032_0.py:173:84
|
172 | )
173 |
174 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
171 | )
172 |
173 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
| ____________________________________________________________________________________^
175 | | """.format(
176 | | 111111
177 | | )
174 | | """.format(
175 | | 111111
176 | | )
| |_^
178 |
179 | "{}".format(
177 |
178 | "{}".format(
|
help: Convert to f-string
171 | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
172 | )
173 |
170 | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
171 | )
172 |
- aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
- """.format(
- 111111
- )
174 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = f"""{111111}
175 + """
176 |
177 | "{}".format(
178 | [
173 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = f"""{111111}
174 + """
175 |
176 | "{}".format(
177 | [
UP032 Use f-string instead of `format` call
--> UP032_0.py:202:1
--> UP032_0.py:201:1
|
200 | "{}".format(**c)
201 |
202 | / "{}".format(
203 | | 1 # comment
204 | | )
199 | "{}".format(**c)
200 |
201 | / "{}".format(
202 | | 1 # comment
203 | | )
| |_^
|
help: Convert to f-string
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:209:1
--> UP032_0.py:208:1
|
207 | # The fixed string will exceed the line length, but it's still smaller than the
208 | # existing line length, so it's fine.
209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
206 | # The fixed string will exceed the line length, but it's still smaller than the
207 | # existing line length, so it's fine.
208 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
210 |
211 | # When fixing, trim the trailing empty string.
209 |
210 | # When fixing, trim the trailing empty string.
|
help: Convert to f-string
206 |
207 | # The fixed string will exceed the line length, but it's still smaller than the
208 | # existing line length, so it's fine.
205 |
206 | # The fixed string will exceed the line length, but it's still smaller than the
207 | # existing line length, so it's fine.
- "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
209 + f"<Customer: {self.internal_ids}, {self.external_ids}, {self.properties}, {self.tags}, {self.others}>"
210 |
211 | # When fixing, trim the trailing empty string.
212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
208 + f"<Customer: {self.internal_ids}, {self.external_ids}, {self.properties}, {self.tags}, {self.others}>"
209 |
210 | # When fixing, trim the trailing empty string.
211 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:212:18
--> UP032_0.py:211:18
|
211 | # When fixing, trim the trailing empty string.
212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
210 | # When fixing, trim the trailing empty string.
211 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
213 | | "".format(new_dict, d))
212 | | "".format(new_dict, d))
| |_______________________________________^
214 |
215 | # When fixing, trim the trailing empty string.
213 |
214 | # When fixing, trim the trailing empty string.
|
help: Convert to f-string
209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
210 |
211 | # When fixing, trim the trailing empty string.
208 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
209 |
210 | # When fixing, trim the trailing empty string.
- raise ValueError("Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d))
212 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}")
211 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}")
212 |
213 | # When fixing, trim the trailing empty string.
214 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:215:18
|
214 | # When fixing, trim the trailing empty string.
215 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
216 | | .format(new_dict, d))
| |_____________________________________^
217 |
218 | raise ValueError(
|
help: Convert to f-string
212 | "".format(new_dict, d))
213 |
214 | # When fixing, trim the trailing empty string.
215 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:216:18
|
215 | # When fixing, trim the trailing empty string.
216 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
217 | | .format(new_dict, d))
| |_____________________________________^
218 |
219 | raise ValueError(
|
help: Convert to f-string
213 | "".format(new_dict, d))
214 |
215 | # When fixing, trim the trailing empty string.
- raise ValueError("Conflicting configuration dicts: {!r} {!r}"
- .format(new_dict, d))
216 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}"
217 + )
218 |
219 | raise ValueError(
220 | "Conflicting configuration dicts: {!r} {!r}"
215 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}"
216 + )
217 |
218 | raise ValueError(
219 | "Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:220:5
--> UP032_0.py:219:5
|
219 | raise ValueError(
220 | / "Conflicting configuration dicts: {!r} {!r}"
221 | | "".format(new_dict, d)
218 | raise ValueError(
219 | / "Conflicting configuration dicts: {!r} {!r}"
220 | | "".format(new_dict, d)
| |__________________________^
222 | )
221 | )
|
help: Convert to f-string
217 | .format(new_dict, d))
218 |
219 | raise ValueError(
216 | .format(new_dict, d))
217 |
218 | raise ValueError(
- "Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d)
220 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
219 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
220 | )
221 |
222 | raise ValueError(
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:224:5
|
223 | raise ValueError(
224 | / "Conflicting configuration dicts: {!r} {!r}"
225 | | "".format(new_dict, d)
| |__________________________^
226 |
227 | )
|
help: Convert to f-string
221 | )
222 |
223 | raise ValueError(
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:225:5
|
224 | raise ValueError(
225 | / "Conflicting configuration dicts: {!r} {!r}"
226 | | "".format(new_dict, d)
| |__________________________^
227 |
228 | )
|
help: Convert to f-string
222 | )
223 |
224 | raise ValueError(
- "Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d)
225 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
226 |
227 | )
228 |
224 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
225 |
226 | )
227 |
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:231:1
--> UP032_0.py:230:1
|
230 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
231 | / (
232 | | "{}"
233 | | "{{}}"
234 | | ).format(a)
229 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
230 | / (
231 | | "{}"
232 | | "{{}}"
233 | | ).format(a)
| |___________^
235 |
236 | ("{}" "{{}}").format(a)
234 |
235 | ("{}" "{{}}").format(a)
|
help: Convert to f-string
229 |
230 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
231 | (
232 + f"{a}"
233 | "{}"
228 |
229 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
230 | (
231 + f"{a}"
232 | "{}"
- "{{}}"
- ).format(a)
234 + )
235 |
236 | ("{}" "{{}}").format(a)
237 |
233 + )
234 |
235 | ("{}" "{{}}").format(a)
236 |
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:236:1
--> UP032_0.py:235:1
|
234 | ).format(a)
235 |
236 | ("{}" "{{}}").format(a)
233 | ).format(a)
234 |
235 | ("{}" "{{}}").format(a)
| ^^^^^^^^^^^^^^^^^^^^^^^
|
help: Convert to f-string
233 | "{{}}"
234 | ).format(a)
235 |
232 | "{{}}"
233 | ).format(a)
234 |
- ("{}" "{{}}").format(a)
236 + (f"{a}" "{}")
235 + (f"{a}" "{}")
236 |
237 |
238 |
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:240:1
--> UP032_0.py:239:1
|
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
240 | / (
241 | | "{}"
242 | | "{{{}}}"
243 | | ).format(a, b)
238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
239 | / (
240 | | "{}"
241 | | "{{{}}}"
242 | | ).format(a, b)
| |______________^
244 |
245 | ("{}" "{{{}}}").format(a, b)
243 |
244 | ("{}" "{{{}}}").format(a, b)
|
help: Convert to f-string
238 |
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
240 | (
237 |
238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
239 | (
- "{}"
- "{{{}}}"
- ).format(a, b)
241 + f"{a}"
242 + f"{{{b}}}"
243 + )
244 |
245 | ("{}" "{{{}}}").format(a, b)
246 |
240 + f"{a}"
241 + f"{{{b}}}"
242 + )
243 |
244 | ("{}" "{{{}}}").format(a, b)
245 |
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:245:1
--> UP032_0.py:244:1
|
243 | ).format(a, b)
244 |
245 | ("{}" "{{{}}}").format(a, b)
242 | ).format(a, b)
243 |
244 | ("{}" "{{{}}}").format(a, b)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
246 |
247 | # The dictionary should be parenthesized.
245 |
246 | # The dictionary should be parenthesized.
|
help: Convert to f-string
242 | "{{{}}}"
243 | ).format(a, b)
244 |
241 | "{{{}}}"
242 | ).format(a, b)
243 |
- ("{}" "{{{}}}").format(a, b)
245 + (f"{a}" f"{{{b}}}")
246 |
247 | # The dictionary should be parenthesized.
248 | "{}".format({0: 1}[0])
244 + (f"{a}" f"{{{b}}}")
245 |
246 | # The dictionary should be parenthesized.
247 | "{}".format({0: 1}[0])
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:248:1
--> UP032_0.py:247:1
|
247 | # The dictionary should be parenthesized.
248 | "{}".format({0: 1}[0])
246 | # The dictionary should be parenthesized.
247 | "{}".format({0: 1}[0])
| ^^^^^^^^^^^^^^^^^^^^^^
249 |
250 | # The dictionary should be parenthesized.
248 |
249 | # The dictionary should be parenthesized.
|
help: Convert to f-string
245 | ("{}" "{{{}}}").format(a, b)
246 |
247 | # The dictionary should be parenthesized.
244 | ("{}" "{{{}}}").format(a, b)
245 |
246 | # The dictionary should be parenthesized.
- "{}".format({0: 1}[0])
248 + f"{({0: 1}[0])}"
249 |
250 | # The dictionary should be parenthesized.
251 | "{}".format({0: 1}.bar)
247 + f"{({0: 1}[0])}"
248 |
249 | # The dictionary should be parenthesized.
250 | "{}".format({0: 1}.bar)
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:251:1
--> UP032_0.py:250:1
|
250 | # The dictionary should be parenthesized.
251 | "{}".format({0: 1}.bar)
249 | # The dictionary should be parenthesized.
250 | "{}".format({0: 1}.bar)
| ^^^^^^^^^^^^^^^^^^^^^^^
252 |
253 | # The dictionary should be parenthesized.
251 |
252 | # The dictionary should be parenthesized.
|
help: Convert to f-string
248 | "{}".format({0: 1}[0])
249 |
250 | # The dictionary should be parenthesized.
247 | "{}".format({0: 1}[0])
248 |
249 | # The dictionary should be parenthesized.
- "{}".format({0: 1}.bar)
251 + f"{({0: 1}.bar)}"
252 |
253 | # The dictionary should be parenthesized.
254 | "{}".format({0: 1}())
250 + f"{({0: 1}.bar)}"
251 |
252 | # The dictionary should be parenthesized.
253 | "{}".format({0: 1}())
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:254:1
--> UP032_0.py:253:1
|
253 | # The dictionary should be parenthesized.
254 | "{}".format({0: 1}())
252 | # The dictionary should be parenthesized.
253 | "{}".format({0: 1}())
| ^^^^^^^^^^^^^^^^^^^^^
255 |
256 | # The string shouldn't be converted, since it would require repeating the function call.
254 |
255 | # The string shouldn't be converted, since it would require repeating the function call.
|
help: Convert to f-string
251 | "{}".format({0: 1}.bar)
252 |
253 | # The dictionary should be parenthesized.
250 | "{}".format({0: 1}.bar)
251 |
252 | # The dictionary should be parenthesized.
- "{}".format({0: 1}())
254 + f"{({0: 1}())}"
255 |
256 | # The string shouldn't be converted, since it would require repeating the function call.
257 | "{x} {x}".format(x=foo())
253 + f"{({0: 1}())}"
254 |
255 | # The string shouldn't be converted, since it would require repeating the function call.
256 | "{x} {x}".format(x=foo())
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:261:1
--> UP032_0.py:260:1
|
260 | # The string _should_ be converted, since the function call is repeated in the arguments.
261 | "{0} {1}".format(foo(), foo())
259 | # The string _should_ be converted, since the function call is repeated in the arguments.
260 | "{0} {1}".format(foo(), foo())
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
262 |
263 | # The call should be removed, but the string itself should remain.
261 |
262 | # The call should be removed, but the string itself should remain.
|
help: Convert to f-string
258 | "{0} {0}".format(foo())
259 |
260 | # The string _should_ be converted, since the function call is repeated in the arguments.
257 | "{0} {0}".format(foo())
258 |
259 | # The string _should_ be converted, since the function call is repeated in the arguments.
- "{0} {1}".format(foo(), foo())
261 + f"{foo()} {foo()}"
262 |
263 | # The call should be removed, but the string itself should remain.
264 | ''.format(self.project)
260 + f"{foo()} {foo()}"
261 |
262 | # The call should be removed, but the string itself should remain.
263 | ''.format(self.project)
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:264:1
--> UP032_0.py:263:1
|
263 | # The call should be removed, but the string itself should remain.
264 | ''.format(self.project)
262 | # The call should be removed, but the string itself should remain.
263 | ''.format(self.project)
| ^^^^^^^^^^^^^^^^^^^^^^^
265 |
266 | # The call should be removed, but the string itself should remain.
264 |
265 | # The call should be removed, but the string itself should remain.
|
help: Convert to f-string
261 | "{0} {1}".format(foo(), foo())
262 |
263 | # The call should be removed, but the string itself should remain.
260 | "{0} {1}".format(foo(), foo())
261 |
262 | # The call should be removed, but the string itself should remain.
- ''.format(self.project)
264 + ''
265 |
266 | # The call should be removed, but the string itself should remain.
267 | "".format(self.project)
263 + ''
264 |
265 | # The call should be removed, but the string itself should remain.
266 | "".format(self.project)
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:267:1
--> UP032_0.py:266:1
|
266 | # The call should be removed, but the string itself should remain.
267 | "".format(self.project)
265 | # The call should be removed, but the string itself should remain.
266 | "".format(self.project)
| ^^^^^^^^^^^^^^^^^^^^^^^
268 |
269 | # Not a valid type annotation but this test shouldn't result in a panic.
267 |
268 | # Not a valid type annotation but this test shouldn't result in a panic.
|
help: Convert to f-string
264 | ''.format(self.project)
265 |
266 | # The call should be removed, but the string itself should remain.
263 | ''.format(self.project)
264 |
265 | # The call should be removed, but the string itself should remain.
- "".format(self.project)
267 + ""
268 |
269 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
266 + ""
267 |
268 | # Not a valid type annotation but this test shouldn't result in a panic.
269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:271:5
--> UP032_0.py:270:5
|
269 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
271 | x: "'{} + {}'.format(x, y)"
268 | # Not a valid type annotation but this test shouldn't result in a panic.
269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
270 | x: "'{} + {}'.format(x, y)"
| ^^^^^^^^^^^^^^^^^^^^^^
272 |
273 | # Regression https://github.com/astral-sh/ruff/issues/21000
271 |
272 | # Regression https://github.com/astral-sh/ruff/issues/21000
|
help: Convert to f-string
268 |
269 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
267 |
268 | # Not a valid type annotation but this test shouldn't result in a panic.
269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
- x: "'{} + {}'.format(x, y)"
271 + x: "f'{x} + {y}'"
272 |
273 | # Regression https://github.com/astral-sh/ruff/issues/21000
274 | # Fix should parenthesize walrus
270 + x: "f'{x} + {y}'"
271 |
272 | # Regression https://github.com/astral-sh/ruff/issues/21000
273 | # Fix should parenthesize walrus
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:277:14
--> UP032_0.py:276:14
|
275 | if __name__ == "__main__":
276 | number = 0
277 | string = "{}".format(number := number + 1)
274 | if __name__ == "__main__":
275 | number = 0
276 | string = "{}".format(number := number + 1)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
278 | print(string)
277 | print(string)
|
help: Convert to f-string
274 | # Fix should parenthesize walrus
275 | if __name__ == "__main__":
276 | number = 0
273 | # Fix should parenthesize walrus
274 | if __name__ == "__main__":
275 | number = 0
- string = "{}".format(number := number + 1)
277 + string = f"{(number := number + 1)}"
278 | print(string)
276 + string = f"{(number := number + 1)}"
277 | print(string)
278 |
279 | # Unicode escape
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:280:1
|
279 | # Unicode escape
280 | "\N{angle}AOB = {angle}°".format(angle=180)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Convert to f-string
277 | print(string)
278 |
279 | # Unicode escape
- "\N{angle}AOB = {angle}°".format(angle=180)
280 + f"\N{angle}AOB = {180}°"

View File

@@ -3,10 +3,11 @@ use std::borrow::Cow;
use ruff_python_ast::PythonVersion;
use ruff_python_ast::{self as ast, Expr, name::Name, token::parenthesized_range};
use ruff_python_codegen::Generator;
use ruff_python_semantic::{BindingId, ResolvedReference, SemanticModel};
use ruff_python_semantic::{ResolvedReference, SemanticModel};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::rules::flake8_async::rules::blocking_open_call::is_open_call_from_pathlib;
use crate::{Applicability, Edit, Fix};
/// Format a code snippet to call `name.method()`.
@@ -119,14 +120,13 @@ impl OpenMode {
pub(super) struct FileOpen<'a> {
/// With item where the open happens, we use it for the reporting range.
pub(super) item: &'a ast::WithItem,
/// Filename expression used as the first argument in `open`, we use it in the diagnostic message.
pub(super) filename: &'a Expr,
/// The file open mode.
pub(super) mode: OpenMode,
/// The file open keywords.
pub(super) keywords: Vec<&'a ast::Keyword>,
/// We only check `open` operations whose file handles are used exactly once.
pub(super) reference: &'a ResolvedReference,
pub(super) argument: OpenArgument<'a>,
}
impl FileOpen<'_> {
@@ -137,6 +137,45 @@ impl FileOpen<'_> {
}
}
#[derive(Debug, Clone, Copy)]
pub(super) enum OpenArgument<'a> {
/// The filename argument to `open`, e.g. "foo.txt" in:
///
/// ```py
/// f = open("foo.txt")
/// ```
Builtin { filename: &'a Expr },
/// The `Path` receiver of a `pathlib.Path.open` call, e.g. the `p` in the
/// context manager in:
///
/// ```py
/// p = Path("foo.txt")
/// with p.open() as f: ...
/// ```
///
/// or `Path("foo.txt")` in
///
/// ```py
/// with Path("foo.txt").open() as f: ...
/// ```
Pathlib { path: &'a Expr },
}
impl OpenArgument<'_> {
pub(super) fn display<'src>(&self, source: &'src str) -> &'src str {
&source[self.range()]
}
}
impl Ranged for OpenArgument<'_> {
fn range(&self) -> TextRange {
match self {
OpenArgument::Builtin { filename } => filename.range(),
OpenArgument::Pathlib { path } => path.range(),
}
}
}
/// Find and return all `open` operations in the given `with` statement.
pub(super) fn find_file_opens<'a>(
with: &'a ast::StmtWith,
@@ -146,10 +185,65 @@ pub(super) fn find_file_opens<'a>(
) -> Vec<FileOpen<'a>> {
with.items
.iter()
.filter_map(|item| find_file_open(item, with, semantic, read_mode, python_version))
.filter_map(|item| {
find_file_open(item, with, semantic, read_mode, python_version)
.or_else(|| find_path_open(item, with, semantic, read_mode, python_version))
})
.collect()
}
fn resolve_file_open<'a>(
item: &'a ast::WithItem,
with: &'a ast::StmtWith,
semantic: &'a SemanticModel<'a>,
read_mode: bool,
mode: OpenMode,
keywords: Vec<&'a ast::Keyword>,
argument: OpenArgument<'a>,
) -> Option<FileOpen<'a>> {
match mode {
OpenMode::ReadText | OpenMode::ReadBytes => {
if !read_mode {
return None;
}
}
OpenMode::WriteText | OpenMode::WriteBytes => {
if read_mode {
return None;
}
}
}
if matches!(mode, OpenMode::ReadBytes | OpenMode::WriteBytes) && !keywords.is_empty() {
return None;
}
let var = item.optional_vars.as_deref()?.as_name_expr()?;
let scope = semantic.current_scope();
let binding = scope.get_all(var.id.as_str()).find_map(|id| {
let b = semantic.binding(id);
(b.range() == var.range()).then_some(b)
})?;
let references: Vec<&ResolvedReference> = binding
.references
.iter()
.map(|id| semantic.reference(*id))
.filter(|reference| with.range().contains_range(reference.range()))
.collect();
let [reference] = references.as_slice() else {
return None;
};
Some(FileOpen {
item,
mode,
keywords,
reference,
argument,
})
}
/// Find `open` operation in the given `with` item.
fn find_file_open<'a>(
item: &'a ast::WithItem,
@@ -165,8 +259,6 @@ fn find_file_open<'a>(
..
} = item.context_expr.as_call_expr()?;
let var = item.optional_vars.as_deref()?.as_name_expr()?;
// Ignore calls with `*args` and `**kwargs`. In the exact case of `open(*filename, mode="w")`,
// it could be a match; but in all other cases, the call _could_ contain unsupported keyword
// arguments, like `buffering`.
@@ -187,58 +279,57 @@ fn find_file_open<'a>(
let (keywords, kw_mode) = match_open_keywords(keywords, read_mode, python_version)?;
let mode = kw_mode.unwrap_or(pos_mode);
match mode {
OpenMode::ReadText | OpenMode::ReadBytes => {
if !read_mode {
return None;
}
}
OpenMode::WriteText | OpenMode::WriteBytes => {
if read_mode {
return None;
}
}
}
// Path.read_bytes and Path.write_bytes do not support any kwargs.
if matches!(mode, OpenMode::ReadBytes | OpenMode::WriteBytes) && !keywords.is_empty() {
return None;
}
// Now we need to find what is this variable bound to...
let scope = semantic.current_scope();
let bindings: Vec<BindingId> = scope.get_all(var.id.as_str()).collect();
let binding = bindings
.iter()
.map(|id| semantic.binding(*id))
// We might have many bindings with the same name, but we only care
// for the one we are looking at right now.
.find(|binding| binding.range() == var.range())?;
// Since many references can share the same binding, we can limit our attention span
// exclusively to the body of the current `with` statement.
let references: Vec<&ResolvedReference> = binding
.references
.iter()
.map(|id| semantic.reference(*id))
.filter(|reference| with.range().contains_range(reference.range()))
.collect();
// And even with all these restrictions, if the file handle gets used not exactly once,
// it doesn't fit the bill.
let [reference] = references.as_slice() else {
return None;
};
Some(FileOpen {
resolve_file_open(
item,
filename,
with,
semantic,
read_mode,
mode,
keywords,
reference,
})
OpenArgument::Builtin { filename },
)
}
fn find_path_open<'a>(
item: &'a ast::WithItem,
with: &'a ast::StmtWith,
semantic: &'a SemanticModel<'a>,
read_mode: bool,
python_version: PythonVersion,
) -> Option<FileOpen<'a>> {
let ast::ExprCall {
func,
arguments: ast::Arguments { args, keywords, .. },
..
} = item.context_expr.as_call_expr()?;
if args.iter().any(Expr::is_starred_expr)
|| keywords.iter().any(|keyword| keyword.arg.is_none())
{
return None;
}
if !is_open_call_from_pathlib(func, semantic) {
return None;
}
let attr = func.as_attribute_expr()?;
let mode = if args.is_empty() {
OpenMode::ReadText
} else {
match_open_mode(args.first()?)?
};
let (keywords, kw_mode) = match_open_keywords(keywords, read_mode, python_version)?;
let mode = kw_mode.unwrap_or(mode);
resolve_file_open(
item,
with,
semantic,
read_mode,
mode,
keywords,
OpenArgument::Pathlib {
path: attr.value.as_ref(),
},
)
}
/// Match positional arguments. Return expression for the file name and open mode.

View File

@@ -15,7 +15,8 @@ mod tests {
use crate::test::test_path;
use crate::{assert_diagnostics, settings};
#[test_case(Rule::ReadWholeFile, Path::new("FURB101.py"))]
#[test_case(Rule::ReadWholeFile, Path::new("FURB101_0.py"))]
#[test_case(Rule::ReadWholeFile, Path::new("FURB101_1.py"))]
#[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))]
#[test_case(Rule::IfExpInsteadOfOrOperator, Path::new("FURB110.py"))]
#[test_case(Rule::ReimplementedOperator, Path::new("FURB118.py"))]
@@ -46,7 +47,8 @@ mod tests {
#[test_case(Rule::MetaClassABCMeta, Path::new("FURB180.py"))]
#[test_case(Rule::HashlibDigestHex, Path::new("FURB181.py"))]
#[test_case(Rule::ListReverseCopy, Path::new("FURB187.py"))]
#[test_case(Rule::WriteWholeFile, Path::new("FURB103.py"))]
#[test_case(Rule::WriteWholeFile, Path::new("FURB103_0.py"))]
#[test_case(Rule::WriteWholeFile, Path::new("FURB103_1.py"))]
#[test_case(Rule::FStringNumberFormat, Path::new("FURB116.py"))]
#[test_case(Rule::SortedMinMax, Path::new("FURB192.py"))]
#[test_case(Rule::SliceToRemovePrefixOrSuffix, Path::new("FURB188.py"))]
@@ -65,7 +67,7 @@ mod tests {
#[test]
fn write_whole_file_python_39() -> Result<()> {
let diagnostics = test_path(
Path::new("refurb/FURB103.py"),
Path::new("refurb/FURB103_0.py"),
&settings::LinterSettings::for_rule(Rule::WriteWholeFile)
.with_target_version(PythonVersion::PY39),
)?;

View File

@@ -10,7 +10,7 @@ use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::fix::snippet::SourceCodeSnippet;
use crate::importer::ImportRequest;
use crate::rules::refurb::helpers::{FileOpen, find_file_opens};
use crate::rules::refurb::helpers::{FileOpen, OpenArgument, find_file_opens};
use crate::{FixAvailability, Violation};
/// ## What it does
@@ -42,27 +42,41 @@ use crate::{FixAvailability, Violation};
/// - [Python documentation: `Path.read_text`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_text)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "v0.1.2")]
pub(crate) struct ReadWholeFile {
pub(crate) struct ReadWholeFile<'a> {
filename: SourceCodeSnippet,
suggestion: SourceCodeSnippet,
argument: OpenArgument<'a>,
}
impl Violation for ReadWholeFile {
impl Violation for ReadWholeFile<'_> {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
let filename = self.filename.truncated_display();
let suggestion = self.suggestion.truncated_display();
format!("`open` and `read` should be replaced by `Path({filename}).{suggestion}`")
match self.argument {
OpenArgument::Pathlib { .. } => {
format!(
"`Path.open()` followed by `read()` can be replaced by `{filename}.{suggestion}`"
)
}
OpenArgument::Builtin { .. } => {
format!("`open` and `read` should be replaced by `Path({filename}).{suggestion}`")
}
}
}
fn fix_title(&self) -> Option<String> {
Some(format!(
"Replace with `Path({}).{}`",
self.filename.truncated_display(),
self.suggestion.truncated_display(),
))
let filename = self.filename.truncated_display();
let suggestion = self.suggestion.truncated_display();
match self.argument {
OpenArgument::Pathlib { .. } => Some(format!("Replace with `{filename}.{suggestion}`")),
OpenArgument::Builtin { .. } => {
Some(format!("Replace with `Path({filename}).{suggestion}`"))
}
}
}
}
@@ -114,13 +128,13 @@ impl<'a> Visitor<'a> for ReadMatcher<'a, '_> {
.position(|open| open.is_ref(read_from))
{
let open = self.candidates.remove(open);
let filename_display = open.argument.display(self.checker.source());
let suggestion = make_suggestion(&open, self.checker.generator());
let mut diagnostic = self.checker.report_diagnostic(
ReadWholeFile {
filename: SourceCodeSnippet::from_str(
&self.checker.generator().expr(open.filename),
),
filename: SourceCodeSnippet::from_str(filename_display),
suggestion: SourceCodeSnippet::from_str(&suggestion),
argument: open.argument,
},
open.item.range(),
);
@@ -188,8 +202,6 @@ fn generate_fix(
let locator = checker.locator();
let filename_code = locator.slice(open.filename.range());
let (import_edit, binding) = checker
.importer()
.get_or_import_symbol(
@@ -206,10 +218,15 @@ fn generate_fix(
[Stmt::Assign(ast::StmtAssign { targets, value, .. })] if value.range() == expr.range() => {
match targets.as_slice() {
[Expr::Name(name)] => {
format!(
"{name} = {binding}({filename_code}).{suggestion}",
name = name.id
)
let target = match open.argument {
OpenArgument::Builtin { filename } => {
let filename_code = locator.slice(filename.range());
format!("{binding}({filename_code})")
}
OpenArgument::Pathlib { path } => locator.slice(path.range()).to_string(),
};
format!("{name} = {target}.{suggestion}", name = name.id)
}
_ => return None,
}
@@ -223,8 +240,16 @@ fn generate_fix(
}),
] if value.range() == expr.range() => match target.as_ref() {
Expr::Name(name) => {
let target = match open.argument {
OpenArgument::Builtin { filename } => {
let filename_code = locator.slice(filename.range());
format!("{binding}({filename_code})")
}
OpenArgument::Pathlib { path } => locator.slice(path.range()).to_string(),
};
format!(
"{var}: {ann} = {binding}({filename_code}).{suggestion}",
"{var}: {ann} = {target}.{suggestion}",
var = name.id,
ann = locator.slice(annotation.range())
)

View File

@@ -9,7 +9,7 @@ use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::fix::snippet::SourceCodeSnippet;
use crate::importer::ImportRequest;
use crate::rules::refurb::helpers::{FileOpen, find_file_opens};
use crate::rules::refurb::helpers::{FileOpen, OpenArgument, find_file_opens};
use crate::{FixAvailability, Locator, Violation};
/// ## What it does
@@ -42,26 +42,40 @@ use crate::{FixAvailability, Locator, Violation};
/// - [Python documentation: `Path.write_text`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.write_text)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "v0.3.6")]
pub(crate) struct WriteWholeFile {
pub(crate) struct WriteWholeFile<'a> {
filename: SourceCodeSnippet,
suggestion: SourceCodeSnippet,
argument: OpenArgument<'a>,
}
impl Violation for WriteWholeFile {
impl Violation for WriteWholeFile<'_> {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
let filename = self.filename.truncated_display();
let suggestion = self.suggestion.truncated_display();
format!("`open` and `write` should be replaced by `Path({filename}).{suggestion}`")
match self.argument {
OpenArgument::Pathlib { .. } => {
format!(
"`Path.open()` followed by `write()` can be replaced by `{filename}.{suggestion}`"
)
}
OpenArgument::Builtin { .. } => {
format!("`open` and `write` should be replaced by `Path({filename}).{suggestion}`")
}
}
}
fn fix_title(&self) -> Option<String> {
Some(format!(
"Replace with `Path({}).{}`",
self.filename.truncated_display(),
self.suggestion.truncated_display(),
))
let filename = self.filename.truncated_display();
let suggestion = self.suggestion.truncated_display();
match self.argument {
OpenArgument::Pathlib { .. } => Some(format!("Replace with `{filename}.{suggestion}`")),
OpenArgument::Builtin { .. } => {
Some(format!("Replace with `Path({filename}).{suggestion}`"))
}
}
}
}
@@ -125,16 +139,15 @@ impl<'a> Visitor<'a> for WriteMatcher<'a, '_> {
.position(|open| open.is_ref(write_to))
{
let open = self.candidates.remove(open);
if self.loop_counter == 0 {
let filename_display = open.argument.display(self.checker.source());
let suggestion = make_suggestion(&open, content, self.checker.locator());
let mut diagnostic = self.checker.report_diagnostic(
WriteWholeFile {
filename: SourceCodeSnippet::from_str(
&self.checker.generator().expr(open.filename),
),
filename: SourceCodeSnippet::from_str(filename_display),
suggestion: SourceCodeSnippet::from_str(&suggestion),
argument: open.argument,
},
open.item.range(),
);
@@ -198,7 +211,6 @@ fn generate_fix(
}
let locator = checker.locator();
let filename_code = locator.slice(open.filename.range());
let (import_edit, binding) = checker
.importer()
@@ -209,7 +221,15 @@ fn generate_fix(
)
.ok()?;
let replacement = format!("{binding}({filename_code}).{suggestion}");
let target = match open.argument {
OpenArgument::Builtin { filename } => {
let filename_code = locator.slice(filename.range());
format!("{binding}({filename_code})")
}
OpenArgument::Pathlib { path } => locator.slice(path.range()).to_string(),
};
let replacement = format!("{target}.{suggestion}");
let applicability = if checker.comment_ranges().intersects(with_stmt.range()) {
Applicability::Unsafe

View File

@@ -2,7 +2,7 @@
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text()`
--> FURB101.py:12:6
--> FURB101_0.py:12:6
|
11 | # FURB101
12 | with open("file.txt") as f:
@@ -26,7 +26,7 @@ help: Replace with `Path("file.txt").read_text()`
16 | with open("file.txt", "rb") as f:
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_bytes()`
--> FURB101.py:16:6
--> FURB101_0.py:16:6
|
15 | # FURB101
16 | with open("file.txt", "rb") as f:
@@ -50,7 +50,7 @@ help: Replace with `Path("file.txt").read_bytes()`
20 | with open("file.txt", mode="rb") as f:
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_bytes()`
--> FURB101.py:20:6
--> FURB101_0.py:20:6
|
19 | # FURB101
20 | with open("file.txt", mode="rb") as f:
@@ -74,7 +74,7 @@ help: Replace with `Path("file.txt").read_bytes()`
24 | with open("file.txt", encoding="utf8") as f:
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf8")`
--> FURB101.py:24:6
--> FURB101_0.py:24:6
|
23 | # FURB101
24 | with open("file.txt", encoding="utf8") as f:
@@ -98,7 +98,7 @@ help: Replace with `Path("file.txt").read_text(encoding="utf8")`
28 | with open("file.txt", errors="ignore") as f:
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text(errors="ignore")`
--> FURB101.py:28:6
--> FURB101_0.py:28:6
|
27 | # FURB101
28 | with open("file.txt", errors="ignore") as f:
@@ -122,7 +122,7 @@ help: Replace with `Path("file.txt").read_text(errors="ignore")`
32 | with open("file.txt", mode="r") as f: # noqa: FURB120
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text()`
--> FURB101.py:32:6
--> FURB101_0.py:32:6
|
31 | # FURB101
32 | with open("file.txt", mode="r") as f: # noqa: FURB120
@@ -147,7 +147,7 @@ help: Replace with `Path("file.txt").read_text()`
note: This is an unsafe fix and may change runtime behavior
FURB101 `open` and `read` should be replaced by `Path(foo()).read_bytes()`
--> FURB101.py:36:6
--> FURB101_0.py:36:6
|
35 | # FURB101
36 | with open(foo(), "rb") as f:
@@ -158,7 +158,7 @@ FURB101 `open` and `read` should be replaced by `Path(foo()).read_bytes()`
help: Replace with `Path(foo()).read_bytes()`
FURB101 `open` and `read` should be replaced by `Path("a.txt").read_text()`
--> FURB101.py:44:6
--> FURB101_0.py:44:6
|
43 | # FURB101
44 | with open("a.txt") as a, open("b.txt", "rb") as b:
@@ -169,7 +169,7 @@ FURB101 `open` and `read` should be replaced by `Path("a.txt").read_text()`
help: Replace with `Path("a.txt").read_text()`
FURB101 `open` and `read` should be replaced by `Path("b.txt").read_bytes()`
--> FURB101.py:44:26
--> FURB101_0.py:44:26
|
43 | # FURB101
44 | with open("a.txt") as a, open("b.txt", "rb") as b:
@@ -180,7 +180,7 @@ FURB101 `open` and `read` should be replaced by `Path("b.txt").read_bytes()`
help: Replace with `Path("b.txt").read_bytes()`
FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text()`
--> FURB101.py:49:18
--> FURB101_0.py:49:18
|
48 | # FURB101
49 | with foo() as a, open("file.txt") as b, foo() as c:
@@ -191,7 +191,7 @@ FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text()`
help: Replace with `Path("file.txt").read_text()`
FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf-8")`
--> FURB101.py:130:6
--> FURB101_0.py:130:6
|
129 | # FURB101
130 | with open("file.txt", encoding="utf-8") as f:
@@ -215,7 +215,7 @@ help: Replace with `Path("file.txt").read_text(encoding="utf-8")`
134 | with open("file.txt", encoding="utf-8") as f:
FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf-8")`
--> FURB101.py:134:6
--> FURB101_0.py:134:6
|
133 | # FURB101 but no fix because it would remove the assignment to `x`
134 | with open("file.txt", encoding="utf-8") as f:
@@ -225,7 +225,7 @@ FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text(enco
help: Replace with `Path("file.txt").read_text(encoding="utf-8")`
FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf-8")`
--> FURB101.py:138:6
--> FURB101_0.py:138:6
|
137 | # FURB101 but no fix because it would remove the `process_contents` call
138 | with open("file.txt", encoding="utf-8") as f:
@@ -234,13 +234,13 @@ FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text(enco
|
help: Replace with `Path("file.txt").read_text(encoding="utf-8")`
FURB101 `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf-8")`
--> FURB101.py:141:6
FURB101 `open` and `read` should be replaced by `Path("file1.txt").read_text(encoding="utf-8")`
--> FURB101_0.py:141:6
|
139 | contents = process_contents(f.read())
140 |
141 | with open("file.txt", encoding="utf-8") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
141 | with open("file1.txt", encoding="utf-8") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
142 | contents: str = process_contents(f.read())
|
help: Replace with `Path("file.txt").read_text(encoding="utf-8")`
help: Replace with `Path("file1.txt").read_text(encoding="utf-8")`

View File

@@ -0,0 +1,39 @@
---
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB101 [*] `Path.open()` followed by `read()` can be replaced by `Path("file.txt").read_text()`
--> FURB101_1.py:4:6
|
2 | from pathlib import Path
3 |
4 | with Path("file.txt").open() as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
5 | contents = f.read()
|
help: Replace with `Path("file.txt").read_text()`
1 |
2 | from pathlib import Path
3 |
- with Path("file.txt").open() as f:
- contents = f.read()
4 + contents = Path("file.txt").read_text()
5 |
6 | with Path("file.txt").open("r") as f:
7 | contents = f.read()
FURB101 [*] `Path.open()` followed by `read()` can be replaced by `Path("file.txt").read_text()`
--> FURB101_1.py:7:6
|
5 | contents = f.read()
6 |
7 | with Path("file.txt").open("r") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8 | contents = f.read()
|
help: Replace with `Path("file.txt").read_text()`
4 | with Path("file.txt").open() as f:
5 | contents = f.read()
6 |
- with Path("file.txt").open("r") as f:
- contents = f.read()
7 + contents = Path("file.txt").read_text()

View File

@@ -2,7 +2,7 @@
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text("test")`
--> FURB103.py:12:6
--> FURB103_0.py:12:6
|
11 | # FURB103
12 | with open("file.txt", "w") as f:
@@ -26,7 +26,7 @@ help: Replace with `Path("file.txt").write_text("test")`
16 | with open("file.txt", "wb") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_bytes(foobar)`
--> FURB103.py:16:6
--> FURB103_0.py:16:6
|
15 | # FURB103
16 | with open("file.txt", "wb") as f:
@@ -50,7 +50,7 @@ help: Replace with `Path("file.txt").write_bytes(foobar)`
20 | with open("file.txt", mode="wb") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_bytes(b"abc")`
--> FURB103.py:20:6
--> FURB103_0.py:20:6
|
19 | # FURB103
20 | with open("file.txt", mode="wb") as f:
@@ -74,7 +74,7 @@ help: Replace with `Path("file.txt").write_bytes(b"abc")`
24 | with open("file.txt", "w", encoding="utf8") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, encoding="utf8")`
--> FURB103.py:24:6
--> FURB103_0.py:24:6
|
23 | # FURB103
24 | with open("file.txt", "w", encoding="utf8") as f:
@@ -98,7 +98,7 @@ help: Replace with `Path("file.txt").write_text(foobar, encoding="utf8")`
28 | with open("file.txt", "w", errors="ignore") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, errors="ignore")`
--> FURB103.py:28:6
--> FURB103_0.py:28:6
|
27 | # FURB103
28 | with open("file.txt", "w", errors="ignore") as f:
@@ -122,7 +122,7 @@ help: Replace with `Path("file.txt").write_text(foobar, errors="ignore")`
32 | with open("file.txt", mode="w") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar)`
--> FURB103.py:32:6
--> FURB103_0.py:32:6
|
31 | # FURB103
32 | with open("file.txt", mode="w") as f:
@@ -146,7 +146,7 @@ help: Replace with `Path("file.txt").write_text(foobar)`
36 | with open(foo(), "wb") as f:
FURB103 `open` and `write` should be replaced by `Path(foo()).write_bytes(bar())`
--> FURB103.py:36:6
--> FURB103_0.py:36:6
|
35 | # FURB103
36 | with open(foo(), "wb") as f:
@@ -157,7 +157,7 @@ FURB103 `open` and `write` should be replaced by `Path(foo()).write_bytes(bar())
help: Replace with `Path(foo()).write_bytes(bar())`
FURB103 `open` and `write` should be replaced by `Path("a.txt").write_text(x)`
--> FURB103.py:44:6
--> FURB103_0.py:44:6
|
43 | # FURB103
44 | with open("a.txt", "w") as a, open("b.txt", "wb") as b:
@@ -168,7 +168,7 @@ FURB103 `open` and `write` should be replaced by `Path("a.txt").write_text(x)`
help: Replace with `Path("a.txt").write_text(x)`
FURB103 `open` and `write` should be replaced by `Path("b.txt").write_bytes(y)`
--> FURB103.py:44:31
--> FURB103_0.py:44:31
|
43 | # FURB103
44 | with open("a.txt", "w") as a, open("b.txt", "wb") as b:
@@ -179,7 +179,7 @@ FURB103 `open` and `write` should be replaced by `Path("b.txt").write_bytes(y)`
help: Replace with `Path("b.txt").write_bytes(y)`
FURB103 `open` and `write` should be replaced by `Path("file.txt").write_text(bar(bar(a + x)))`
--> FURB103.py:49:18
--> FURB103_0.py:49:18
|
48 | # FURB103
49 | with foo() as a, open("file.txt", "w") as b, foo() as c:
@@ -190,7 +190,7 @@ FURB103 `open` and `write` should be replaced by `Path("file.txt").write_text(ba
help: Replace with `Path("file.txt").write_text(bar(bar(a + x)))`
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, newline="\r\n")`
--> FURB103.py:58:6
--> FURB103_0.py:58:6
|
57 | # FURB103
58 | with open("file.txt", "w", newline="\r\n") as f:
@@ -214,7 +214,7 @@ help: Replace with `Path("file.txt").write_text(foobar, newline="\r\n")`
62 | import builtins
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, newline="\r\n")`
--> FURB103.py:66:6
--> FURB103_0.py:66:6
|
65 | # FURB103
66 | with builtins.open("file.txt", "w", newline="\r\n") as f:
@@ -237,7 +237,7 @@ help: Replace with `Path("file.txt").write_text(foobar, newline="\r\n")`
70 | from builtins import open as o
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, newline="\r\n")`
--> FURB103.py:74:6
--> FURB103_0.py:74:6
|
73 | # FURB103
74 | with o("file.txt", "w", newline="\r\n") as f:
@@ -260,7 +260,7 @@ help: Replace with `Path("file.txt").write_text(foobar, newline="\r\n")`
78 |
FURB103 [*] `open` and `write` should be replaced by `Path("test.json")....`
--> FURB103.py:154:6
--> FURB103_0.py:154:6
|
152 | data = {"price": 100}
153 |
@@ -284,7 +284,7 @@ help: Replace with `Path("test.json")....`
158 | with open("tmp_path/pyproject.toml", "w") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("tmp_path/pyproject.toml")....`
--> FURB103.py:158:6
--> FURB103_0.py:158:6
|
157 | # See: https://github.com/astral-sh/ruff/issues/21381
158 | with open("tmp_path/pyproject.toml", "w") as f:

View File

@@ -0,0 +1,157 @@
---
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("file.txt").write_text("test")`
--> FURB103_1.py:3:6
|
1 | from pathlib import Path
2 |
3 | with Path("file.txt").open("w") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
4 | f.write("test")
|
help: Replace with `Path("file.txt").write_text("test")`
1 | from pathlib import Path
2 |
- with Path("file.txt").open("w") as f:
- f.write("test")
3 + Path("file.txt").write_text("test")
4 |
5 | with Path("file.txt").open("wb") as f:
6 | f.write(b"test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("file.txt").write_bytes(b"test")`
--> FURB103_1.py:6:6
|
4 | f.write("test")
5 |
6 | with Path("file.txt").open("wb") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
7 | f.write(b"test")
|
help: Replace with `Path("file.txt").write_bytes(b"test")`
3 | with Path("file.txt").open("w") as f:
4 | f.write("test")
5 |
- with Path("file.txt").open("wb") as f:
- f.write(b"test")
6 + Path("file.txt").write_bytes(b"test")
7 |
8 | with Path("file.txt").open(mode="w") as f:
9 | f.write("test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("file.txt").write_text("test")`
--> FURB103_1.py:9:6
|
7 | f.write(b"test")
8 |
9 | with Path("file.txt").open(mode="w") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 | f.write("test")
|
help: Replace with `Path("file.txt").write_text("test")`
6 | with Path("file.txt").open("wb") as f:
7 | f.write(b"test")
8 |
- with Path("file.txt").open(mode="w") as f:
- f.write("test")
9 + Path("file.txt").write_text("test")
10 |
11 | with Path("file.txt").open("w", encoding="utf8") as f:
12 | f.write("test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("file.txt").write_text("test", encoding="utf8")`
--> FURB103_1.py:12:6
|
10 | f.write("test")
11 |
12 | with Path("file.txt").open("w", encoding="utf8") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13 | f.write("test")
|
help: Replace with `Path("file.txt").write_text("test", encoding="utf8")`
9 | with Path("file.txt").open(mode="w") as f:
10 | f.write("test")
11 |
- with Path("file.txt").open("w", encoding="utf8") as f:
- f.write("test")
12 + Path("file.txt").write_text("test", encoding="utf8")
13 |
14 | with Path("file.txt").open("w", errors="ignore") as f:
15 | f.write("test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("file.txt").write_text("test", errors="ignore")`
--> FURB103_1.py:15:6
|
13 | f.write("test")
14 |
15 | with Path("file.txt").open("w", errors="ignore") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
16 | f.write("test")
|
help: Replace with `Path("file.txt").write_text("test", errors="ignore")`
12 | with Path("file.txt").open("w", encoding="utf8") as f:
13 | f.write("test")
14 |
- with Path("file.txt").open("w", errors="ignore") as f:
- f.write("test")
15 + Path("file.txt").write_text("test", errors="ignore")
16 |
17 | with Path(foo()).open("w") as f:
18 | f.write("test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path(foo()).write_text("test")`
--> FURB103_1.py:18:6
|
16 | f.write("test")
17 |
18 | with Path(foo()).open("w") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
19 | f.write("test")
|
help: Replace with `Path(foo()).write_text("test")`
15 | with Path("file.txt").open("w", errors="ignore") as f:
16 | f.write("test")
17 |
- with Path(foo()).open("w") as f:
- f.write("test")
18 + Path(foo()).write_text("test")
19 |
20 | p = Path("file.txt")
21 | with p.open("w") as f:
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `p.write_text("test")`
--> FURB103_1.py:22:6
|
21 | p = Path("file.txt")
22 | with p.open("w") as f:
| ^^^^^^^^^^^^^^^^
23 | f.write("test")
|
help: Replace with `p.write_text("test")`
19 | f.write("test")
20 |
21 | p = Path("file.txt")
- with p.open("w") as f:
- f.write("test")
22 + p.write_text("test")
23 |
24 | with Path("foo", "bar", "baz").open("w") as f:
25 | f.write("test")
FURB103 [*] `Path.open()` followed by `write()` can be replaced by `Path("foo", "bar", "baz").write_text("test")`
--> FURB103_1.py:25:6
|
23 | f.write("test")
24 |
25 | with Path("foo", "bar", "baz").open("w") as f:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
26 | f.write("test")
|
help: Replace with `Path("foo", "bar", "baz").write_text("test")`
22 | with p.open("w") as f:
23 | f.write("test")
24 |
- with Path("foo", "bar", "baz").open("w") as f:
- f.write("test")
25 + Path("foo", "bar", "baz").write_text("test")

View File

@@ -2,7 +2,7 @@
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text("test")`
--> FURB103.py:12:6
--> FURB103_0.py:12:6
|
11 | # FURB103
12 | with open("file.txt", "w") as f:
@@ -26,7 +26,7 @@ help: Replace with `Path("file.txt").write_text("test")`
16 | with open("file.txt", "wb") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_bytes(foobar)`
--> FURB103.py:16:6
--> FURB103_0.py:16:6
|
15 | # FURB103
16 | with open("file.txt", "wb") as f:
@@ -50,7 +50,7 @@ help: Replace with `Path("file.txt").write_bytes(foobar)`
20 | with open("file.txt", mode="wb") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_bytes(b"abc")`
--> FURB103.py:20:6
--> FURB103_0.py:20:6
|
19 | # FURB103
20 | with open("file.txt", mode="wb") as f:
@@ -74,7 +74,7 @@ help: Replace with `Path("file.txt").write_bytes(b"abc")`
24 | with open("file.txt", "w", encoding="utf8") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, encoding="utf8")`
--> FURB103.py:24:6
--> FURB103_0.py:24:6
|
23 | # FURB103
24 | with open("file.txt", "w", encoding="utf8") as f:
@@ -98,7 +98,7 @@ help: Replace with `Path("file.txt").write_text(foobar, encoding="utf8")`
28 | with open("file.txt", "w", errors="ignore") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar, errors="ignore")`
--> FURB103.py:28:6
--> FURB103_0.py:28:6
|
27 | # FURB103
28 | with open("file.txt", "w", errors="ignore") as f:
@@ -122,7 +122,7 @@ help: Replace with `Path("file.txt").write_text(foobar, errors="ignore")`
32 | with open("file.txt", mode="w") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("file.txt").write_text(foobar)`
--> FURB103.py:32:6
--> FURB103_0.py:32:6
|
31 | # FURB103
32 | with open("file.txt", mode="w") as f:
@@ -146,7 +146,7 @@ help: Replace with `Path("file.txt").write_text(foobar)`
36 | with open(foo(), "wb") as f:
FURB103 `open` and `write` should be replaced by `Path(foo()).write_bytes(bar())`
--> FURB103.py:36:6
--> FURB103_0.py:36:6
|
35 | # FURB103
36 | with open(foo(), "wb") as f:
@@ -157,7 +157,7 @@ FURB103 `open` and `write` should be replaced by `Path(foo()).write_bytes(bar())
help: Replace with `Path(foo()).write_bytes(bar())`
FURB103 `open` and `write` should be replaced by `Path("a.txt").write_text(x)`
--> FURB103.py:44:6
--> FURB103_0.py:44:6
|
43 | # FURB103
44 | with open("a.txt", "w") as a, open("b.txt", "wb") as b:
@@ -168,7 +168,7 @@ FURB103 `open` and `write` should be replaced by `Path("a.txt").write_text(x)`
help: Replace with `Path("a.txt").write_text(x)`
FURB103 `open` and `write` should be replaced by `Path("b.txt").write_bytes(y)`
--> FURB103.py:44:31
--> FURB103_0.py:44:31
|
43 | # FURB103
44 | with open("a.txt", "w") as a, open("b.txt", "wb") as b:
@@ -179,7 +179,7 @@ FURB103 `open` and `write` should be replaced by `Path("b.txt").write_bytes(y)`
help: Replace with `Path("b.txt").write_bytes(y)`
FURB103 `open` and `write` should be replaced by `Path("file.txt").write_text(bar(bar(a + x)))`
--> FURB103.py:49:18
--> FURB103_0.py:49:18
|
48 | # FURB103
49 | with foo() as a, open("file.txt", "w") as b, foo() as c:
@@ -190,7 +190,7 @@ FURB103 `open` and `write` should be replaced by `Path("file.txt").write_text(ba
help: Replace with `Path("file.txt").write_text(bar(bar(a + x)))`
FURB103 [*] `open` and `write` should be replaced by `Path("test.json")....`
--> FURB103.py:154:6
--> FURB103_0.py:154:6
|
152 | data = {"price": 100}
153 |
@@ -214,7 +214,7 @@ help: Replace with `Path("test.json")....`
158 | with open("tmp_path/pyproject.toml", "w") as f:
FURB103 [*] `open` and `write` should be replaced by `Path("tmp_path/pyproject.toml")....`
--> FURB103.py:158:6
--> FURB103_0.py:158:6
|
157 | # See: https://github.com/astral-sh/ruff/issues/21381
158 | with open("tmp_path/pyproject.toml", "w") as f:

View File

@@ -0,0 +1,74 @@
---
source: crates/ruff_linter/src/linter.rs
---
invalid-syntax: annotated name `a` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:4:5
|
2 | def f1():
3 | global a
4 | a: str = "foo" # error
| ^
5 |
6 | b: int = 1
|
invalid-syntax: annotated name `b` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:10:9
|
8 | def inner():
9 | global b
10 | b: str = "nested" # error
| ^
11 |
12 | c: int = 1
|
invalid-syntax: annotated name `c` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:15:5
|
13 | def f2():
14 | global c
15 | c: list[str] = [] # error
| ^
16 |
17 | d: int = 1
|
invalid-syntax: annotated name `d` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:20:5
|
18 | def f3():
19 | global d
20 | d: str # error
| ^
21 |
22 | e: int = 1
|
invalid-syntax: annotated name `g` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:29:1
|
27 | f: int = 1 # okay
28 |
29 | g: int = 1
| ^
30 | global g # error
|
invalid-syntax: annotated name `x` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:33:5
|
32 | class C:
33 | x: str
| ^
34 | global x # error
|
invalid-syntax: annotated name `x` can't be global
--> resources/test/fixtures/semantic_errors/annotated_global.py:38:5
|
36 | class D:
37 | global x # error
38 | x: str
| ^
|

View File

@@ -592,11 +592,23 @@ impl FormatString {
fn parse_literal(text: &str) -> Result<(FormatPart, &str), FormatParseError> {
let mut cur_text = text;
let mut result_string = String::new();
let mut pending_escape = false;
while !cur_text.is_empty() {
if pending_escape
&& let Some((unicode_string, remaining)) =
FormatString::parse_escaped_unicode_string(cur_text)
{
result_string.push_str(unicode_string);
cur_text = remaining;
pending_escape = false;
continue;
}
match FormatString::parse_literal_single(cur_text) {
Ok((next_char, remaining)) => {
result_string.push(next_char);
cur_text = remaining;
pending_escape = next_char == '\\' && !pending_escape;
}
Err(err) => {
return if result_string.is_empty() {
@@ -678,6 +690,13 @@ impl FormatString {
}
Err(FormatParseError::UnmatchedBracket)
}
fn parse_escaped_unicode_string(text: &str) -> Option<(&str, &str)> {
text.strip_prefix("N{")?.find('}').map(|idx| {
let end_idx = idx + 3; // 3 for "N{"
(&text[..end_idx], &text[end_idx..])
})
}
}
pub trait FromTemplate<'a>: Sized {
@@ -1020,4 +1039,48 @@ mod tests {
Err(FormatParseError::InvalidCharacterAfterRightBracket)
);
}
#[test]
fn test_format_unicode_escape() {
let expected = Ok(FormatString {
format_parts: vec![FormatPart::Literal("I am a \\N{snowman}".to_owned())],
});
assert_eq!(FormatString::from_str("I am a \\N{snowman}"), expected);
}
#[test]
fn test_format_unicode_escape_with_field() {
let expected = Ok(FormatString {
format_parts: vec![
FormatPart::Literal("I am a \\N{snowman}".to_owned()),
FormatPart::Field {
field_name: "snowman".to_owned(),
conversion_spec: None,
format_spec: String::new(),
},
],
});
assert_eq!(
FormatString::from_str("I am a \\N{snowman}{snowman}"),
expected
);
}
#[test]
fn test_format_multiple_escape_with_field() {
let expected = Ok(FormatString {
format_parts: vec![
FormatPart::Literal("I am a \\\\N".to_owned()),
FormatPart::Field {
field_name: "snowman".to_owned(),
conversion_spec: None,
format_spec: String::new(),
},
],
});
assert_eq!(FormatString::from_str("I am a \\\\N{snowman}"), expected);
}
}

View File

@@ -272,7 +272,9 @@ impl SemanticSyntaxChecker {
fn check_annotation<Ctx: SemanticSyntaxContext>(stmt: &ast::Stmt, ctx: &Ctx) {
match stmt {
Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. }) => {
Stmt::AnnAssign(ast::StmtAnnAssign {
target, annotation, ..
}) => {
if ctx.python_version() > PythonVersion::PY313 {
// test_ok valid_annotation_py313
// # parse_options: {"target-version": "3.13"}
@@ -297,6 +299,18 @@ impl SemanticSyntaxChecker {
};
visitor.visit_expr(annotation);
}
if let Expr::Name(ast::ExprName { id, .. }) = target.as_ref() {
if let Some(global_stmt) = ctx.global(id.as_str()) {
let global_start = global_stmt.start();
if !ctx.in_module_scope() || target.start() < global_start {
Self::add_error(
ctx,
SemanticSyntaxErrorKind::AnnotatedGlobal(id.to_string()),
target.range(),
);
}
}
}
}
Stmt::FunctionDef(ast::StmtFunctionDef {
type_params,

View File

@@ -200,24 +200,22 @@ Configuration override that applies to specific files based on glob patterns.
An override allows you to apply different rule configurations to specific
files or directories. Multiple overrides can match the same file, with
later overrides take precedence.
later overrides take precedence. Override rules take precedence over global
rules for matching files.
### Precedence
- Later overrides in the array take precedence over earlier ones
- Override rules take precedence over global rules for matching files
### Examples
For example, to relax enforcement of rules in test files:
```toml
# Relax rules for test files
[[tool.ty.overrides]]
include = ["tests/**", "**/test_*.py"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "warn"
```
# Ignore generated files but still check important ones
Or, to ignore a rule in generated files but retain enforcement in an important file:
```toml
[[tool.ty.overrides]]
include = ["generated/**"]
exclude = ["generated/important.py"]

View File

@@ -2,6 +2,15 @@
ty defines and respects the following environment variables:
### `TY_CONFIG_FILE`
Path to a `ty.toml` configuration file to use.
When set, ty will use this file for configuration instead of
discovering configuration files automatically.
Equivalent to the `--config-file` command-line argument.
### `TY_LOG`
If set, ty will use this value as the log level for its `--verbose` output.

View File

@@ -9,6 +9,7 @@ use ty_combine::Combine;
use ty_project::metadata::options::{EnvironmentOptions, Options, SrcOptions, TerminalOptions};
use ty_project::metadata::value::{RangedValue, RelativeGlobPattern, RelativePathBuf, ValueSource};
use ty_python_semantic::lint;
use ty_static::EnvVars;
// Configures Clap v3-style help menu colors
const STYLES: Styles = Styles::styled()
@@ -121,7 +122,7 @@ pub(crate) struct CheckCommand {
/// The path to a `ty.toml` file to use for configuration.
///
/// While ty configuration can be included in a `pyproject.toml` file, it is not allowed in this context.
#[arg(long, env = "TY_CONFIG_FILE", value_name = "PATH")]
#[arg(long, env = EnvVars::TY_CONFIG_FILE, value_name = "PATH")]
pub(crate) config_file: Option<SystemPathBuf>,
/// The format to use for printing diagnostic messages.

View File

@@ -515,6 +515,18 @@ fn detect_function_arg_completions<'db>(
parsed: &ParsedModuleRef,
offset: TextSize,
) -> Option<Vec<Completion<'db>>> {
// But be careful: this isn't as simple as just finding a call
// expression. We also have to make sure we are in the "arguments"
// portion of the call. Otherwise we risk incorrectly returning
// something for `(<CURSOR>)(arg1, arg2)`-style expressions.
if !covering_node(parsed.syntax().into(), TextRange::empty(offset))
.ancestors()
.take_while(|node| !node.is_statement())
.any(|node| node.is_arguments())
{
return None;
}
let sig_help = signature_help(db, file, offset)?;
let set_function_args = detect_set_function_args(parsed, offset);
@@ -2594,9 +2606,7 @@ def frob(): ...
assert_snapshot!(
builder.skip_keywords().skip_builtins().build().snapshot(),
@r"
foo
",
@"<No completions found after filtering out completions>",
);
}
@@ -2610,9 +2620,7 @@ def frob(): ...
assert_snapshot!(
builder.skip_keywords().skip_builtins().build().snapshot(),
@r"
foo
",
@"<No completions found after filtering out completions>",
);
}
@@ -3204,7 +3212,7 @@ quux.<CURSOR>
assert_snapshot!(
builder.skip_keywords().skip_builtins().type_signatures().build().snapshot(), @r"
count :: bound method Quux.count(value: Any, /) -> int
index :: bound method Quux.index(value: Any, start: SupportsIndex = Literal[0], stop: SupportsIndex = int, /) -> int
index :: bound method Quux.index(value: Any, start: SupportsIndex = 0, stop: SupportsIndex = ..., /) -> int
x :: int
y :: str
__add__ :: Overload[(value: tuple[int | str, ...], /) -> tuple[int | str, ...], (value: tuple[_T@__add__, ...], /) -> tuple[int | str | _T@__add__, ...]]
@@ -3269,7 +3277,7 @@ bar(o<CURSOR>
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
foo
okay
okay=
"
);
}
@@ -3290,7 +3298,7 @@ bar(o<CURSOR>
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
foo
okay
okay=
"
);
}
@@ -3308,9 +3316,9 @@ foo(b<CURSOR>
assert_snapshot!(
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
bar
barbaz
baz
bar=
barbaz=
baz=
"
);
}
@@ -3327,9 +3335,7 @@ foo(bar=1, b<CURSOR>
assert_snapshot!(
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
baz
"
@"baz="
);
}
@@ -3347,9 +3353,7 @@ abc(o<CURSOR>
assert_snapshot!(
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
okay
"
@"okay="
);
}
@@ -3365,9 +3369,7 @@ abc(okay=1, ba<CURSOR> baz=5
assert_snapshot!(
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
bar
"
@"bar="
);
}
@@ -3411,9 +3413,9 @@ bar(o<CURSOR>
builder.skip_keywords().skip_builtins().skip_auto_import().build().snapshot(),
@r"
foo
okay
okay_abc
okay_okay
okay=
okay_abc=
okay_okay=
"
);
}
@@ -3433,7 +3435,7 @@ bar(<CURSOR>
assert_snapshot!(builder.skip_keywords().skip_builtins().build().snapshot(), @r"
bar
foo
okay
okay=
");
}
@@ -6715,6 +6717,27 @@ def f(zqzqzq: str):
);
}
#[test]
fn auto_import_prioritizes_reusing_import_from_statements() {
let builder = completion_test_builder(
"\
import typing
from typing import Callable
TypedDi<CURSOR>
",
);
assert_snapshot!(
builder.imports().build().snapshot(),
@r"
TypedDict :: , TypedDict
is_typeddict :: , is_typeddict
_FilterConfigurationTypedDict :: from logging.config import _FilterConfigurationTypedDict
_FormatterConfigurationTypedDict :: from logging.config import _FormatterConfigurationTypedDict
",
);
}
/// A way to create a simple single-file (named `main.py`) completion test
/// builder.
///
@@ -6740,6 +6763,7 @@ def f(zqzqzq: str):
skip_builtins: bool,
skip_keywords: bool,
type_signatures: bool,
imports: bool,
module_names: bool,
// This doesn't seem like a "very complex" type to me... ---AG
#[allow(clippy::type_complexity)]
@@ -6772,6 +6796,7 @@ def f(zqzqzq: str):
original,
filtered,
type_signatures: self.type_signatures,
imports: self.imports,
module_names: self.module_names,
}
}
@@ -6832,6 +6857,15 @@ def f(zqzqzq: str):
self
}
/// When set, include the import associated with the
/// completion.
///
/// Not enabled by default.
fn imports(mut self) -> CompletionTestBuilder {
self.imports = true;
self
}
/// When set, the module name for each symbol is included
/// in the snapshot (if available).
fn module_names(mut self) -> CompletionTestBuilder {
@@ -6864,6 +6898,9 @@ def f(zqzqzq: str):
/// Whether type signatures should be included in the snapshot
/// generated by `CompletionTest::snapshot`.
type_signatures: bool,
/// Whether to show the import that will be inserted when this
/// completion is selected.
imports: bool,
/// Whether module names should be included in the snapshot
/// generated by `CompletionTest::snapshot`.
module_names: bool,
@@ -6885,7 +6922,7 @@ def f(zqzqzq: str):
self.filtered
.iter()
.map(|c| {
let mut snapshot = c.name.as_str().to_string();
let mut snapshot = c.insert.as_deref().unwrap_or(c.name.as_str()).to_string();
if self.type_signatures {
let ty =
c.ty.map(|ty| ty.display(self.db).to_string())
@@ -6899,6 +6936,17 @@ def f(zqzqzq: str):
.unwrap_or("<no import required>");
snapshot = format!("{snapshot} :: {module_name}");
}
if self.imports {
if let Some(ref edit) = c.import {
if let Some(import) = edit.content() {
snapshot = format!("{snapshot} :: {import}");
} else {
snapshot = format!("{snapshot} :: <import deletion>");
}
} else {
snapshot = format!("{snapshot} :: <no import edit>");
}
}
snapshot
})
.collect::<Vec<String>>()
@@ -6947,6 +6995,7 @@ def f(zqzqzq: str):
skip_builtins: false,
skip_keywords: false,
type_signatures: false,
imports: false,
module_names: false,
predicate: None,
}

View File

@@ -151,14 +151,19 @@ impl fmt::Display for DisplayHoverContent<'_, '_> {
Some(TypeVarVariance::Bivariant) => " (bivariant)",
None => "",
};
// Special types like `<special-form of whatever 'blahblah' with 'florps'>`
// render poorly with python syntax-highlighting but well as xml
let ty_string = ty
.display_with(self.db, DisplaySettings::default().multiline())
.to_string();
let syntax = if ty_string.starts_with('<') {
"xml"
} else {
"python"
};
self.kind
.fenced_code_block(
format!(
"{}{variance}",
ty.display_with(self.db, DisplaySettings::default().multiline())
),
"python",
)
.fenced_code_block(format!("{ty_string}{variance}"), syntax)
.fmt(f)
}
HoverContent::Docstring(docstring) => docstring.render(self.kind).fmt(f),
@@ -358,7 +363,7 @@ mod tests {
Everyone loves my class!!
---------------------------------------------
```python
```xml
<class 'MyClass'>
```
---
@@ -420,7 +425,7 @@ mod tests {
Everyone loves my class!!
---------------------------------------------
```python
```xml
<class 'MyClass'>
```
---
@@ -480,7 +485,7 @@ mod tests {
initializes MyClass (perfectly)
---------------------------------------------
```python
```xml
<class 'MyClass'>
```
---
@@ -536,7 +541,7 @@ mod tests {
initializes MyClass (perfectly)
---------------------------------------------
```python
```xml
<class 'MyClass'>
```
---
@@ -595,7 +600,7 @@ mod tests {
Everyone loves my class!!
---------------------------------------------
```python
```xml
<class 'MyClass'>
```
---
@@ -1178,13 +1183,13 @@ def ab(a: str): ...
.build();
assert_snapshot!(test.hover(), @r"
(a: int) -> Unknown
def ab(a: int) -> Unknown
---------------------------------------------
the int overload
---------------------------------------------
```python
(a: int) -> Unknown
def ab(a: int) -> Unknown
```
---
the int overload
@@ -1238,13 +1243,13 @@ def ab(a: str):
.build();
assert_snapshot!(test.hover(), @r#"
(a: str) -> Unknown
def ab(a: str) -> Unknown
---------------------------------------------
the int overload
---------------------------------------------
```python
(a: str) -> Unknown
def ab(a: str) -> Unknown
```
---
the int overload
@@ -1298,7 +1303,7 @@ def ab(a: int):
.build();
assert_snapshot!(test.hover(), @r"
(
def ab(
a: int,
b: int
) -> Unknown
@@ -1307,7 +1312,7 @@ def ab(a: int):
---------------------------------------------
```python
(
def ab(
a: int,
b: int
) -> Unknown
@@ -1364,13 +1369,13 @@ def ab(a: int):
.build();
assert_snapshot!(test.hover(), @r"
(a: int) -> Unknown
def ab(a: int) -> Unknown
---------------------------------------------
the two arg overload
---------------------------------------------
```python
(a: int) -> Unknown
def ab(a: int) -> Unknown
```
---
the two arg overload
@@ -1428,7 +1433,7 @@ def ab(a: int, *, c: int):
.build();
assert_snapshot!(test.hover(), @r"
(
def ab(
a: int,
*,
b: int
@@ -1438,7 +1443,7 @@ def ab(a: int, *, c: int):
---------------------------------------------
```python
(
def ab(
a: int,
*,
b: int
@@ -1500,7 +1505,7 @@ def ab(a: int, *, c: int):
.build();
assert_snapshot!(test.hover(), @r"
(
def ab(
a: int,
*,
c: int
@@ -1510,7 +1515,7 @@ def ab(a: int, *, c: int):
---------------------------------------------
```python
(
def ab(
a: int,
*,
c: int
@@ -1559,11 +1564,11 @@ def ab(a: int, *, c: int):
);
assert_snapshot!(test.hover(), @r#"
(
def foo(
a: int,
b
) -> Unknown
(
def foo(
a: str,
b
) -> Unknown
@@ -1572,11 +1577,11 @@ def ab(a: int, *, c: int):
---------------------------------------------
```python
(
def foo(
a: int,
b
) -> Unknown
(
def foo(
a: str,
b
) -> Unknown
@@ -1623,15 +1628,15 @@ def ab(a: int, *, c: int):
);
assert_snapshot!(test.hover(), @r#"
(a: int) -> Unknown
(a: str) -> Unknown
def foo(a: int) -> Unknown
def foo(a: str) -> Unknown
---------------------------------------------
The first overload
---------------------------------------------
```python
(a: int) -> Unknown
(a: str) -> Unknown
def foo(a: int) -> Unknown
def foo(a: str) -> Unknown
```
---
The first overload
@@ -1680,7 +1685,7 @@ def ab(a: int, *, c: int):
Wow this module rocks.
---------------------------------------------
```python
```xml
<module 'lib'>
```
---
@@ -2029,7 +2034,7 @@ def function():
assert_snapshot!(test.hover(), @r"
<class 'Click'>
---------------------------------------------
```python
```xml
<class 'Click'>
```
---------------------------------------------
@@ -2234,7 +2239,7 @@ def function():
Wow this module rocks.
---------------------------------------------
```python
```xml
<module 'lib'>
```
---
@@ -2734,6 +2739,86 @@ def function():
assert_snapshot!(test.hover(), @"Hover provided no content");
}
#[test]
fn hover_dunder_doc() {
let test = cursor_test(
r#"
class My<CURSOR>Class:
__doc__ = "hello there"
"#,
);
assert_snapshot!(test.hover(), @r#"
<class 'MyClass'>
---------------------------------------------
hello there
---------------------------------------------
```xml
<class 'MyClass'>
```
---
hello there
---------------------------------------------
info[hover]: Hovered content is
--> main.py:2:7
|
2 | class MyClass:
| ^^-^^^^
| | |
| | Cursor offset
| source
3 | __doc__ = "hello there"
|
"#);
}
#[test]
fn hover_dunder_doc_complex() {
let test = cursor_test(
r#"
class My<CURSOR>Class:
__doc__ = (
r"""This is some extremely complex docstring
Designed to make
"""
+ r"""
A typechecker
"""
"""
Fall to its knees and sob
"""
r"""
Witness my works and
weep before them
"""
)
"#,
);
assert_snapshot!(test.hover(), @r#"
<class 'MyClass'>
---------------------------------------------
```xml
<class 'MyClass'>
```
---------------------------------------------
info[hover]: Hovered content is
--> main.py:2:7
|
2 | class MyClass:
| ^^-^^^^
| | |
| | Cursor offset
| source
3 | __doc__ = (
4 | r"""This is some extremely complex docstring
|
"#);
}
#[test]
fn hover_class_typevar_variance() {
let test = cursor_test(
@@ -3228,12 +3313,12 @@ def function():
// TODO: We should only show the matching overload here.
// https://github.com/astral-sh/ty/issues/73
assert_snapshot!(test.hover(), @r"
(other: Test, /) -> Test
(other: Other, /) -> Test
def __add__(other: Test, /) -> Test
def __add__(other: Other, /) -> Test
---------------------------------------------
```python
(other: Test, /) -> Test
(other: Other, /) -> Test
def __add__(other: Test, /) -> Test
def __add__(other: Other, /) -> Test
```
---------------------------------------------
info[hover]: Hovered content is
@@ -3343,7 +3428,7 @@ def function():
assert_snapshot!(test.hover(), @r"
<module 'mypackage.subpkg'>
---------------------------------------------
```python
```xml
<module 'mypackage.subpkg'>
```
---------------------------------------------
@@ -3385,7 +3470,7 @@ def function():
assert_snapshot!(test.hover(), @r"
<module 'mypackage.subpkg'>
---------------------------------------------
```python
```xml
<module 'mypackage.subpkg'>
```
---------------------------------------------
@@ -3469,7 +3554,7 @@ def function():
assert_snapshot!(test.hover(), @r"
<module 'mypackage.subpkg.submod'>
---------------------------------------------
```python
```xml
<module 'mypackage.subpkg.submod'>
```
---------------------------------------------
@@ -3510,7 +3595,7 @@ def function():
assert_snapshot!(test.hover(), @r"
<module 'mypackage.subpkg'>
---------------------------------------------
```python
```xml
<module 'mypackage.subpkg'>
```
---------------------------------------------

View File

@@ -745,8 +745,17 @@ impl ImportResponseKind<'_> {
fn priority(&self) -> usize {
match *self {
ImportResponseKind::Unqualified { .. } => 0,
ImportResponseKind::Qualified { .. } => 1,
ImportResponseKind::Partial(_) => 2,
ImportResponseKind::Partial(_) => 1,
// N.B. When given the choice between adding a
// name to an existing `from ... import ...`
// statement and using an existing `import ...`
// in a qualified manner, we currently choose
// the former. Originally we preferred qualification,
// but there is some evidence that this violates
// expectations.
//
// Ref: https://github.com/astral-sh/ty/issues/1274#issuecomment-3352233790
ImportResponseKind::Qualified { .. } => 2,
}
}
}
@@ -1332,9 +1341,9 @@ import collections
);
assert_snapshot!(
test.import("collections", "defaultdict"), @r"
from collections import OrderedDict
from collections import OrderedDict, defaultdict
import collections
collections.defaultdict
defaultdict
");
}

View File

@@ -6216,7 +6216,7 @@ mod tests {
assert_snapshot!(test.inlay_hints(), @r#"
from typing import Literal
a[: <special form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
@@ -6232,7 +6232,7 @@ mod tests {
|
2 | from typing import Literal
3 |
4 | a[: <special form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
4 | a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
| ^^^^^^^
|
@@ -6250,7 +6250,7 @@ mod tests {
|
2 | from typing import Literal
3 |
4 | a[: <special form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
4 | a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
| ^^^
|
@@ -6268,7 +6268,7 @@ mod tests {
|
2 | from typing import Literal
3 |
4 | a[: <special form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
4 | a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
| ^^^
|
@@ -6286,7 +6286,7 @@ mod tests {
|
2 | from typing import Literal
3 |
4 | a[: <special form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
4 | a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
| ^^^
|
"#);
@@ -6636,7 +6636,7 @@ mod tests {
assert_snapshot!(test.inlay_hints(), @r"
from typing import Protocol, TypeVar
T = TypeVar([name=]'T')
Strange[: <special form 'typing.Protocol[T]'>] = Protocol[T]
Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:276:13
@@ -6654,7 +6654,7 @@ mod tests {
2 | from typing import Protocol, TypeVar
3 | T = TypeVar([name=]'T')
| ^^^^
4 | Strange[: <special form 'typing.Protocol[T]'>] = Protocol[T]
4 | Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
|
info[inlay-hint-location]: Inlay Hint Target
@@ -6671,7 +6671,7 @@ mod tests {
|
2 | from typing import Protocol, TypeVar
3 | T = TypeVar([name=]'T')
4 | Strange[: <special form 'typing.Protocol[T]'>] = Protocol[T]
4 | Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
| ^^^^^^^^^^^^^^^
|
@@ -6688,7 +6688,7 @@ mod tests {
|
2 | from typing import Protocol, TypeVar
3 | T = TypeVar([name=]'T')
4 | Strange[: <special form 'typing.Protocol[T]'>] = Protocol[T]
4 | Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
| ^
|
");

View File

@@ -254,7 +254,9 @@ impl<'db> SemanticTokenVisitor<'db> {
}
fn is_constant_name(name: &str) -> bool {
name.chars().all(|c| c.is_uppercase() || c == '_') && name.len() > 1
name.chars()
.all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
&& name.len() > 1
}
fn classify_name(&self, name: &ast::ExprName) -> (SemanticTokenType, SemanticTokenModifier) {
@@ -2230,6 +2232,49 @@ class MyClass:
"###);
}
#[test]
fn test_constant_variations() {
let test = SemanticTokenTest::new(
r#"
A = 1
AB = 1
ABC = 1
A1 = 1
AB1 = 1
ABC1 = 1
A_B = 1
A1_B = 1
A_B1 = 1
A_1 = 1
"#,
);
let tokens = test.highlight_file();
assert_snapshot!(test.to_snapshot(&tokens), @r#"
"A" @ 1..2: Variable [definition]
"1" @ 5..6: Number
"AB" @ 7..9: Variable [definition, readonly]
"1" @ 12..13: Number
"ABC" @ 14..17: Variable [definition, readonly]
"1" @ 20..21: Number
"A1" @ 22..24: Variable [definition, readonly]
"1" @ 27..28: Number
"AB1" @ 29..32: Variable [definition, readonly]
"1" @ 35..36: Number
"ABC1" @ 37..41: Variable [definition, readonly]
"1" @ 44..45: Number
"A_B" @ 46..49: Variable [definition, readonly]
"1" @ 52..53: Number
"A1_B" @ 54..58: Variable [definition, readonly]
"1" @ 61..62: Number
"A_B1" @ 63..67: Variable [definition, readonly]
"1" @ 70..71: Number
"A_1" @ 72..75: Variable [definition, readonly]
"1" @ 78..79: Number
"#);
}
#[test]
fn test_implicitly_concatenated_strings() {
let test = SemanticTokenTest::new(

View File

@@ -124,6 +124,11 @@ fn get_call_expr(
})?;
// Find the covering node at the given position that is a function call.
// Note that we are okay with the range being anywhere within a call
// expression, even if it's not in the arguments portion of the call
// expression. This is because, e.g., a user can request signature
// information at a call site, and this should ideally work anywhere
// within the call site, even at the function name.
let call = covering_node(root_node, token.range())
.find_first(|node| {
if !node.is_expr_call() {

View File

@@ -1225,24 +1225,22 @@ pub struct TerminalOptions {
///
/// An override allows you to apply different rule configurations to specific
/// files or directories. Multiple overrides can match the same file, with
/// later overrides take precedence.
/// later overrides take precedence. Override rules take precedence over global
/// rules for matching files.
///
/// ### Precedence
///
/// - Later overrides in the array take precedence over earlier ones
/// - Override rules take precedence over global rules for matching files
///
/// ### Examples
/// For example, to relax enforcement of rules in test files:
///
/// ```toml
/// # Relax rules for test files
/// [[tool.ty.overrides]]
/// include = ["tests/**", "**/test_*.py"]
///
/// [tool.ty.overrides.rules]
/// possibly-unresolved-reference = "warn"
/// ```
///
/// # Ignore generated files but still check important ones
/// Or, to ignore a rule in generated files but retain enforcement in an important file:
///
/// ```toml
/// [[tool.ty.overrides]]
/// include = ["generated/**"]
/// exclude = ["generated/important.py"]

View File

@@ -169,13 +169,13 @@ def f(x: Any[int]):
`Any` cannot be called (this leads to a `TypeError` at runtime):
```py
Any() # error: [call-non-callable] "Object of type `<special form 'typing.Any'>` is not callable"
Any() # error: [call-non-callable] "Object of type `<special-form 'typing.Any'>` is not callable"
```
`Any` also cannot be used as a metaclass (under the hood, this leads to an implicit call to `Any`):
```py
class F(metaclass=Any): ... # error: [invalid-metaclass] "Metaclass type `<special form 'typing.Any'>` is not callable"
class F(metaclass=Any): ... # error: [invalid-metaclass] "Metaclass type `<special-form 'typing.Any'>` is not callable"
```
And `Any` cannot be used in `isinstance()` checks:

View File

@@ -407,4 +407,22 @@ def f_okay(c: Callable[[], None]):
c.__qualname__ = "my_callable" # okay
```
## From a class
### Subclasses should return themselves, not superclass
```py
from ty_extensions import into_callable
class Base:
def __init__(self) -> None:
pass
class A(Base):
pass
# revealed: () -> A
reveal_type(into_callable(A))
```
[gradual form]: https://typing.python.org/en/latest/spec/glossary.html#term-gradual-form

View File

@@ -205,3 +205,93 @@ class B:
class A(B): ...
class B: ...
```
## Default argument values
### Not deferred in regular files
```py
# error: [unresolved-reference]
def f(mode: int = ParseMode.test):
pass
class ParseMode:
test = 1
```
### Deferred in stub files
Forward references in default argument values are allowed in stub files.
```pyi
def f(mode: int = ParseMode.test): ...
class ParseMode:
test: int
```
### Undefined names are still errors in stub files
```pyi
# error: [unresolved-reference]
def f(mode: int = NeverDefined.test): ...
```
## Class keyword arguments
### Not deferred in regular files
```py
# error: [unresolved-reference]
class Foo(metaclass=SomeMeta):
pass
class SomeMeta(type):
pass
```
### Deferred in stub files
Forward references in class keyword arguments are allowed in stub files.
```pyi
class Foo(metaclass=SomeMeta): ...
class SomeMeta(type): ...
```
### Undefined names are still errors in stub files
```pyi
# error: [unresolved-reference]
class Foo(metaclass=NeverDefined): ...
```
## Lambda default argument values
### Not deferred in regular files
```py
# error: [unresolved-reference]
f = lambda x=Foo(): x
class Foo:
pass
```
### Deferred in stub files
Forward references in lambda default argument values are allowed in stub files.
```pyi
f = lambda x=Foo(): x
class Foo: ...
```
### Undefined names are still errors in stub files
```pyi
# error: [unresolved-reference]
f = lambda x=NeverDefined(): x
```

View File

@@ -59,7 +59,7 @@ python-version = "3.11"
```py
from typing import Never
reveal_type(Never) # revealed: <special form 'typing.Never'>
reveal_type(Never) # revealed: <special-form 'typing.Never'>
```
### Python 3.10

View File

@@ -194,7 +194,7 @@ reveal_type(B().name_does_not_matter()) # revealed: B
reveal_type(B().positional_only(1)) # revealed: B
reveal_type(B().keyword_only(x=1)) # revealed: B
# TODO: This should deally be `B`
reveal_type(B().decorated_method()) # revealed: Unknown
reveal_type(B().decorated_method()) # revealed: Self@decorated_method
reveal_type(B().a_property) # revealed: B

View File

@@ -152,6 +152,20 @@ The expressions in these string annotations aren't valid expressions in this con
shouldn't panic.
```py
# Regression test for https://github.com/astral-sh/ty/issues/1865
# error: [fstring-type-annotation]
stringified_fstring_with_conditional: "f'{1 if 1 else 1}'"
# error: [fstring-type-annotation]
stringified_fstring_with_boolean_expression: "f'{1 or 2}'"
# error: [fstring-type-annotation]
stringified_fstring_with_generator_expression: "f'{(i for i in range(5))}'"
# error: [fstring-type-annotation]
stringified_fstring_with_list_comprehension: "f'{[i for i in range(5)]}'"
# error: [fstring-type-annotation]
stringified_fstring_with_dict_comprehension: "f'{ {i: i for i in range(5)} }'"
# error: [fstring-type-annotation]
stringified_fstring_with_set_comprehension: "f'{ {i for i in range(5)} }'"
a: "1 or 2"
b: "(x := 1)"
# error: [invalid-type-form]

View File

@@ -43,9 +43,7 @@ async def main():
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as pool:
result = await loop.run_in_executor(pool, blocking_function)
# TODO: should be `int`
reveal_type(result) # revealed: Unknown
reveal_type(result) # revealed: int
```
### `asyncio.Task`

View File

@@ -1208,7 +1208,7 @@ def _(flag: bool):
reveal_type(C1.y) # revealed: int | str
C1.y = 100
# error: [invalid-assignment] "Object of type `Literal["problematic"]` is not assignable to attribute `y` on type `<class 'C1'> | <class 'C1'>`"
# error: [invalid-assignment] "Object of type `Literal["problematic"]` is not assignable to attribute `y` on type `<class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:3'> | <class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:8'>`"
C1.y = "problematic"
class C2:

View File

@@ -13,7 +13,7 @@ python-version = "3.10"
class A: ...
class B: ...
reveal_type(A | B) # revealed: <types.UnionType special form 'A | B'>
reveal_type(A | B) # revealed: <types.UnionType special-form 'A | B'>
```
## Union of two classes (prior to 3.10)
@@ -43,14 +43,14 @@ class A: ...
class B: ...
def _(sub_a: type[A], sub_b: type[B]):
reveal_type(A | sub_b) # revealed: <types.UnionType special form>
reveal_type(sub_a | B) # revealed: <types.UnionType special form>
reveal_type(sub_a | sub_b) # revealed: <types.UnionType special form>
reveal_type(A | sub_b) # revealed: <types.UnionType special-form>
reveal_type(sub_a | B) # revealed: <types.UnionType special-form>
reveal_type(sub_a | sub_b) # revealed: <types.UnionType special-form>
class C[T]: ...
class D[T]: ...
reveal_type(C | D) # revealed: <types.UnionType special form 'C[Unknown] | D[Unknown]'>
reveal_type(C | D) # revealed: <types.UnionType special-form 'C[Unknown] | D[Unknown]'>
reveal_type(C[int] | D[str]) # revealed: <types.UnionType special form 'C[int] | D[str]'>
reveal_type(C[int] | D[str]) # revealed: <types.UnionType special-form 'C[int] | D[str]'>
```

View File

@@ -114,6 +114,7 @@ but fall back to `bool` otherwise.
```py
from enum import Enum
from types import FunctionType
from typing import TypeVar
class Answer(Enum):
NO = 0
@@ -137,6 +138,7 @@ reveal_type(isinstance("", int)) # revealed: bool
class A: ...
class SubclassOfA(A): ...
class OtherSubclassOfA(A): ...
class B: ...
reveal_type(isinstance(A, type)) # revealed: Literal[True]
@@ -161,6 +163,29 @@ def _(x: A | B, y: list[int]):
else:
reveal_type(x) # revealed: B & ~A
reveal_type(isinstance(x, B)) # revealed: Literal[True]
T = TypeVar("T")
T_bound_A = TypeVar("T_bound_A", bound=A)
T_constrained = TypeVar("T_constrained", SubclassOfA, OtherSubclassOfA)
def _(
x: T,
x_bound_a: T_bound_A,
x_constrained_sub_a: T_constrained,
):
reveal_type(isinstance(x, object)) # revealed: Literal[True]
reveal_type(isinstance(x, A)) # revealed: bool
reveal_type(isinstance(x_bound_a, object)) # revealed: Literal[True]
reveal_type(isinstance(x_bound_a, A)) # revealed: Literal[True]
reveal_type(isinstance(x_bound_a, SubclassOfA)) # revealed: bool
reveal_type(isinstance(x_bound_a, B)) # revealed: bool
reveal_type(isinstance(x_constrained_sub_a, object)) # revealed: Literal[True]
reveal_type(isinstance(x_constrained_sub_a, A)) # revealed: Literal[True]
reveal_type(isinstance(x_constrained_sub_a, SubclassOfA)) # revealed: bool
reveal_type(isinstance(x_constrained_sub_a, OtherSubclassOfA)) # revealed: bool
reveal_type(isinstance(x_constrained_sub_a, B)) # revealed: bool
```
Certain special forms in the typing module are not instances of `type`, so are strictly-speaking

View File

@@ -113,9 +113,7 @@ intention that it shouldn't influence the method's descriptor behavior. For exam
```py
from typing import Callable
# TODO: this could use a generic signature, but we don't support
# `ParamSpec` and solving of typevars inside `Callable` types yet.
def memoize(f: Callable[[C1, int], str]) -> Callable[[C1, int], str]:
def memoize[**P, R](f: Callable[P, R]) -> Callable[P, R]:
raise NotImplementedError
class C1:
@@ -259,4 +257,37 @@ reveal_type(MyClass().method) # revealed: (...) -> int
reveal_type(MyClass().method.__name__) # revealed: str
```
## classmethods passed through Callable-returning decorators
The behavior described above is also applied to classmethods. If a method is decorated with
`@classmethod`, and also with another decorator which returns a Callable type, we make the
assumption that the decorator returns a callable which still has the classmethod descriptor
behavior.
```py
from typing import Callable
def callable_identity[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return func
class C:
@callable_identity
@classmethod
def f1(cls, x: int) -> str:
return "a"
@classmethod
@callable_identity
def f2(cls, x: int) -> str:
return "a"
# error: [too-many-positional-arguments]
# error: [invalid-argument-type]
C.f1(C, 1)
C.f1(1)
C().f1(1)
C.f2(1)
C().f2(1)
```
[`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092

View File

@@ -57,7 +57,7 @@ We can access attributes on objects of all kinds:
import sys
reveal_type(inspect.getattr_static(sys, "dont_write_bytecode")) # revealed: bool
# revealed: def getattr_static(obj: object, attr: str, default: Any | None = EllipsisType) -> Any
# revealed: def getattr_static(obj: object, attr: str, default: Any | None = ...) -> Any
reveal_type(inspect.getattr_static(inspect, "getattr_static"))
reveal_type(inspect.getattr_static(1, "real")) # revealed: property
@@ -144,7 +144,7 @@ from typing import Any
def _(a: Any, tuple_of_any: tuple[Any]):
reveal_type(inspect.getattr_static(a, "x", "default")) # revealed: Any | Literal["default"]
# revealed: def index(self, value: Any, start: SupportsIndex = Literal[0], stop: SupportsIndex = int, /) -> int
# revealed: def index(self, value: Any, start: SupportsIndex = 0, stop: SupportsIndex = ..., /) -> int
reveal_type(inspect.getattr_static(tuple_of_any, "index", "default"))
```

View File

@@ -444,20 +444,18 @@ When a `@classmethod` is additionally decorated with another decorator, it is st
class method:
```py
from __future__ import annotations
def does_nothing[T](f: T) -> T:
return f
class C:
@classmethod
@does_nothing
def f1(cls: type[C], x: int) -> str:
def f1(cls, x: int) -> str:
return "a"
@does_nothing
@classmethod
def f2(cls: type[C], x: int) -> str:
def f2(cls, x: int) -> str:
return "a"
reveal_type(C.f1(1)) # revealed: str
@@ -600,9 +598,9 @@ from typing_extensions import Self
reveal_type(object.__new__) # revealed: def __new__(cls) -> Self@__new__
reveal_type(object().__new__) # revealed: def __new__(cls) -> Self@__new__
# revealed: Overload[(cls, x: str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc = Literal[0], /) -> Self@__new__, (cls, x: str | bytes | bytearray, /, base: SupportsIndex) -> Self@__new__]
# revealed: Overload[(cls, x: str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc = 0, /) -> Self@__new__, (cls, x: str | bytes | bytearray, /, base: SupportsIndex) -> Self@__new__]
reveal_type(int.__new__)
# revealed: Overload[(cls, x: str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc = Literal[0], /) -> Self@__new__, (cls, x: str | bytes | bytearray, /, base: SupportsIndex) -> Self@__new__]
# revealed: Overload[(cls, x: str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc = 0, /) -> Self@__new__, (cls, x: str | bytes | bytearray, /, base: SupportsIndex) -> Self@__new__]
reveal_type((42).__new__)
class X:

View File

@@ -36,7 +36,7 @@ class Point:
x: int
y: int
reveal_type(Point.__replace__) # revealed: (self: Point, *, x: int = int, y: int = int) -> Point
reveal_type(Point.__replace__) # revealed: (self: Point, *, x: int = ..., y: int = ...) -> Point
```
The `__replace__` method can either be called directly or through the `replace` function:

View File

@@ -602,19 +602,35 @@ super(object, object()).__class__
# Not all objects valid in a class's bases list are valid as the first argument to `super()`.
# For example, it's valid to inherit from `typing.ChainMap`, but it's not valid as the first argument to `super()`.
#
# error: [invalid-super-argument] "`<special form 'typing.ChainMap'>` is not a valid class"
# error: [invalid-super-argument] "`<special-form 'typing.ChainMap'>` is not a valid class"
reveal_type(super(typing.ChainMap, collections.ChainMap())) # revealed: Unknown
# Meanwhile, it's not valid to inherit from unsubscripted `typing.Generic`,
# but it *is* valid as the first argument to `super()`.
#
# revealed: <super: <special form 'typing.Generic'>, <class 'SupportsInt'>>
# revealed: <super: <special-form 'typing.Generic'>, <class 'SupportsInt'>>
reveal_type(super(typing.Generic, typing.SupportsInt))
def _(x: type[typing.Any], y: typing.Any):
reveal_type(super(x, y)) # revealed: <super: Any, Any>
```
### Diagnostic when the invalid type is rendered very verbosely
<!-- snapshot-diagnostics -->
```py
def coinflip() -> bool:
return False
def f():
if coinflip():
class A: ...
else:
class A: ...
super(A, A()) # error: [invalid-super-argument]
```
### Instance Member Access via `super`
Accessing instance members through `super()` is not allowed.

View File

@@ -87,25 +87,25 @@ class C:
def inner_a(positional=self.a):
return
self.a = inner_a
# revealed: def inner_a(positional=Unknown | (def inner_a(positional=Unknown) -> Unknown)) -> Unknown
# revealed: def inner_a(positional=...) -> Unknown
reveal_type(inner_a)
def inner_b(*, kw_only=self.b):
return
self.b = inner_b
# revealed: def inner_b(*, kw_only=Unknown | (def inner_b(*, kw_only=Unknown) -> Unknown)) -> Unknown
# revealed: def inner_b(*, kw_only=...) -> Unknown
reveal_type(inner_b)
def inner_c(positional_only=self.c, /):
return
self.c = inner_c
# revealed: def inner_c(positional_only=Unknown | (def inner_c(positional_only=Unknown, /) -> Unknown), /) -> Unknown
# revealed: def inner_c(positional_only=..., /) -> Unknown
reveal_type(inner_c)
def inner_d(*, kw_only=self.d):
return
self.d = inner_d
# revealed: def inner_d(*, kw_only=Unknown | (def inner_d(*, kw_only=Unknown) -> Unknown)) -> Unknown
# revealed: def inner_d(*, kw_only=...) -> Unknown
reveal_type(inner_d)
```
@@ -114,7 +114,7 @@ We do, however, still check assignability of the default value to the parameter
```py
class D:
def f(self: "D"):
# error: [invalid-parameter-default] "Default value of type `Unknown | (def inner_a(a: int = Unknown | (def inner_a(a: int = Unknown) -> Unknown)) -> Unknown)` is not assignable to annotated parameter type `int`"
# error: [invalid-parameter-default] "Default value of type `Unknown | (def inner_a(a: int = ...) -> Unknown)` is not assignable to annotated parameter type `int`"
def inner_a(a: int = self.a): ...
self.a = inner_a
```
@@ -129,16 +129,16 @@ class C:
self.c = lambda positional_only=self.c, /: positional_only
self.d = lambda *, kw_only=self.d: kw_only
# revealed: (positional=Unknown | ((positional=Unknown) -> Unknown)) -> Unknown
# revealed: (positional=...) -> Unknown
reveal_type(self.a)
# revealed: (*, kw_only=Unknown | ((*, kw_only=Unknown) -> Unknown)) -> Unknown
# revealed: (*, kw_only=...) -> Unknown
reveal_type(self.b)
# revealed: (positional_only=Unknown | ((positional_only=Unknown, /) -> Unknown), /) -> Unknown
# revealed: (positional_only=..., /) -> Unknown
reveal_type(self.c)
# revealed: (*, kw_only=Unknown | ((*, kw_only=Unknown) -> Unknown)) -> Unknown
# revealed: (*, kw_only=...) -> Unknown
reveal_type(self.d)
```

View File

@@ -643,6 +643,91 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None
Person(name="Alice")
```
### Field specifiers using `**kwargs`
Some field specifiers may use `**kwargs` to pass through standard parameters like `default`,
`default_factory`, `init`, `kw_only`, and `alias`. This section tests that all these parameters work
correctly when passed via `**kwargs` for all three kinds of transformers.
#### Function-based transformer
```py
from typing import Any
from typing_extensions import dataclass_transform
def field(**kwargs: Any) -> Any: ...
@dataclass_transform(field_specifiers=(field,))
def create_model[T](cls: type[T]) -> type[T]:
return cls
@create_model
class Person:
id: int = field(init=False)
name: str
age: int = field(default=0)
tags: list[str] = field(default_factory=list)
email: str = field(kw_only=True)
internal_notes: str = field(alias="notes")
# revealed: (self: Person, name: str, age: int = ..., tags: list[str] = ..., notes: str, *, email: str) -> None
reveal_type(Person.__init__)
Person("Alice", 30, [], "some notes", email="alice@example.com")
Person("Bob", email="bob@example.com", notes="other notes")
```
#### Metaclass-based transformer
```py
from typing import Any
from typing_extensions import dataclass_transform
def field(**kwargs: Any) -> Any: ...
@dataclass_transform(field_specifiers=(field,))
class ModelMeta(type): ...
class ModelBase(metaclass=ModelMeta): ...
class Person(ModelBase):
id: int = field(init=False)
name: str
age: int = field(default=0)
tags: list[str] = field(default_factory=list)
email: str = field(kw_only=True)
internal_notes: str = field(alias="notes")
# revealed: (self: Person, name: str, age: int = ..., tags: list[str] = ..., notes: str, *, email: str) -> None
reveal_type(Person.__init__)
Person("Alice", 30, [], "some notes", email="alice@example.com")
Person("Bob", email="bob@example.com", notes="other notes")
```
#### Base-class-based transformer
```py
from typing import Any
from typing_extensions import dataclass_transform
def field(**kwargs: Any) -> Any: ...
@dataclass_transform(field_specifiers=(field,))
class ModelBase: ...
class Person(ModelBase):
id: int = field(init=False)
name: str
age: int = field(default=0)
tags: list[str] = field(default_factory=list)
email: str = field(kw_only=True)
internal_notes: str = field(alias="notes")
# revealed: (self: Person, name: str, age: int = ..., tags: list[str] = ..., notes: str, *, email: str) -> None
reveal_type(Person.__init__)
Person("Alice", 30, [], "some notes", email="alice@example.com")
Person("Bob", email="bob@example.com", notes="other notes")
```
### Support for `alias`
The `alias` parameter in field specifiers allows providing an alternative name for the parameter in
@@ -749,8 +834,8 @@ class Outer:
outer_a: int = outer_field(init=False)
outer_b: str = inner_field(init=False)
reveal_type(Outer.__init__) # revealed: (self: Outer, outer_b: str = Any) -> None
reveal_type(Outer.Inner.__init__) # revealed: (self: Inner, inner_b: str = Any) -> None
reveal_type(Outer.__init__) # revealed: (self: Outer, outer_b: str = ...) -> None
reveal_type(Outer.Inner.__init__) # revealed: (self: Inner, inner_b: str = ...) -> None
```
## Overloaded dataclass-like decorators
@@ -868,4 +953,83 @@ reveal_type(t.key) # revealed: int
reveal_type(t.name) # revealed: str
```
## `__dataclass_fields__` and `DataclassInstance` protocol
Classes created via `dataclass_transform` should have `__dataclass_fields__` and
`__dataclass_params__` attributes, allowing them to satisfy the `DataclassInstance` protocol. This
enables use of `dataclasses.fields`, `dataclasses.asdict`, `dataclasses.replace`, etc.
### Function-based transformer
```py
from dataclasses import fields, asdict, replace, Field
from typing import dataclass_transform, Any
@dataclass_transform()
def create_model[T](cls: type[T]) -> type[T]:
return cls
@create_model
class Person:
name: str
age: int
p = Person("Alice", 30)
reveal_type(Person.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(p.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(fields(Person)) # revealed: tuple[Field[Any], ...]
reveal_type(asdict(p)) # revealed: dict[str, Any]
reveal_type(replace(p, name="Bob")) # revealed: Person
```
### Metaclass-based transformer
```py
from dataclasses import fields, asdict, replace, Field
from typing import dataclass_transform, Any
@dataclass_transform()
class ModelMeta(type): ...
class ModelBase(metaclass=ModelMeta): ...
class Person(ModelBase):
name: str
age: int
p = Person("Alice", 30)
reveal_type(Person.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(p.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(fields(Person)) # revealed: tuple[Field[Any], ...]
reveal_type(asdict(p)) # revealed: dict[str, Any]
reveal_type(replace(p, name="Bob")) # revealed: Person
```
### Base-class-based transformer
```py
from dataclasses import fields, asdict, replace, Field
from typing import dataclass_transform, Any
@dataclass_transform()
class ModelBase: ...
class Person(ModelBase):
name: str
age: int
p = Person("Alice", 30)
reveal_type(Person.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(p.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(fields(Person)) # revealed: tuple[Field[Any], ...]
reveal_type(asdict(p)) # revealed: dict[str, Any]
reveal_type(replace(p, name="Bob")) # revealed: Person
```
[`typing.dataclass_transform`]: https://docs.python.org/3/library/typing.html#typing.dataclass_transform

View File

@@ -69,7 +69,7 @@ class D:
y: str = "default"
z: int | None = 1 + 2
reveal_type(D.__init__) # revealed: (self: D, x: int, y: str = Literal["default"], z: int | None = Literal[3]) -> None
reveal_type(D.__init__) # revealed: (self: D, x: int, y: str = "default", z: int | None = 3) -> None
```
This also works if the declaration and binding are split:
@@ -221,7 +221,7 @@ class D:
(x): int = 1
# TODO: should ideally not include a `x` parameter
reveal_type(D.__init__) # revealed: (self: D, x: int = Literal[1]) -> None
reveal_type(D.__init__) # revealed:(self: D, x: int = 1) -> None
```
## `@dataclass` calls with arguments
@@ -670,7 +670,7 @@ class A:
a: str = field(kw_only=False)
b: int = 0
reveal_type(A.__init__) # revealed: (self: A, a: str, *, b: int = Literal[0]) -> None
reveal_type(A.__init__) # revealed:(self: A, a: str, *, b: int = 0) -> None
A("hi")
```
@@ -991,7 +991,7 @@ class C:
class_variable1: ClassVar[Final[int]] = 1
class_variable2: ClassVar[Final[int]] = 1
reveal_type(C.__init__) # revealed: (self: C, instance_variable_no_default: int, instance_variable: int = Literal[1]) -> None
reveal_type(C.__init__) # revealed:(self: C, instance_variable_no_default: int, instance_variable: int = 1) -> None
c = C(1)
# error: [invalid-assignment] "Cannot assign to final attribute `instance_variable` on type `C`"
@@ -1082,7 +1082,7 @@ class C(Base):
z: int = 10
x: int = 15
reveal_type(C.__init__) # revealed: (self: C, x: int = Literal[15], y: int = Literal[0], z: int = Literal[10]) -> None
reveal_type(C.__init__) # revealed:(self: C, x: int = 15, y: int = 0, z: int = 10) -> None
```
## Conditionally defined fields
@@ -1243,7 +1243,7 @@ class UppercaseString:
class C:
upper: UppercaseString = UppercaseString()
reveal_type(C.__init__) # revealed: (self: C, upper: str = str) -> None
reveal_type(C.__init__) # revealed: (self: C, upper: str = ...) -> None
c = C("abc")
reveal_type(c.upper) # revealed: str
@@ -1289,7 +1289,7 @@ class ConvertToLength:
class C:
converter: ConvertToLength = ConvertToLength()
reveal_type(C.__init__) # revealed: (self: C, converter: str = Literal[""]) -> None
reveal_type(C.__init__) # revealed: (self: C, converter: str = "") -> None
c = C("abc")
reveal_type(c.converter) # revealed: int
@@ -1328,7 +1328,7 @@ class AcceptsStrAndInt:
class C:
field: AcceptsStrAndInt = AcceptsStrAndInt()
reveal_type(C.__init__) # revealed: (self: C, field: str | int = int) -> None
reveal_type(C.__init__) # revealed: (self: C, field: str | int = ...) -> None
```
## `dataclasses.field`

View File

@@ -11,7 +11,7 @@ class Member:
role: str = field(default="user")
tag: str | None = field(default=None, init=False)
# revealed: (self: Member, name: str, role: str = Literal["user"]) -> None
# revealed: (self: Member, name: str, role: str = "user") -> None
reveal_type(Member.__init__)
alice = Member(name="Alice", role="admin")
@@ -37,7 +37,7 @@ class Data:
content: list[int] = field(default_factory=list)
timestamp: datetime = field(default_factory=datetime.now, init=False)
# revealed: (self: Data, content: list[int] = list[int]) -> None
# revealed: (self: Data, content: list[int] = ...) -> None
reveal_type(Data.__init__)
data = Data([1, 2, 3])
@@ -63,7 +63,7 @@ class Person:
age: int | None = field(default=None, kw_only=True)
role: str = field(default="user", kw_only=True)
# revealed: (self: Person, name: str, *, age: int | None = None, role: str = Literal["user"]) -> None
# revealed: (self: Person, name: str, *, age: int | None = None, role: str = "user") -> None
reveal_type(Person.__init__)
alice = Person(role="admin", name="Alice")
@@ -82,8 +82,7 @@ def get_default() -> str:
reveal_type(field(default=1)) # revealed: dataclasses.Field[Literal[1]]
reveal_type(field(default=None)) # revealed: dataclasses.Field[None]
# TODO: this could ideally be `dataclasses.Field[str]` with a better generics solver
reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[Unknown]
reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[str]
```
## dataclass_transform field_specifiers

View File

@@ -144,11 +144,10 @@ from functools import cache
def f(x: int) -> int:
return x**2
# TODO: Should be `_lru_cache_wrapper[int]`
reveal_type(f) # revealed: _lru_cache_wrapper[Unknown]
# TODO: Should be `int`
reveal_type(f(1)) # revealed: Unknown
# revealed: _lru_cache_wrapper[int]
reveal_type(f)
# revealed: int
reveal_type(f(1))
```
## Lambdas as decorators

View File

@@ -11,9 +11,9 @@ classes. Uses of these items should subsequently produce a warning.
from typing_extensions import deprecated
@deprecated("use OtherClass")
def myfunc(): ...
def myfunc(x: int): ...
myfunc() # error: [deprecated] "use OtherClass"
myfunc(1) # error: [deprecated] "use OtherClass"
```
```py

View File

@@ -195,3 +195,52 @@ class C:
c = C()
c.square("hello") # error: [invalid-argument-type]
```
## Types with the same name but from different files
`module.py`:
```py
class Foo: ...
def needs_a_foo(x: Foo): ...
```
`main.py`:
```py
from module import needs_a_foo
class Foo: ...
needs_a_foo(Foo()) # error: [invalid-argument-type]
```
## TypeVars with bounds that have the same name but are from different files
In this case, using fully qualified names is *not* necessary.
```toml
[environment]
python-version = "3.12"
```
`module.py`:
```py
class Foo: ...
def needs_a_foo(x: Foo): ...
```
`main.py`:
```py
from module import needs_a_foo
class Foo: ...
def f[T: Foo](x: T) -> T:
needs_a_foo(x) # error: [invalid-argument-type]
return x
```

View File

@@ -591,9 +591,9 @@ try:
reveal_type(x) # revealed: B | D
reveal_type(x) # revealed: B | D
x = foo
reveal_type(x) # revealed: def foo(param=A) -> Unknown
reveal_type(x) # revealed: def foo(param=...) -> Unknown
except:
reveal_type(x) # revealed: Literal[1] | (def foo(param=A) -> Unknown)
reveal_type(x) # revealed: Literal[1] | (def foo(param=...) -> Unknown)
class Bar:
x = could_raise_returns_E()
@@ -603,9 +603,9 @@ except:
reveal_type(x) # revealed: <class 'Bar'>
finally:
# TODO: should be `Literal[1] | <class 'foo'> | <class 'Bar'>`
reveal_type(x) # revealed: (def foo(param=A) -> Unknown) | <class 'Bar'>
reveal_type(x) # revealed: (def foo(param=...) -> Unknown) | <class 'Bar'>
reveal_type(x) # revealed: (def foo(param=A) -> Unknown) | <class 'Bar'>
reveal_type(x) # revealed: (def foo(param=...) -> Unknown) | <class 'Bar'>
```
[1]: https://astral-sh.notion.site/Exception-handler-control-flow-11348797e1ca80bb8ce1e9aedbbe439d

View File

@@ -24,8 +24,8 @@ reveal_type(lambda a, b: a + b) # revealed: (a, b) -> Unknown
But, it can have default values:
```py
reveal_type(lambda a=1: a) # revealed: (a=Literal[1]) -> Unknown
reveal_type(lambda a, b=2: a) # revealed: (a, b=Literal[2]) -> Unknown
reveal_type(lambda a=1: a) # revealed: (a=1) -> Unknown
reveal_type(lambda a, b=2: a) # revealed: (a, b=2) -> Unknown
```
And, positional-only parameters:
@@ -37,7 +37,7 @@ reveal_type(lambda a, b, /, c: c) # revealed: (a, b, /, c) -> Unknown
And, keyword-only parameters:
```py
reveal_type(lambda a, *, b=2, c: b) # revealed: (a, *, b=Literal[2], c) -> Unknown
reveal_type(lambda a, *, b=2, c: b) # revealed: (a, *, b=2, c) -> Unknown
```
And, variadic parameter:
@@ -55,7 +55,7 @@ reveal_type(lambda **kwargs: kwargs) # revealed: (**kwargs) -> Unknown
Mixing all of them together:
```py
# revealed: (a, b, /, c=Literal[True], *args, *, d=Literal["default"], e=Literal[5], **kwargs) -> Unknown
# revealed: (a, b, /, c=True, *args, *, d="default", e=5, **kwargs) -> Unknown
reveal_type(lambda a, b, /, c=True, *args, d="default", e=5, **kwargs: None)
```
@@ -94,7 +94,7 @@ Here, a `lambda` expression is used as the default value for a parameter in anot
expression.
```py
reveal_type(lambda a=lambda x, y: 0: 2) # revealed: (a=(x, y) -> Unknown) -> Unknown
reveal_type(lambda a=lambda x, y: 0: 2) # revealed: (a=...) -> Unknown
```
## Assignment

View File

@@ -38,7 +38,7 @@ class Product(BaseModel):
name: str = Field(..., kw_only=False, min_length=1)
internal_price_cent: int = Field(..., gt=0, alias="price_cent")
reveal_type(Product.__init__) # revealed: (self: Product, name: str = Any, *, price_cent: int = Any) -> None
reveal_type(Product.__init__) # revealed: (self: Product, name: str = ..., *, price_cent: int = ...) -> None
product = Product("Laptop", price_cent=999_00)

View File

@@ -19,7 +19,7 @@ class User:
id: int
role: str = strawberry.field(default="user")
reveal_type(User.__init__) # revealed: (self: User, *, id: int, role: str = Any) -> None
reveal_type(User.__init__) # revealed: (self: User, *, id: int, role: str = ...) -> None
user = User(id=1)
reveal_type(user.id) # revealed: int

View File

@@ -80,7 +80,7 @@ class Foo(Protocol):
def f[T](self, v: T) -> T: ...
t = (Protocol, int)
reveal_type(t[0]) # revealed: <special form 'typing.Protocol'>
reveal_type(t[0]) # revealed: <special-form 'typing.Protocol'>
class Lorem(t[0]):
def f(self) -> int: ...

View File

@@ -555,8 +555,7 @@ def identity(x: T) -> T:
def head(xs: list[T]) -> T:
return xs[0]
# TODO: this should be `Literal[1]`
reveal_type(invoke(identity, 1)) # revealed: Unknown
reveal_type(invoke(identity, 1)) # revealed: Literal[1]
# TODO: this should be `Unknown | int`
reveal_type(invoke(head, [1, 2, 3])) # revealed: Unknown

View File

@@ -518,8 +518,7 @@ V = TypeVar("V", default="V")
class D(Generic[V]):
x: V
# TODO: we shouldn't leak a typevar like this in type inference
reveal_type(D().x) # revealed: V@D
reveal_type(D().x) # revealed: Unknown
```
## Regression

View File

@@ -493,8 +493,7 @@ def identity[T](x: T) -> T:
def head[T](xs: list[T]) -> T:
return xs[0]
# TODO: this should be `Literal[1]`
reveal_type(invoke(identity, 1)) # revealed: Unknown
reveal_type(invoke(identity, 1)) # revealed: Literal[1]
# TODO: this should be `Unknown | int`
reveal_type(invoke(head, [1, 2, 3])) # revealed: Unknown
@@ -736,3 +735,204 @@ def f[T](x: T, y: Not[T]) -> T:
y = x # error: [invalid-assignment]
return x
```
## `Callable` parameters
### Class constructors
We can recurse into the parameters and return values of `Callable` parameters to infer
specializations of a generic function.
```py
from typing import Any, Callable, NoReturn, overload, Self
from ty_extensions import generic_context, into_callable
def accepts_callable[**P, R](callable: Callable[P, R]) -> Callable[P, R]:
return callable
def returns_int() -> int:
raise NotImplementedError
# revealed: () -> int
reveal_type(into_callable(returns_int))
# revealed: () -> int
reveal_type(accepts_callable(returns_int))
# revealed: int
reveal_type(accepts_callable(returns_int)())
class ClassWithoutConstructor: ...
# revealed: () -> ClassWithoutConstructor
reveal_type(into_callable(ClassWithoutConstructor))
# revealed: () -> ClassWithoutConstructor
reveal_type(accepts_callable(ClassWithoutConstructor))
# revealed: ClassWithoutConstructor
reveal_type(accepts_callable(ClassWithoutConstructor)())
class ClassWithNew:
def __new__(cls, *args, **kwargs) -> Self:
raise NotImplementedError
# revealed: (...) -> ClassWithNew
reveal_type(into_callable(ClassWithNew))
# revealed: (...) -> ClassWithNew
reveal_type(accepts_callable(ClassWithNew))
# revealed: ClassWithNew
reveal_type(accepts_callable(ClassWithNew)())
class ClassWithInit:
def __init__(self) -> None: ...
# revealed: () -> ClassWithInit
reveal_type(into_callable(ClassWithInit))
# revealed: () -> ClassWithInit
reveal_type(accepts_callable(ClassWithInit))
# revealed: ClassWithInit
reveal_type(accepts_callable(ClassWithInit)())
class ClassWithNewAndInit:
def __new__(cls, *args, **kwargs) -> Self:
raise NotImplementedError
def __init__(self, x: int) -> None: ...
# TODO: We do not currently solve a common behavioral supertype for the two solutions of P.
# revealed: ((...) -> ClassWithNewAndInit) | ((x: int) -> ClassWithNewAndInit)
reveal_type(into_callable(ClassWithNewAndInit))
# TODO: revealed: ((...) -> ClassWithNewAndInit) | ((x: int) -> ClassWithNewAndInit)
# revealed: (...) -> ClassWithNewAndInit
reveal_type(accepts_callable(ClassWithNewAndInit))
# revealed: ClassWithNewAndInit
reveal_type(accepts_callable(ClassWithNewAndInit)())
class Meta(type):
def __call__(cls, *args: Any, **kwargs: Any) -> NoReturn:
raise NotImplementedError
class ClassWithNoReturnMetatype(metaclass=Meta):
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
raise NotImplementedError
# TODO: The return types here are wrong, because we end up creating a constraint (Never ≤ R), which
# we confuse with "R has no lower bound".
# revealed: (...) -> Never
reveal_type(into_callable(ClassWithNoReturnMetatype))
# TODO: revealed: (...) -> Never
# revealed: (...) -> Unknown
reveal_type(accepts_callable(ClassWithNoReturnMetatype))
# TODO: revealed: Never
# revealed: Unknown
reveal_type(accepts_callable(ClassWithNoReturnMetatype)())
class Proxy: ...
class ClassWithIgnoredInit:
def __new__(cls) -> Proxy:
return Proxy()
def __init__(self, x: int) -> None: ...
# revealed: () -> Proxy
reveal_type(into_callable(ClassWithIgnoredInit))
# revealed: () -> Proxy
reveal_type(accepts_callable(ClassWithIgnoredInit))
# revealed: Proxy
reveal_type(accepts_callable(ClassWithIgnoredInit)())
class ClassWithOverloadedInit[T]:
t: T # invariant
@overload
def __init__(self: "ClassWithOverloadedInit[int]", x: int) -> None: ...
@overload
def __init__(self: "ClassWithOverloadedInit[str]", x: str) -> None: ...
def __init__(self, x: int | str) -> None: ...
# TODO: The old solver cannot handle this overloaded constructor. The ideal solution is that we
# would solve **P once, and map it to the entire overloaded signature of the constructor. This
# mapping would have to include the return types, since there are different return types for each
# overload. We would then also have to determine that R must be equal to the return type of **P's
# solution.
# revealed: Overload[(x: int) -> ClassWithOverloadedInit[int], (x: str) -> ClassWithOverloadedInit[str]]
reveal_type(into_callable(ClassWithOverloadedInit))
# TODO: revealed: Overload[(x: int) -> ClassWithOverloadedInit[int], (x: str) -> ClassWithOverloadedInit[str]]
# revealed: Overload[(x: int) -> ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str], (x: str) -> ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str]]
reveal_type(accepts_callable(ClassWithOverloadedInit))
# TODO: revealed: ClassWithOverloadedInit[int]
# revealed: ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str]
reveal_type(accepts_callable(ClassWithOverloadedInit)(0))
# TODO: revealed: ClassWithOverloadedInit[str]
# revealed: ClassWithOverloadedInit[int] | ClassWithOverloadedInit[str]
reveal_type(accepts_callable(ClassWithOverloadedInit)(""))
class GenericClass[T]:
t: T # invariant
def __new__(cls, x: list[T], y: list[T]) -> Self:
raise NotImplementedError
def _(x: list[str]):
# TODO: This fails because we are not propagating GenericClass's generic context into the
# Callable that we create for it.
# revealed: (x: list[T@GenericClass], y: list[T@GenericClass]) -> GenericClass[T@GenericClass]
reveal_type(into_callable(GenericClass))
# revealed: ty_extensions.GenericContext[T@GenericClass]
reveal_type(generic_context(into_callable(GenericClass)))
# revealed: (x: list[T@GenericClass], y: list[T@GenericClass]) -> GenericClass[T@GenericClass]
reveal_type(accepts_callable(GenericClass))
# TODO: revealed: ty_extensions.GenericContext[T@GenericClass]
# revealed: None
reveal_type(generic_context(accepts_callable(GenericClass)))
# TODO: revealed: GenericClass[str]
# TODO: no errors
# revealed: GenericClass[T@GenericClass]
# error: [invalid-argument-type]
# error: [invalid-argument-type]
reveal_type(accepts_callable(GenericClass)(x, x))
```
### Don't include identical lower/upper bounds in type mapping multiple times
This is was a performance regression reported in
[ty#1968](https://github.com/astral-sh/ty/issues/1968). Before fixing this, we would see the
`U ≤ M1 | ... | M7` upper bound 7 times. Since we intersect upper bounds before recording a single
type mapping, we would perform 7 intersections. Each intersection would require 7^2 comparisons of
the `Mx` types. We now have a simple heuristics that avoids processing any identical lower or upper
bound more than once, since we know the extra copies cannot affect the result.
```py
from typing import Callable, Generic, TypeVar, Union
class M1: ...
class M2: ...
class M3: ...
class M4: ...
class M5: ...
class M6: ...
class M7: ...
Msg = Union[M1, M2, M3, M4, M5, M6, M7]
T = TypeVar("T")
U_co = TypeVar("U_co", covariant=True)
class Stream(Generic[T]):
def apply(self, func: Callable[["Stream[T]"], "Stream[U_co]"]) -> "Stream[U_co]":
return func(self)
TMsg = TypeVar("TMsg", bound=Msg)
class Builder(Generic[TMsg]):
def build(self) -> Stream[TMsg]:
stream: Stream[TMsg] = Stream()
# TODO: no error
# error: [invalid-assignment]
stream = stream.apply(self._handler)
return stream
def _handler(self, stream: Stream[Msg]) -> Stream[Msg]:
return stream
```

View File

@@ -477,7 +477,7 @@ def keyword_only_with_default_2(*, y: int = 42) -> int:
# parameter list i.e., `()`
# TODO: This shouldn't error
# error: [invalid-argument-type]
# revealed: (*, x: int = Literal[42]) -> bool
# revealed: (*, x: int = 42) -> bool
reveal_type(multiple(keyword_only_with_default_1, keyword_only_with_default_2))
def keyword_only1(*, x: int) -> int:
@@ -503,7 +503,8 @@ class C[**P]:
def __init__(self, f: Callable[P, int]) -> None:
self.f = f
def f(x: int, y: str) -> bool:
# Note that the return type must match exactly, since C is invariant on the return type of C.f.
def f(x: int, y: str) -> int:
return True
c = C(f)
@@ -618,6 +619,22 @@ reveal_type(foo.method) # revealed: bound method Foo[(int, str, /)].method(int,
reveal_type(foo.method(1, "a")) # revealed: str
```
### Gradual types propagate through `ParamSpec` inference
```py
from typing import Callable
def callable_identity[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return func
@callable_identity
def f(env: dict) -> None:
pass
# revealed: (env: dict[Unknown, Unknown]) -> None
reveal_type(f)
```
### Overloads
`overloaded.pyi`:
@@ -662,7 +679,7 @@ reveal_type(change_return_type(int_int)) # revealed: Overload[(x: int) -> str,
reveal_type(change_return_type(int_str)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
# error: [invalid-argument-type]
reveal_type(change_return_type(str_str)) # revealed: Overload[(x: int) -> str, (x: str) -> str]
reveal_type(change_return_type(str_str)) # revealed: (...) -> str
# TODO: Both of these shouldn't raise an error
# error: [invalid-argument-type]

View File

@@ -883,7 +883,7 @@ reveal_type(C[int]().y) # revealed: int
class D[T = T]:
x: T
reveal_type(D().x) # revealed: T@D
reveal_type(D().x) # revealed: Unknown
```
[pep 695]: https://peps.python.org/pep-0695/

View File

@@ -426,7 +426,8 @@ from ty_extensions import ConstraintSet, generic_context
def mentions[T, U]():
# (T@mentions ≤ int) ∧ (U@mentions = list[T@mentions])
constraints = ConstraintSet.range(Never, T, int) & ConstraintSet.range(list[T], U, list[T])
# revealed: ty_extensions.Specialization[T@mentions = int, U@mentions = list[int]]
# TODO: revealed: ty_extensions.Specialization[T@mentions = int, U@mentions = list[int]]
# revealed: ty_extensions.Specialization[T@mentions = int, U@mentions = Unknown]
reveal_type(generic_context(mentions).specialize_constrained(constraints))
```

View File

@@ -77,44 +77,44 @@ IntOrTypeVar = int | T
TypeVarOrNone = T | None
NoneOrTypeVar = None | T
reveal_type(IntOrStr) # revealed: <types.UnionType special form 'int | str'>
reveal_type(IntOrStrOrBytes1) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes2) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes3) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes4) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes5) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes6) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(BytesOrIntOrStr) # revealed: <types.UnionType special form 'bytes | int | str'>
reveal_type(IntOrNone) # revealed: <types.UnionType special form 'int | None'>
reveal_type(NoneOrInt) # revealed: <types.UnionType special form 'None | int'>
reveal_type(IntOrStrOrNone) # revealed: <types.UnionType special form 'int | str | None'>
reveal_type(NoneOrIntOrStr) # revealed: <types.UnionType special form 'None | int | str'>
reveal_type(IntOrAny) # revealed: <types.UnionType special form 'int | Any'>
reveal_type(AnyOrInt) # revealed: <types.UnionType special form 'Any | int'>
reveal_type(NoneOrAny) # revealed: <types.UnionType special form 'None | Any'>
reveal_type(AnyOrNone) # revealed: <types.UnionType special form 'Any | None'>
reveal_type(NeverOrAny) # revealed: <types.UnionType special form 'Any'>
reveal_type(AnyOrNever) # revealed: <types.UnionType special form 'Any'>
reveal_type(UnknownOrInt) # revealed: <types.UnionType special form 'Unknown | int'>
reveal_type(IntOrUnknown) # revealed: <types.UnionType special form 'int | Unknown'>
reveal_type(StrOrZero) # revealed: <types.UnionType special form 'str | Literal[0]'>
reveal_type(ZeroOrStr) # revealed: <types.UnionType special form 'Literal[0] | str'>
reveal_type(IntOrLiteralString) # revealed: <types.UnionType special form 'int | LiteralString'>
reveal_type(LiteralStringOrInt) # revealed: <types.UnionType special form 'LiteralString | int'>
reveal_type(NoneOrTuple) # revealed: <types.UnionType special form 'None | tuple[int, str]'>
reveal_type(TupleOrNone) # revealed: <types.UnionType special form 'tuple[int, str] | None'>
reveal_type(IntOrAnnotated) # revealed: <types.UnionType special form 'int | str'>
reveal_type(AnnotatedOrInt) # revealed: <types.UnionType special form 'str | int'>
reveal_type(IntOrOptional) # revealed: <types.UnionType special form 'int | str | None'>
reveal_type(OptionalOrInt) # revealed: <types.UnionType special form 'str | None | int'>
reveal_type(IntOrTypeOfStr) # revealed: <types.UnionType special form 'int | type[str]'>
reveal_type(TypeOfStrOrInt) # revealed: <types.UnionType special form 'type[str] | int'>
reveal_type(IntOrCallable) # revealed: <types.UnionType special form 'int | ((str, /) -> bytes)'>
reveal_type(CallableOrInt) # revealed: <types.UnionType special form '((str, /) -> bytes) | int'>
reveal_type(TypeVarOrInt) # revealed: <types.UnionType special form 'T@TypeVarOrInt | int'>
reveal_type(IntOrTypeVar) # revealed: <types.UnionType special form 'int | T@IntOrTypeVar'>
reveal_type(TypeVarOrNone) # revealed: <types.UnionType special form 'T@TypeVarOrNone | None'>
reveal_type(NoneOrTypeVar) # revealed: <types.UnionType special form 'None | T@NoneOrTypeVar'>
reveal_type(IntOrStr) # revealed: <types.UnionType special-form 'int | str'>
reveal_type(IntOrStrOrBytes1) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes2) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes3) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes4) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes5) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(IntOrStrOrBytes6) # revealed: <types.UnionType special-form 'int | str | bytes'>
reveal_type(BytesOrIntOrStr) # revealed: <types.UnionType special-form 'bytes | int | str'>
reveal_type(IntOrNone) # revealed: <types.UnionType special-form 'int | None'>
reveal_type(NoneOrInt) # revealed: <types.UnionType special-form 'None | int'>
reveal_type(IntOrStrOrNone) # revealed: <types.UnionType special-form 'int | str | None'>
reveal_type(NoneOrIntOrStr) # revealed: <types.UnionType special-form 'None | int | str'>
reveal_type(IntOrAny) # revealed: <types.UnionType special-form 'int | Any'>
reveal_type(AnyOrInt) # revealed: <types.UnionType special-form 'Any | int'>
reveal_type(NoneOrAny) # revealed: <types.UnionType special-form 'None | Any'>
reveal_type(AnyOrNone) # revealed: <types.UnionType special-form 'Any | None'>
reveal_type(NeverOrAny) # revealed: <types.UnionType special-form 'Any'>
reveal_type(AnyOrNever) # revealed: <types.UnionType special-form 'Any'>
reveal_type(UnknownOrInt) # revealed: <types.UnionType special-form 'Unknown | int'>
reveal_type(IntOrUnknown) # revealed: <types.UnionType special-form 'int | Unknown'>
reveal_type(StrOrZero) # revealed: <types.UnionType special-form 'str | Literal[0]'>
reveal_type(ZeroOrStr) # revealed: <types.UnionType special-form 'Literal[0] | str'>
reveal_type(IntOrLiteralString) # revealed: <types.UnionType special-form 'int | LiteralString'>
reveal_type(LiteralStringOrInt) # revealed: <types.UnionType special-form 'LiteralString | int'>
reveal_type(NoneOrTuple) # revealed: <types.UnionType special-form 'None | tuple[int, str]'>
reveal_type(TupleOrNone) # revealed: <types.UnionType special-form 'tuple[int, str] | None'>
reveal_type(IntOrAnnotated) # revealed: <types.UnionType special-form 'int | str'>
reveal_type(AnnotatedOrInt) # revealed: <types.UnionType special-form 'str | int'>
reveal_type(IntOrOptional) # revealed: <types.UnionType special-form 'int | str | None'>
reveal_type(OptionalOrInt) # revealed: <types.UnionType special-form 'str | None | int'>
reveal_type(IntOrTypeOfStr) # revealed: <types.UnionType special-form 'int | type[str]'>
reveal_type(TypeOfStrOrInt) # revealed: <types.UnionType special-form 'type[str] | int'>
reveal_type(IntOrCallable) # revealed: <types.UnionType special-form 'int | ((str, /) -> bytes)'>
reveal_type(CallableOrInt) # revealed: <types.UnionType special-form '((str, /) -> bytes) | int'>
reveal_type(TypeVarOrInt) # revealed: <types.UnionType special-form 'T@TypeVarOrInt | int'>
reveal_type(IntOrTypeVar) # revealed: <types.UnionType special-form 'int | T@IntOrTypeVar'>
reveal_type(TypeVarOrNone) # revealed: <types.UnionType special-form 'T@TypeVarOrNone | None'>
reveal_type(NoneOrTypeVar) # revealed: <types.UnionType special-form 'None | T@NoneOrTypeVar'>
def _(
int_or_str: IntOrStr,
@@ -295,7 +295,7 @@ X = Foo | Bar
# In an ideal world, perhaps we would respect `Meta.__or__` here and reveal `str`?
# But we still need to record what the elements are, since (according to the typing spec)
# `X` is still a valid type alias
reveal_type(X) # revealed: <types.UnionType special form 'Foo | Bar'>
reveal_type(X) # revealed: <types.UnionType special-form 'Foo | Bar'>
def f(obj: X):
reveal_type(obj) # revealed: Foo | Bar
@@ -391,17 +391,17 @@ MyOptional = T | None
reveal_type(MyList) # revealed: <class 'list[T@MyList]'>
reveal_type(MyDict) # revealed: <class 'dict[T@MyDict, U@MyDict]'>
reveal_type(MyType) # revealed: <special form 'type[T@MyType]'>
reveal_type(MyType) # revealed: <special-form 'type[T@MyType]'>
reveal_type(IntAndType) # revealed: <class 'tuple[int, T@IntAndType]'>
reveal_type(Pair) # revealed: <class 'tuple[T@Pair, T@Pair]'>
reveal_type(Sum) # revealed: <class 'tuple[T@Sum, U@Sum]'>
reveal_type(ListOrTuple) # revealed: <types.UnionType special form 'list[T@ListOrTuple] | tuple[T@ListOrTuple, ...]'>
# revealed: <types.UnionType special form 'list[T@ListOrTupleLegacy] | tuple[T@ListOrTupleLegacy, ...]'>
reveal_type(ListOrTuple) # revealed: <types.UnionType special-form 'list[T@ListOrTuple] | tuple[T@ListOrTuple, ...]'>
# revealed: <types.UnionType special-form 'list[T@ListOrTupleLegacy] | tuple[T@ListOrTupleLegacy, ...]'>
reveal_type(ListOrTupleLegacy)
reveal_type(MyCallable) # revealed: <typing.Callable special form '(**P@MyCallable) -> T@MyCallable'>
reveal_type(AnnotatedType) # revealed: <special form 'typing.Annotated[T@AnnotatedType, <metadata>]'>
reveal_type(MyCallable) # revealed: <typing.Callable special-form '(**P@MyCallable) -> T@MyCallable'>
reveal_type(AnnotatedType) # revealed: <special-form 'typing.Annotated[T@AnnotatedType, <metadata>]'>
reveal_type(TransparentAlias) # revealed: TypeVar
reveal_type(MyOptional) # revealed: <types.UnionType special form 'T@MyOptional | None'>
reveal_type(MyOptional) # revealed: <types.UnionType special-form 'T@MyOptional | None'>
def _(
list_of_ints: MyList[int],
@@ -457,13 +457,13 @@ AnnotatedInt = AnnotatedType[int]
SubclassOfInt = MyType[int]
CallableIntToStr = MyCallable[[int], str]
reveal_type(IntsOrNone) # revealed: <types.UnionType special form 'list[int] | None'>
reveal_type(IntsOrStrs) # revealed: <types.UnionType special form 'tuple[int, int] | tuple[str, str]'>
reveal_type(IntsOrNone) # revealed: <types.UnionType special-form 'list[int] | None'>
reveal_type(IntsOrStrs) # revealed: <types.UnionType special-form 'tuple[int, int] | tuple[str, str]'>
reveal_type(ListOfPairs) # revealed: <class 'list[tuple[str, str]]'>
reveal_type(ListOrTupleOfInts) # revealed: <types.UnionType special form 'list[int] | tuple[int, ...]'>
reveal_type(AnnotatedInt) # revealed: <special form 'typing.Annotated[int, <metadata>]'>
reveal_type(SubclassOfInt) # revealed: <special form 'type[int]'>
reveal_type(CallableIntToStr) # revealed: <typing.Callable special form '(int, /) -> str'>
reveal_type(ListOrTupleOfInts) # revealed: <types.UnionType special-form 'list[int] | tuple[int, ...]'>
reveal_type(AnnotatedInt) # revealed: <special-form 'typing.Annotated[int, <metadata>]'>
reveal_type(SubclassOfInt) # revealed: <special-form 'type[int]'>
reveal_type(CallableIntToStr) # revealed: <typing.Callable special-form '(int, /) -> str'>
def _(
ints_or_none: IntsOrNone,
@@ -495,8 +495,8 @@ MyOtherType = MyType[T]
TypeOrList = MyType[B] | MyList[B]
reveal_type(MyOtherList) # revealed: <class 'list[T@MyOtherList]'>
reveal_type(MyOtherType) # revealed: <special form 'type[T@MyOtherType]'>
reveal_type(TypeOrList) # revealed: <types.UnionType special form 'type[B@TypeOrList] | list[B@TypeOrList]'>
reveal_type(MyOtherType) # revealed: <special-form 'type[T@MyOtherType]'>
reveal_type(TypeOrList) # revealed: <types.UnionType special-form 'type[B@TypeOrList] | list[B@TypeOrList]'>
def _(
list_of_ints: MyOtherList[int],
@@ -730,7 +730,7 @@ def _(doubly_specialized: DictInt[int]):
Union = list[str] | list[int]
# error: [non-subscriptable] "Cannot subscript non-generic type: `<types.UnionType special form 'list[str] | list[int]'>` is already specialized"
# error: [non-subscriptable] "Cannot subscript non-generic type: `<types.UnionType special-form 'list[str] | list[int]'>` is already specialized"
def _(doubly_specialized: Union[int]):
reveal_type(doubly_specialized) # revealed: Unknown
@@ -974,7 +974,7 @@ from typing import Optional
MyOptionalInt = Optional[int]
reveal_type(MyOptionalInt) # revealed: <types.UnionType special form 'int | None'>
reveal_type(MyOptionalInt) # revealed: <types.UnionType special-form 'int | None'>
def _(optional_int: MyOptionalInt):
reveal_type(optional_int) # revealed: int | None
@@ -1007,9 +1007,9 @@ MyLiteralString = LiteralString
MyNoReturn = NoReturn
MyNever = Never
reveal_type(MyLiteralString) # revealed: <special form 'typing.LiteralString'>
reveal_type(MyNoReturn) # revealed: <special form 'typing.NoReturn'>
reveal_type(MyNever) # revealed: <special form 'typing.Never'>
reveal_type(MyLiteralString) # revealed: <special-form 'typing.LiteralString'>
reveal_type(MyNoReturn) # revealed: <special-form 'typing.NoReturn'>
reveal_type(MyNever) # revealed: <special-form 'typing.Never'>
def _(
ls: MyLiteralString,
@@ -1062,8 +1062,8 @@ from typing import Union
IntOrStr = Union[int, str]
IntOrStrOrBytes = Union[int, Union[str, bytes]]
reveal_type(IntOrStr) # revealed: <types.UnionType special form 'int | str'>
reveal_type(IntOrStrOrBytes) # revealed: <types.UnionType special form 'int | str | bytes'>
reveal_type(IntOrStr) # revealed: <types.UnionType special-form 'int | str'>
reveal_type(IntOrStrOrBytes) # revealed: <types.UnionType special-form 'int | str | bytes'>
def _(
int_or_str: IntOrStr,
@@ -1091,7 +1091,7 @@ An empty `typing.Union` leads to a `TypeError` at runtime, so we emit an error.
# error: [invalid-type-form] "`typing.Union` requires at least one type argument"
EmptyUnion = Union[()]
reveal_type(EmptyUnion) # revealed: <types.UnionType special form 'Never'>
reveal_type(EmptyUnion) # revealed: <types.UnionType special-form 'Never'>
def _(empty: EmptyUnion):
reveal_type(empty) # revealed: Never
@@ -1136,14 +1136,14 @@ SubclassOfG = type[G]
SubclassOfGInt = type[G[int]]
SubclassOfP = type[P]
reveal_type(SubclassOfA) # revealed: <special form 'type[A]'>
reveal_type(SubclassOfAny) # revealed: <special form 'type[Any]'>
reveal_type(SubclassOfAOrB1) # revealed: <special form 'type[A | B]'>
reveal_type(SubclassOfAOrB2) # revealed: <types.UnionType special form 'type[A] | type[B]'>
reveal_type(SubclassOfAOrB3) # revealed: <types.UnionType special form 'type[A] | type[B]'>
reveal_type(SubclassOfG) # revealed: <special form 'type[G[Unknown]]'>
reveal_type(SubclassOfGInt) # revealed: <special form 'type[G[int]]'>
reveal_type(SubclassOfP) # revealed: <special form 'type[P]'>
reveal_type(SubclassOfA) # revealed: <special-form 'type[A]'>
reveal_type(SubclassOfAny) # revealed: <special-form 'type[Any]'>
reveal_type(SubclassOfAOrB1) # revealed: <special-form 'type[A | B]'>
reveal_type(SubclassOfAOrB2) # revealed: <types.UnionType special-form 'type[A] | type[B]'>
reveal_type(SubclassOfAOrB3) # revealed: <types.UnionType special-form 'type[A] | type[B]'>
reveal_type(SubclassOfG) # revealed: <special-form 'type[G[Unknown]]'>
reveal_type(SubclassOfGInt) # revealed: <special-form 'type[G[int]]'>
reveal_type(SubclassOfP) # revealed: <special-form 'type[P]'>
def _(
subclass_of_a: SubclassOfA,
@@ -1224,14 +1224,14 @@ SubclassOfG = Type[G]
SubclassOfGInt = Type[G[int]]
SubclassOfP = Type[P]
reveal_type(SubclassOfA) # revealed: <special form 'type[A]'>
reveal_type(SubclassOfAny) # revealed: <special form 'type[Any]'>
reveal_type(SubclassOfAOrB1) # revealed: <special form 'type[A | B]'>
reveal_type(SubclassOfAOrB2) # revealed: <types.UnionType special form 'type[A] | type[B]'>
reveal_type(SubclassOfAOrB3) # revealed: <types.UnionType special form 'type[A] | type[B]'>
reveal_type(SubclassOfG) # revealed: <special form 'type[G[Unknown]]'>
reveal_type(SubclassOfGInt) # revealed: <special form 'type[G[int]]'>
reveal_type(SubclassOfP) # revealed: <special form 'type[P]'>
reveal_type(SubclassOfA) # revealed: <special-form 'type[A]'>
reveal_type(SubclassOfAny) # revealed: <special-form 'type[Any]'>
reveal_type(SubclassOfAOrB1) # revealed: <special-form 'type[A | B]'>
reveal_type(SubclassOfAOrB2) # revealed: <types.UnionType special-form 'type[A] | type[B]'>
reveal_type(SubclassOfAOrB3) # revealed: <types.UnionType special-form 'type[A] | type[B]'>
reveal_type(SubclassOfG) # revealed: <special-form 'type[G[Unknown]]'>
reveal_type(SubclassOfGInt) # revealed: <special-form 'type[G[int]]'>
reveal_type(SubclassOfP) # revealed: <special-form 'type[P]'>
def _(
subclass_of_a: SubclassOfA,
@@ -1346,25 +1346,25 @@ DefaultDictOrNone = DefaultDict[str, int] | None
DequeOrNone = Deque[str] | None
OrderedDictOrNone = OrderedDict[str, int] | None
reveal_type(NoneOrList) # revealed: <types.UnionType special form 'None | list[str]'>
reveal_type(NoneOrSet) # revealed: <types.UnionType special form 'None | set[str]'>
reveal_type(NoneOrDict) # revealed: <types.UnionType special form 'None | dict[str, int]'>
reveal_type(NoneOrFrozenSet) # revealed: <types.UnionType special form 'None | frozenset[str]'>
reveal_type(NoneOrChainMap) # revealed: <types.UnionType special form 'None | ChainMap[str, int]'>
reveal_type(NoneOrCounter) # revealed: <types.UnionType special form 'None | Counter[str]'>
reveal_type(NoneOrDefaultDict) # revealed: <types.UnionType special form 'None | defaultdict[str, int]'>
reveal_type(NoneOrDeque) # revealed: <types.UnionType special form 'None | deque[str]'>
reveal_type(NoneOrOrderedDict) # revealed: <types.UnionType special form 'None | OrderedDict[str, int]'>
reveal_type(NoneOrList) # revealed: <types.UnionType special-form 'None | list[str]'>
reveal_type(NoneOrSet) # revealed: <types.UnionType special-form 'None | set[str]'>
reveal_type(NoneOrDict) # revealed: <types.UnionType special-form 'None | dict[str, int]'>
reveal_type(NoneOrFrozenSet) # revealed: <types.UnionType special-form 'None | frozenset[str]'>
reveal_type(NoneOrChainMap) # revealed: <types.UnionType special-form 'None | ChainMap[str, int]'>
reveal_type(NoneOrCounter) # revealed: <types.UnionType special-form 'None | Counter[str]'>
reveal_type(NoneOrDefaultDict) # revealed: <types.UnionType special-form 'None | defaultdict[str, int]'>
reveal_type(NoneOrDeque) # revealed: <types.UnionType special-form 'None | deque[str]'>
reveal_type(NoneOrOrderedDict) # revealed: <types.UnionType special-form 'None | OrderedDict[str, int]'>
reveal_type(ListOrNone) # revealed: <types.UnionType special form 'list[int] | None'>
reveal_type(SetOrNone) # revealed: <types.UnionType special form 'set[int] | None'>
reveal_type(DictOrNone) # revealed: <types.UnionType special form 'dict[str, int] | None'>
reveal_type(FrozenSetOrNone) # revealed: <types.UnionType special form 'frozenset[int] | None'>
reveal_type(ChainMapOrNone) # revealed: <types.UnionType special form 'ChainMap[str, int] | None'>
reveal_type(CounterOrNone) # revealed: <types.UnionType special form 'Counter[str] | None'>
reveal_type(DefaultDictOrNone) # revealed: <types.UnionType special form 'defaultdict[str, int] | None'>
reveal_type(DequeOrNone) # revealed: <types.UnionType special form 'deque[str] | None'>
reveal_type(OrderedDictOrNone) # revealed: <types.UnionType special form 'OrderedDict[str, int] | None'>
reveal_type(ListOrNone) # revealed: <types.UnionType special-form 'list[int] | None'>
reveal_type(SetOrNone) # revealed: <types.UnionType special-form 'set[int] | None'>
reveal_type(DictOrNone) # revealed: <types.UnionType special-form 'dict[str, int] | None'>
reveal_type(FrozenSetOrNone) # revealed: <types.UnionType special-form 'frozenset[int] | None'>
reveal_type(ChainMapOrNone) # revealed: <types.UnionType special-form 'ChainMap[str, int] | None'>
reveal_type(CounterOrNone) # revealed: <types.UnionType special-form 'Counter[str] | None'>
reveal_type(DefaultDictOrNone) # revealed: <types.UnionType special-form 'defaultdict[str, int] | None'>
reveal_type(DequeOrNone) # revealed: <types.UnionType special-form 'deque[str] | None'>
reveal_type(OrderedDictOrNone) # revealed: <types.UnionType special-form 'OrderedDict[str, int] | None'>
def _(
none_or_list: NoneOrList,
@@ -1457,9 +1457,9 @@ CallableNoArgs = Callable[[], None]
BasicCallable = Callable[[int, str], bytes]
GradualCallable = Callable[..., str]
reveal_type(CallableNoArgs) # revealed: <typing.Callable special form '() -> None'>
reveal_type(BasicCallable) # revealed: <typing.Callable special form '(int, str, /) -> bytes'>
reveal_type(GradualCallable) # revealed: <typing.Callable special form '(...) -> str'>
reveal_type(CallableNoArgs) # revealed: <typing.Callable special-form '() -> None'>
reveal_type(BasicCallable) # revealed: <typing.Callable special-form '(int, str, /) -> bytes'>
reveal_type(GradualCallable) # revealed: <typing.Callable special-form '(...) -> str'>
def _(
callable_no_args: CallableNoArgs,
@@ -1491,8 +1491,8 @@ InvalidCallable1 = Callable[[int]]
# error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
InvalidCallable2 = Callable[int, str]
reveal_type(InvalidCallable1) # revealed: <typing.Callable special form '(...) -> Unknown'>
reveal_type(InvalidCallable2) # revealed: <typing.Callable special form '(...) -> Unknown'>
reveal_type(InvalidCallable1) # revealed: <typing.Callable special-form '(...) -> Unknown'>
reveal_type(InvalidCallable2) # revealed: <typing.Callable special-form '(...) -> Unknown'>
def _(invalid_callable1: InvalidCallable1, invalid_callable2: InvalidCallable2):
reveal_type(invalid_callable1) # revealed: (...) -> Unknown

View File

@@ -53,8 +53,8 @@ in `import os.path as os.path` the `os.path` is not a valid identifier.
```py
from b import Any, Literal, foo
reveal_type(Any) # revealed: <special form 'typing.Any'>
reveal_type(Literal) # revealed: <special form 'typing.Literal'>
reveal_type(Any) # revealed: <special-form 'typing.Any'>
reveal_type(Literal) # revealed: <special-form 'typing.Literal'>
reveal_type(foo) # revealed: <module 'foo'>
```
@@ -132,7 +132,7 @@ reveal_type(Any) # revealed: Unknown
```pyi
from typing import Any
reveal_type(Any) # revealed: <special form 'typing.Any'>
reveal_type(Any) # revealed: <special-form 'typing.Any'>
```
## Nested mixed re-export and not
@@ -169,7 +169,7 @@ reveal_type(Any) # revealed: Unknown
```pyi
from typing import Any
reveal_type(Any) # revealed: <special form 'typing.Any'>
reveal_type(Any) # revealed: <special-form 'typing.Any'>
```
## Exported as different name

View File

@@ -1437,7 +1437,7 @@ are present due to `*` imports.
import collections.abc
reveal_type(collections.abc.Sequence) # revealed: <class 'Sequence'>
reveal_type(collections.abc.Callable) # revealed: <special form 'typing.Callable'>
reveal_type(collections.abc.Callable) # revealed: <special-form 'typing.Callable'>
reveal_type(collections.abc.Set) # revealed: <class 'AbstractSet'>
```

View File

@@ -304,7 +304,7 @@ x11: list[Literal[1] | Literal[2] | Literal[3]] = [1, 2, 3]
reveal_type(x11) # revealed: list[Literal[1, 2, 3]]
x12: Y[Y[Literal[1]]] = [[1]]
reveal_type(x12) # revealed: list[Y[Literal[1]]]
reveal_type(x12) # revealed: list[list[Literal[1]]]
x13: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)]
reveal_type(x13) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]

View File

@@ -289,6 +289,14 @@ reveal_type(x) # revealed: <class 'A'> | <class 'B'>
class Foo(x): ...
reveal_mro(Foo) # revealed: (<class 'Foo'>, Unknown, <class 'object'>)
def f():
if returns_bool():
class C: ...
else:
class C: ...
class D(C): ... # error: [unsupported-base]
```
## `UnionType` instances are now allowed as a base
@@ -301,7 +309,7 @@ class B: ...
EitherOr = A | B
# error: [invalid-base] "Invalid class base with type `<types.UnionType special form 'A | B'>`"
# error: [invalid-base] "Invalid class base with type `<types.UnionType special-form 'A | B'>`"
class Foo(EitherOr): ...
```
@@ -393,7 +401,7 @@ else:
# revealed: (<class 'B'>, <class 'X'>, <class 'Y'>, <class 'O'>, <class 'object'>) | (<class 'B'>, <class 'Y'>, <class 'X'>, <class 'O'>, <class 'object'>)
reveal_mro(B)
# error: 12 [unsupported-base] "Unsupported class base with type `<class 'B'> | <class 'B'>`"
# error: 12 [unsupported-base] "Unsupported class base with type `<class 'mdtest_snippet.B @ src/mdtest_snippet.py:25'> | <class 'mdtest_snippet.B @ src/mdtest_snippet.py:28'>`"
class Z(A, B): ...
reveal_mro(Z) # revealed: (<class 'Z'>, Unknown, <class 'object'>)

View File

@@ -104,6 +104,8 @@ class C:
value: str | None
def foo(c: C):
# The truthiness check `c.value` narrows to `str & ~AlwaysFalsy`.
# The subsequent `len(c.value)` doesn't narrow further since `str` is not narrowable by len().
if c.value and len(c.value):
reveal_type(c.value) # revealed: str & ~AlwaysFalsy
@@ -114,7 +116,7 @@ def foo(c: C):
if c.value is None or not len(c.value):
reveal_type(c.value) # revealed: str | None
else: # c.value is not None and len(c.value)
# TODO: should be # `str & ~AlwaysFalsy`
# `c.value is not None` narrows to `str`, but `str` is not narrowable by len().
reveal_type(c.value) # revealed: str
```

View File

@@ -156,7 +156,7 @@ from typing import Union
IntOrStr = Union[int, str]
reveal_type(IntOrStr) # revealed: <types.UnionType special form 'int | str'>
reveal_type(IntOrStr) # revealed: <types.UnionType special-form 'int | str'>
def _(x: int | str | bytes | memoryview | range):
if isinstance(x, IntOrStr):

View File

@@ -209,7 +209,7 @@ from typing import Union
IntOrStr = Union[int, str]
reveal_type(IntOrStr) # revealed: <types.UnionType special form 'int | str'>
reveal_type(IntOrStr) # revealed: <types.UnionType special-form 'int | str'>
def f(x: type[int | str | bytes | range]):
if issubclass(x, IntOrStr):

View File

@@ -0,0 +1,131 @@
# Narrowing for `len(..)` checks
When `len(x)` is used in a boolean context, we can narrow the type of `x` based on whether `len(x)`
is truthy (non-zero) or falsy (zero).
We apply `~AlwaysFalsy` narrowing when ANY part of the type is narrowable (string/bytes literals,
`LiteralString`, tuples). This removes types that are always falsy (like `Literal[""]`) while
leaving non-narrowable types (like `str`, `list`) unchanged.
## String literals
The intersection with `~AlwaysFalsy` simplifies to just the non-empty literal.
```py
from typing import Literal
def _(x: Literal["foo", ""]):
if len(x):
reveal_type(x) # revealed: Literal["foo"]
else:
reveal_type(x) # revealed: Literal[""]
```
## Bytes literals
```py
from typing import Literal
def _(x: Literal[b"foo", b""]):
if len(x):
reveal_type(x) # revealed: Literal[b"foo"]
else:
reveal_type(x) # revealed: Literal[b""]
```
## LiteralString
```toml
[environment]
python-version = "3.11"
```
```py
from typing import LiteralString
def _(x: LiteralString):
if len(x):
reveal_type(x) # revealed: LiteralString & ~Literal[""]
else:
reveal_type(x) # revealed: Literal[""]
```
## Tuples
Ideally we'd narrow these types further, e.g. to `tuple[int, ...] & ~tuple[()]` in the positive case
and `tuple[()]` in the negative case (see <https://github.com/astral-sh/ty/issues/560>).
```py
def _(x: tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysFalsy
else:
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysTruthy
```
## Unions of narrowable types
```py
from typing import Literal
def _(x: Literal["foo", ""] | tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: Literal["foo"] | (tuple[int, ...] & ~AlwaysFalsy)
else:
reveal_type(x) # revealed: Literal[""] | (tuple[int, ...] & ~AlwaysTruthy)
```
## Types that are not narrowed
For `str`, `list`, and other types where a subclass could have a `__bool__` that disagrees with
`__len__`, we do not narrow:
```py
def not_narrowed_str(x: str):
if len(x):
# No narrowing because `str` could be subclassed with a custom `__bool__`
reveal_type(x) # revealed: str
def not_narrowed_list(x: list[int]):
if len(x):
# No narrowing because `list` could be subclassed with a custom `__bool__`
reveal_type(x) # revealed: list[int]
```
## Mixed unions (narrowable and non-narrowable)
When a union contains both narrowable and non-narrowable types, we narrow the narrowable parts while
leaving the non-narrowable parts unchanged:
```py
from typing import Literal
def _(x: Literal["foo", ""] | list[int]):
if len(x):
# `Literal[""]` is removed, `list[int]` is unchanged
reveal_type(x) # revealed: Literal["foo"] | list[int]
else:
reveal_type(x) # revealed: Literal[""] | list[int]
```
## Narrowing away empty literals
This pattern is common when a prior truthiness check narrows a type, and then a conditional
expression adds an empty literal back:
```py
def _(lines: list[str]):
for line in lines:
if not line:
continue
reveal_type(line) # revealed: str & ~AlwaysFalsy
value = line if len(line) < 3 else ""
reveal_type(value) # revealed: (str & ~AlwaysFalsy) | Literal[""]
if len(value):
# `Literal[""]` is removed, `str & ~AlwaysFalsy` is unchanged
reveal_type(value) # revealed: str & ~AlwaysFalsy
# Accessing value[0] is safe here
_ = value[0]
```

View File

@@ -113,7 +113,7 @@ MyList: TypeAlias = list[T]
ListOrSet: TypeAlias = list[T] | set[T]
reveal_type(MyList) # revealed: <class 'list[T]'>
reveal_type(ListOrSet) # revealed: <types.UnionType special form 'list[T] | set[T]'>
reveal_type(ListOrSet) # revealed: <types.UnionType special-form 'list[T] | set[T]'>
def _(list_of_int: MyList[int], list_or_set_of_str: ListOrSet[str]):
reveal_type(list_of_int) # revealed: list[int]
@@ -293,7 +293,7 @@ def _(rec: RecursiveHomogeneousTuple):
reveal_type(rec) # revealed: tuple[Divergent, ...]
ClassInfo: TypeAlias = type | UnionType | tuple["ClassInfo", ...]
reveal_type(ClassInfo) # revealed: <types.UnionType special form 'type | UnionType | tuple[Divergent, ...]'>
reveal_type(ClassInfo) # revealed: <types.UnionType special-form 'type | UnionType | tuple[Divergent, ...]'>
def my_isinstance(obj: object, classinfo: ClassInfo) -> bool:
# TODO should be `type | UnionType | tuple[ClassInfo, ...]`

View File

@@ -97,7 +97,7 @@ inside the module:
import typing
reveal_type(typing.__name__) # revealed: str
reveal_type(typing.__init__) # revealed: bound method ModuleType.__init__(name: str, doc: str | None = EllipsisType) -> None
reveal_type(typing.__init__) # revealed: bound method ModuleType.__init__(name: str, doc: str | None = ...) -> None
# For a stub module, we don't know that `__file__` is a string (at runtime it may be entirely
# unset, but we follow typeshed here):

View File

@@ -37,7 +37,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/attribute_as
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal[1]` is not assignable to attribute `attr` on type `<class 'C1'> | <class 'C1'>`
error[invalid-assignment]: Object of type `Literal[1]` is not assignable to attribute `attr` on type `<class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:3'> | <class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:7'>`
--> src/mdtest_snippet.py:11:5
|
10 | # TODO: The error message here could be improved to explain why the assignment fails.

View File

@@ -15,9 +15,9 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/deprecated.md
1 | from typing_extensions import deprecated
2 |
3 | @deprecated("use OtherClass")
4 | def myfunc(): ...
4 | def myfunc(x: int): ...
5 |
6 | myfunc() # error: [deprecated] "use OtherClass"
6 | myfunc(1) # error: [deprecated] "use OtherClass"
7 | from typing_extensions import deprecated
8 |
9 | @deprecated("use BetterClass")
@@ -42,9 +42,9 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/deprecated.md
warning[deprecated]: The function `myfunc` is deprecated
--> src/mdtest_snippet.py:6:1
|
4 | def myfunc(): ...
4 | def myfunc(x: int): ...
5 |
6 | myfunc() # error: [deprecated] "use OtherClass"
6 | myfunc(1) # error: [deprecated] "use OtherClass"
| ^^^^^^ use OtherClass
7 | from typing_extensions import deprecated
|

View File

@@ -0,0 +1,53 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_argument_type.md - Invalid argument type diagnostics - TypeVars with bounds that have the same name but are from different files
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_argument_type.md
---
# Python source files
## module.py
```
1 | class Foo: ...
2 |
3 | def needs_a_foo(x: Foo): ...
```
## main.py
```
1 | from module import needs_a_foo
2 |
3 | class Foo: ...
4 |
5 | def f[T: Foo](x: T) -> T:
6 | needs_a_foo(x) # error: [invalid-argument-type]
7 | return x
```
# Diagnostics
```
error[invalid-argument-type]: Argument to function `needs_a_foo` is incorrect
--> src/main.py:6:17
|
5 | def f[T: Foo](x: T) -> T:
6 | needs_a_foo(x) # error: [invalid-argument-type]
| ^ Expected `Foo`, found `T@f`
7 | return x
|
info: Function defined here
--> src/module.py:3:5
|
1 | class Foo: ...
2 |
3 | def needs_a_foo(x: Foo): ...
| ^^^^^^^^^^^ ------ Parameter declared here
|
info: rule `invalid-argument-type` is enabled by default
```

Some files were not shown because too many files have changed in this diff Show More