Compare commits

..

52 Commits

Author SHA1 Message Date
Brent Westbrook
cabdd969ec convert to serializable diagnostics 2025-07-21 16:09:52 -04:00
Brent Westbrook
2e5c8b9799 Revert "custom serializers"
This reverts commit e1219bc27c.
2025-07-21 16:09:43 -04:00
Brent Westbrook
e1219bc27c custom serializers 2025-07-21 16:09:27 -04:00
Micha Reiser
926e83323a [ty] Avoid rechecking the entire project when changing the opened files (#19463) 2025-07-21 18:05:03 +02:00
Micha Reiser
5cace28c3e [ty] Add warning for unknown TY_MEMORY_REPORT value (#19465) 2025-07-21 14:29:24 +00:00
github-actions[bot]
3785e13231 [ty] Sync vendored typeshed stubs (#19461)
Co-authored-by: typeshedbot <>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-07-21 14:01:42 +01:00
Alex Waygood
c2380fa0e2 [ty] Extend tuple __len__ and __bool__ special casing to also cover tuple subclasses (#19289)
Co-authored-by: Brent Westbrook
2025-07-21 12:50:46 +00:00
Alex Waygood
4dec44ae49 [ty] bump docstring-adder pin (#19458) 2025-07-21 13:38:40 +01:00
David Peter
b6579eaf04 [ty] Disallow assignment to Final class attributes (#19457)
## Summary

Emit errors for the following assignments:
```py
class C:
    CLASS_LEVEL_CONSTANT: Final[int] = 1

C.CLASS_LEVEL_CONSTANT = 2
C().CLASS_LEVEL_CONSTANT = 2
```

## Test Plan

Updated and new MD tests
2025-07-21 14:27:56 +02:00
renovate[bot]
f063c0e874 Update dependency ruff to v0.12.4 (#19442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:32:09 +02:00
renovate[bot]
6a65734ee3 Update pre-commit hook astral-sh/ruff-pre-commit to v0.12.4 (#19443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:31:55 +02:00
renovate[bot]
00066e094c Update rui314/setup-mold digest to 702b190 (#19441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:31:47 +02:00
renovate[bot]
37a1958374 Update taiki-e/install-action action to v2.56.19 (#19448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:31:18 +02:00
renovate[bot]
2535d791ae Update Rust crate strum_macros to v0.27.2 (#19447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:31:07 +02:00
renovate[bot]
05c4399e7b Update Rust crate strum to v0.27.2 (#19446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:30:51 +02:00
renovate[bot]
b18434b0f6 Update Rust crate rand to v0.9.2 (#19444)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:30:07 +02:00
renovate[bot]
17779c9a17 Update Rust crate serde_json to v1.0.141 (#19445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 08:29:51 +02:00
Dylan
53fc0614da Fix unreachable panic in parser (#19183)
Parsing the (invalid) expression `f"{\t"i}"` caused a panic because the
`TStringMiddle` character was "unreachable" due the way the parser
recovered from the line continuation (it ate the t-string start).

The cause of the issue is as follows: 

The parser begins parsing the f-string and expects to see a list of
objects, essentially alternating between _interpolated elements_ and
ordinary strings. It is happy to see the first left brace, but then
there is a lexical error caused by the line-continuation character. So
instead of the parser seeing a list of elements with just one member, it
sees a list that starts like this:

- Interpolated element with an invalid token, stored as a `Name`
- Something else built from tokens beginning with `TStringStart` and
`TStringMiddle`

When it sees the `TStringStart` error recovery says "that's a list
element I don't know what to do with, let's skip it". When it sees
`TStringMiddle` it says "oh, that looks like the middle of _some
interpolated string_ so let's try to parse it as one of the literal
elements of my `FString`". Unfortunately, the function being used to
parse individual list elements thinks (arguably correctly) that it's not
possible to have a `TStringMiddle` sitting in your `FString`, and hits
`unreachable`.

Two potential ways (among many) to solve this issue are:

1. Allow a `TStringMiddle` as a valid "literal" part of an f-string
during parsing (with the hope/understanding that this would only occur
in an invalid context)
2. Skip the `TStringMiddle` as an "unexpected/invalid list item" in the
same way that we skipped `TStringStart`.

I have opted for the second approach since it seems somehow more morally
correct, even though it loses more information. To implement this, the
recovery context needs to know whether we are in an f-string or t-string
- hence the changes to that enum. As a bonus we get slightly more
specific error messages in some cases.

Closes #18860
2025-07-20 22:04:14 +00:00
Dan Parizher
59249f483b [ruff] Support byte strings (RUF055) (#18926)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Closes #18739

## Test Plan

<!-- How was it tested? -->

---------

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
2025-07-20 17:58:40 -04:00
Micha Reiser
84e76f4d04 [ty] Avoid second lookup for infer_maybe_standalone_expression (#19439) 2025-07-20 18:22:04 +02:00
UnboundVariable
0acc273286 [ty] Implemented "go to definition" support for import statements (#19428)
This PR extends the "go to declaration" and "go to definition"
functionality to support import statements — both standard imports and
"from" import forms.

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
2025-07-19 11:22:07 -07:00
Micha Reiser
93a9fabb26 [ty] Avoid secondary tree traversal to get call expression for keyword arguments (#19429) 2025-07-19 18:21:27 +02:00
Micha Reiser
98d1811dd1 [ty] Add goto definition to playground (#19425) 2025-07-19 15:44:44 +02:00
Aria Desires
06f9f52e59 [ty] Add support for @warnings.deprecated (#19376)
* [x] basic handling
  * [x] parse and discover `@warnings.deprecated` attributes
  * [x] associate them with function definitions
  * [x] associate them with class definitions
  * [x] add a new "deprecated" diagnostic
* [x] ensure diagnostic is styled appropriately for LSPs
(DiagnosticTag::Deprecated)

* [x] functions
  * [x] fire on calls
  * [x] fire on arbitrary references 
* [x] classes
  * [x] fire on initializers
  * [x] fire on arbitrary references
* [x] methods
  * [x] fire on calls
  * [x] fire on arbitrary references
* [ ] overloads
  * [ ] fire on calls
  * [ ] fire on arbitrary references(??? maybe not ???)
  * [ ] only fire if the actual selected overload is deprecated 

* [ ] dunder desugarring (warn on deprecated `__add__` if `+` is
invoked)
* [ ] alias supression? (don't warn on uses of variables that deprecated
items were assigned to)

* [ ] import logic
  * [x] fire on imports of deprecated items
* [ ] suppress subsequent diagnostics if the import diagnostic fired (is
this handled by alias supression?)
  * [x] fire on all qualified references (`module.mydeprecated`)
  * [x] fire on all references that depend on a `*` import
    


Fixes https://github.com/astral-sh/ty/issues/153
2025-07-18 23:50:29 +00:00
Jack O'Connor
e9a64e5825 [ty] make del x force local resolution of x in the current scope (#19389)
Fixes https://github.com/astral-sh/ty/issues/769.

**Updated:** The preferred approach here is to keep the SemanticIndex
simple (`del` of any name marks that name "bound" in the current scope)
and to move complexity to type inference (free variable resolution stops
when it finds a binding, unless that binding is declared `nonlocal`). As
part of this change, free variable resolution will now union the types
it finds as it walks in enclosing scopes. This approach is still
incomplete, because it doesn't consider inner scopes or sibling scopes,
but it improves the common case.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-07-18 14:58:32 -07:00
UnboundVariable
360eb7005f [ty] Added support for "go to definition" for attribute accesses and keyword arguments (#19417)
This PR builds upon #19371. It addresses a few additional code review
suggestions and adds support for attribute accesses (expressions of the
form `x.y`) and keyword arguments within call expressions.

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
2025-07-18 11:33:57 -07:00
Micha Reiser
630c7a3152 [ty] Reduce number of inline stored definitions per place (#19409) 2025-07-18 18:28:46 +02:00
Ibraheem Ahmed
e6e029a8b7 Update salsa (#19258)
## Summary

Pulls in https://github.com/salsa-rs/salsa/pull/934.
2025-07-18 12:14:28 -04:00
Andrew Gallant
64f9481fd0 [ty] Add caching for submodule completion suggestions (#19408)
This change makes it so we aren't doing a directory traversal every time
we ask for completions from a module. Specifically, submodules that
aren't attributes of their parent module can only be discovered by
looking at the directory tree. But we want to avoid doing a directory
scan unless we think there are changes.

To make this work, this change does a little bit of surgery to
`FileRoot`. Previously, a `FileRoot` was only used for library search
paths. Its revision was bumped whenever a file in that tree was added,
deleted or even modified (to support the discovery of `pth` files and
changes to its contents). This generally seems fine since these are
presumably dependency paths that shouldn't change frequently.

In this change, we add a `FileRoot` for the project. But having the
`FileRoot`'s revision bumped for every change in the project makes
caching based on that `FileRoot` rather ineffective. That is, cache
invalidation will occur too aggressively. To the point that there is
little point in adding caching in the first place. To mitigate this, a
`FileRoot`'s revision is only bumped on a change to a child file's
contents when the `FileRoot` is a `LibrarySearchPath`. Otherwise, we
only bump the revision when a file is created or added.

The effect is that, at least in VS Code, when a new module is added or
removed, this change is picked up and the cache is properly invalidated.
Other LSP clients with worse support for file watching (which seems to
be the case for the CoC vim plugin that I use) don't work as well. Here,
the cache is less likely to be invalidated which might cause completions
to have stale results. Unless there's an obvious way to fix or improve
this, I propose punting on improvements here for now.
2025-07-18 11:54:27 -04:00
Dhruv Manilawala
99d0ac60b4 [ty] Track open files in the server (#19264)
## Summary

This PR updates the server to keep track of open files both system and
virtual files.

This is done by updating the project by adding the file in the open file
set in `didOpen` notification and removing it in `didClose`
notification.

This does mean that for workspace diagnostics, ty will only check open
files because the behavior of different diagnostic builder is to first
check `is_file_open` and only add diagnostics for open files. So, this
required updating the `is_file_open` model to be `should_check_file`
model which validates whether the file needs to be checked based on the
`CheckMode`. If the check mode is open files only then it will check
whether the file is open. If it's all files then it'll return `true` by
default.

Closes: astral-sh/ty#619

## Test Plan

### Before

There are two files in the project: `__init__.py` and `diagnostics.py`.

In the video, I'm demonstrating the old behavior where making changes to
the (open) `diagnostics.py` file results in re-parsing the file:


https://github.com/user-attachments/assets/c2ac0ecd-9c77-42af-a924-c3744b146045

### After

Same setup as above.

In the video, I'm demonstrating the new behavior where making changes to
the (open) `diagnostics.py` file doesn't result in re-parting the file:


https://github.com/user-attachments/assets/7b82fe92-f330-44c7-b527-c841c4545f8f
2025-07-18 19:33:35 +05:30
Andrew Gallant
ba7ed3a6f9 [ty] Use as the "cut" indicator in diagnostic rendering (#19420)
This makes ty match ruff's behavior. Specifically, we want to use `…`
instead of the default `...` because `...` has special significance in
Python.
2025-07-18 07:46:48 -04:00
justin
39b41838f3 [ty] synthesize __setattr__ for frozen dataclasses (#19307)
## Summary

Synthesize a `__setattr__` method with a return type of `Never` for
frozen dataclasses.

https://docs.python.org/3/library/dataclasses.html#frozen-instances

https://docs.python.org/3/library/dataclasses.html#dataclasses.FrozenInstanceError

### Related
https://github.com/astral-sh/ty/issues/111
https://github.com/astral-sh/ruff/pull/17974#discussion_r2108527106
https://github.com/astral-sh/ruff/pull/18347#discussion_r2128174665

## Test Plan

New Markdown tests

---------

Co-authored-by: David Peter <mail@david-peter.de>
2025-07-18 11:35:05 +02:00
UnboundVariable
c7640a433e [ty] Fixed bug in semantic token provider for parameters. (#19418)
This fixes https://github.com/astral-sh/ty/issues/832.

New tests were added to prevent future regressions.

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
2025-07-18 00:02:23 -07:00
Micha Reiser
1765014be3 [ty] Shrink reachability constraints (#19410) 2025-07-18 07:36:18 +02:00
Brent Westbrook
997dc2e7cc Move JUnit rendering to ruff_db (#19370)
Summary
--

This PR moves the JUnit output format to the new rendering
infrastructure. As I
mention in a TODO in the code, there's some code that will be shared
with the
`grouped` output format. Hopefully I'll have that PR up too by the time
this one
is reviewed.

Test Plan
--

Existing tests moved to `ruff_db`

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-17 18:24:13 -04:00
Douglas Creager
4aee0398cb [ty] Show the raw argument type in reveal_type (#19400)
This PR is changes how `reveal_type` determines what type to reveal, in
a way that should be a no-op to most callers.

Previously, we would reveal the type of the first parameter, _after_ all
of the call binding machinery had done its work. This includes inferring
the specialization of a generic function, and then applying that
specialization to all parameter and argument types, which is relevant
since the typeshed definition of `reveal_type` is generic:

```pyi
def reveal_type(obj: _T, /) -> _T: ...
```

Normally this does not matter, since we infer `_T = [arg type]` and
apply that to the parameter type, yielding `[arg type]`. But applying
that specialization also simplifies the argument type, which makes
`reveal_type` less useful as a debugging aid when we want to see the
actual, raw, unsimplified argument type.

With this patch, we now grab the original unmodified argument type and
reveal that instead.

In addition to making the debugging aid example work, this also makes
our `reveal_type` implementation more robust to custom typeshed
definitions, such as

```py
def reveal_type(obj: Any) -> Any: ...
```

(That custom definition is probably not what anyone would want, since
you wouldn't be able to depend on the return type being equivalent to
the argument type, but still)
2025-07-17 16:50:29 -04:00
Brent Westbrook
1fd9103e81 Canonicalize path before filtering (#19407)
## Summary

This came up on
[Discord](https://discord.com/channels/1039017663004942429/1343692072921731082/1395447082520678440)
and also in #19387, but on macOS the tmp directory is a symlink to
`/private/tmp`, which breaks this filter. I'm still not quite sure why
only these tests are affected when we use the `tempdir_filter`
elsewhere, but hopefully this fixes the immediate issue. Just
`tempdir.path().canonicalize()` also worked, but I used `dunce` since
that's what I saw in other tests (I guess it's not _just_ these tests).

Some related links from uv:
-
1b2f212e8b/crates/uv/tests/it/common/mod.rs (L1161-L1178)
-
1b2f212e8b/crates/uv/tests/it/common/mod.rs (L424-L438)
- https://github.com/astral-sh/uv/pull/14290

Thanks to @zanieb for those!

## Test Plan

I tested the `main` branch on my MacBook and reproduced the test
failure, then confirmed that the tests pass after the change. Now to
make sure it passes on Windows, which caused most of the trouble in the
first PR!
2025-07-17 14:02:17 -04:00
Dylan
ee2759b365 Bump 0.12.4 (#19406) 2025-07-17 12:14:01 -05:00
Aria Desires
35f33d9bf5 [ty] publish settings diagnostics (#19335) 2025-07-17 11:57:00 -04:00
chiri
5d78b3117a [flake8-use-pathlib] Add autofix for PTH109 (#19245)
## Summary

Part of #2331

## Test Plan

`cargo nextest run flake8_use_pathlib`
2025-07-17 10:11:43 -04:00
Dhruv Manilawala
c2a05b4825 [ty] Use bitflags for resolved client capabilities (#19393)
## Summary

This PR updates the `ResolvedClientCapabilities` to be represented as
`bitflags`. This allows us to remove the `Arc` as the type becomes copy.

Additionally, this PR also fixed the goto definition and declaration
code to use the `textDocument.definition.linkSupport` and
`textDocument.declaration.linkSupport` client capability.

This PR also removes the unused client capabilities which are
`code_action_deferred_edit_resolution`, `apply_edit`, and
`document_changes` which are all related to auto-fix ability.
2025-07-17 15:31:47 +05:30
UnboundVariable
fae0b5c89e [ty] Initial implementation of declaration and definition providers. (#19371)
This PR implements "go to definition" and "go to declaration"
functionality for name nodes only. Future PRs will add support for
attributes, module names in import statements, keyword argument names,
etc.

This PR:
* Registers a declaration and definition request handler for the
language server.
* Splits out the `goto_type_definition` into its own module. The `goto`
module contains functionality that is common to `goto_type_definition`,
`goto_declaration` and `goto_definition`.
* Roughs in a new module `stub_mapping` that is not yet implemented. It
will be responsible for mapping a definition in a stub file to its
corresponding definition(s) in an implementation (source) file.
* Adds a new IDE support function `definitions_for_name` that collects
all of the definitions associated with a name and resolves any imports
(recursively) to find the original definitions associated with that
name.
* Adds a new `VisibleAncestorsIter` stuct that iterates up the scope
hierarchy but skips scopes that are not visible to starting scope.

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
2025-07-16 15:07:24 -07:00
Matthew Mckee
cbe94b094b [ty] Support empty function bodies in if TYPE_CHECKING blocks (#19372)
## Summary

Resolves https://github.com/astral-sh/ty/issues/339

Supports having a blank function body inside `if TYPE_CHECKING` block or
in the elif or else of a `if not TYPE_CHECKING` block.

```py
if TYPE_CHECKING:
    def foo() -> int: ...

if not TYPE_CHECKING: ...
else:     
    def bar() -> int: ...
```

## Test Plan

Update `function/return_type.md`

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-07-16 14:48:04 -06:00
Dan Parizher
029de784f1 [flake8-use-pathlib] Fix false negative on direct Path() instantiation (PTH210) (#19388)
## Summary

Fixes #19329

---------

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
2025-07-16 20:43:03 +00:00
frank
ff94fe7447 Treat form feed as valid whitespace before a semicolon (#19343)
fixes #19310
2025-07-16 16:39:05 -04:00
Dan Parizher
b2501b45e0 [pylint] Detect indirect pathlib.Path usages for unspecified-encoding (PLW1514) (#19304)
## Summary

Fixes #19294

---------

Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
2025-07-16 12:57:31 -04:00
Dan Parizher
291699b375 [refurb] FURB164 fix should validate arguments and should usually be marked unsafe (#19136)
## Summary

Fixes #19076

An attempt at fixing #19076 where the rule could change program behavior
by incorrectly converting from_float/from_decimal method calls to
constructor calls.

The fix implements argument validation using Ruff's existing type
inference system (`ResolvedPythonType`, `typing::is_int`,
`typing::is_float`) to determine when conversions are actually safe,
adds logic to detect invalid method calls (wrong argument counts,
incorrect keyword names) and suppress fixes for them, and changes the
default fix applicability from `Safe` to `Unsafe` with safe fixes only
offered when the argument type is known to be compatible and no
problematic keywords are used.

One uncertainty is whether the type inference catches all possible edge
cases in complex codebases, but the new approach is significantly more
conservative and safer than the previous implementation.

## Test Plan

I updated the existing test fixtures with edge cases from the issue and
manually verified behavior with temporary test files for
valid/unsafe/invalid scenarios.

---------

Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
2025-07-16 15:38:33 +00:00
Jack O'Connor
64ac7d7dbf [ty] use the name span rather than the statement span for unresolved global lints (#19379)
This is a follow-up to https://github.com/astral-sh/ruff/pull/19344 that
improves the error formatting slightly. For example with this program:

```py
def f():
    global foo, bar
```

Before we printed:

```
1 | def f():
2 |     global foo, bar
  |     ^^^^^^^^^^^^^^^ `foo` has no declarations or bindings in the global scope
...
1 | def f():
2 |     global foo, bar
  |     ^^^^^^^^^^^^^^^ `bar` has no declarations or bindings in the global scope
```

Now we print:

```
1 | def f():
2 |     global foo, bar
  |            ^^^ `foo` has no declarations or bindings in the global scope
...
1 | def f():
2 |     global foo, bar
  |                 ^^^ `bar` has no declarations or bindings in the global scope
```

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-16 15:34:47 +00:00
Jack O'Connor
5f2e855c29 allow reads of "free" variables to refer to a global declaration
Previously this worked if there was also a binding in the same scope as
the `global` declaration (probably almost always the case), but CPython
doesn't require this.

This change surfaced an error in an existing test, where a global
variable was only ever declared and bound using the `global` keyword,
and never mentioned explicitly in the global scope. @AlexWaygood
suggested we probably want to keep that requirement, so I'm adding an a
new test for that on top of fixing the failing test.
2025-07-16 08:30:42 -07:00
Jack O'Connor
3b4667ec32 respect annotation-only declarations in infer_place_load 2025-07-16 08:30:42 -07:00
Brent Westbrook
893f5727e5 [flake8-type-checking, pyupgrade, ruff] Add from __future__ import annotations when it would allow new fixes (TC001, TC002, TC003, UP037, RUF013) (#19100)
## Summary

This is a second attempt at addressing
https://github.com/astral-sh/ruff/issues/18502 instead of reusing
`FA100` (#18919).

This PR:
- adds a new `lint.allow-importing-future-annotations` option
- uses the option to add a `__future__` import when it would trigger
`TC001`, `TC002`, or `TC003`
- uses the option to add an import when it would allow unquoting more
annotations in [quoted-annotation
(UP037)](https://docs.astral.sh/ruff/rules/quoted-annotation/#quoted-annotation-up037)
- uses the option to allow the `|` union syntax before 3.10 in
[implicit-optional
(RUF013)](https://docs.astral.sh/ruff/rules/implicit-optional/#implicit-optional-ruf013)

I started adding a fix for [runtime-string-union
(TC010)](https://docs.astral.sh/ruff/rules/runtime-string-union/#runtime-string-union-tc010)
too, as mentioned in my previous
[comment](https://github.com/astral-sh/ruff/issues/18502#issuecomment-3005238092),
but some of the existing tests already imported `from __future__ import
annotations`, so I think we intentionally flag these cases for the user
to inspect. Adding the import is _a_ fix but probably not the best one.

## Test Plan

Existing `TC` tests, new copies of them with the option enabled, and new
tests based on ideas in
https://github.com/astral-sh/ruff/pull/18919#discussion_r2166292705 and
the following thread. For UP037 and RUF013, the new tests are also
copies of the existing tests, with the new option enabled. The easiest
way to review them is probably by their diffs from the existing
snapshots:

### UP037

`UP037_0.py` and `UP037_2.pyi` have no diffs. The diff for `UP037_1.py`
is below. It correctly unquotes an annotation in module scope that would
otherwise be invalid.

<details><summary>UP037_1.py</summary>

```diff
3d2
< snapshot_kind: text
23c22,42
< 12 12 |
---
> 12 12 |
>
> UP037_1.py:14:4: UP037 [*] Remove quotes from type annotation
>    |
> 13 | # OK
> 14 | X: "Tuple[int, int]" = (0, 0)
>    |    ^^^^^^^^^^^^^^^^^ UP037
>    |
>    = help: Remove quotes
>
> ℹ Unsafe fix
>    1  |+from __future__ import annotations
> 1  2  | from typing import TYPE_CHECKING
> 2  3  |
> 3  4  | if TYPE_CHECKING:
> --------------------------------------------------------------------------------
> 11 12 |
> 12 13 |
> 13 14 | # OK
> 14    |-X: "Tuple[int, int]" = (0, 0)
>    15 |+X: Tuple[int, int] = (0, 0)
```

</details>

### RUF013

The diffs here are mostly just the imports because the original snaps
were on 3.13. So we're getting the same fixes now on 3.9.

<details><summary>RUF013_0.py</summary>

```diff
3d2
< snapshot_kind: text
14,16c13,20
< 17 17 |     pass
< 18 18 | 
< 19 19 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 17 18 |     pass
> 18 19 | 
> 19 20 | 
18,21c22,25
<    20 |+def f(arg: int | None = None):  # RUF013
< 21 21 |     pass
< 22 22 | 
< 23 23 | 
---
>    21 |+def f(arg: int | None = None):  # RUF013
> 21 22 |     pass
> 22 23 | 
> 23 24 | 
32,34c36,43
< 21 21 |     pass
< 22 22 | 
< 23 23 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 21 22 |     pass
> 22 23 | 
> 23 24 | 
36,39c45,48
<    24 |+def f(arg: str | None = None):  # RUF013
< 25 25 |     pass
< 26 26 | 
< 27 27 | 
---
>    25 |+def f(arg: str | None = None):  # RUF013
> 25 26 |     pass
> 26 27 | 
> 27 28 | 
50,52c59,66
< 25 25 |     pass
< 26 26 | 
< 27 27 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 25 26 |     pass
> 26 27 | 
> 27 28 | 
54,57c68,71
<    28 |+def f(arg: Tuple[str] | None = None):  # RUF013
< 29 29 |     pass
< 30 30 | 
< 31 31 | 
---
>    29 |+def f(arg: Tuple[str] | None = None):  # RUF013
> 29 30 |     pass
> 30 31 | 
> 31 32 | 
68,70c82,89
< 55 55 |     pass
< 56 56 | 
< 57 57 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 55 56 |     pass
> 56 57 | 
> 57 58 | 
72,75c91,94
<    58 |+def f(arg: Union | None = None):  # RUF013
< 59 59 |     pass
< 60 60 | 
< 61 61 | 
---
>    59 |+def f(arg: Union | None = None):  # RUF013
> 59 60 |     pass
> 60 61 | 
> 61 62 | 
86,88c105,112
< 59 59 |     pass
< 60 60 | 
< 61 61 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 59 60 |     pass
> 60 61 | 
> 61 62 | 
90,93c114,117
<    62 |+def f(arg: Union[int] | None = None):  # RUF013
< 63 63 |     pass
< 64 64 | 
< 65 65 | 
---
>    63 |+def f(arg: Union[int] | None = None):  # RUF013
> 63 64 |     pass
> 64 65 | 
> 65 66 | 
104,106c128,135
< 63 63 |     pass
< 64 64 | 
< 65 65 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 63 64 |     pass
> 64 65 | 
> 65 66 | 
108,111c137,140
<    66 |+def f(arg: Union[int, str] | None = None):  # RUF013
< 67 67 |     pass
< 68 68 | 
< 69 69 | 
---
>    67 |+def f(arg: Union[int, str] | None = None):  # RUF013
> 67 68 |     pass
> 68 69 | 
> 69 70 | 
122,124c151,158
< 82 82 |     pass
< 83 83 | 
< 84 84 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 82 83 |     pass
> 83 84 | 
> 84 85 | 
126,129c160,163
<    85 |+def f(arg: int | float | None = None):  # RUF013
< 86 86 |     pass
< 87 87 | 
< 88 88 | 
---
>    86 |+def f(arg: int | float | None = None):  # RUF013
> 86 87 |     pass
> 87 88 | 
> 88 89 | 
140,142c174,181
< 86 86 |     pass
< 87 87 | 
< 88 88 | 
---
>    1  |+from __future__ import annotations
> 1  2  | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 86 87 |     pass
> 87 88 | 
> 88 89 | 
144,147c183,186
<    89 |+def f(arg: int | float | str | bytes | None = None):  # RUF013
< 90 90 |     pass
< 91 91 | 
< 92 92 | 
---
>    90 |+def f(arg: int | float | str | bytes | None = None):  # RUF013
> 90 91 |     pass
> 91 92 | 
> 92 93 | 
158,160c197,204
< 105 105 |     pass
< 106 106 | 
< 107 107 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 105 106 |     pass
> 106 107 | 
> 107 108 | 
162,165c206,209
<     108 |+def f(arg: Literal[1] | None = None):  # RUF013
< 109 109 |     pass
< 110 110 | 
< 111 111 | 
---
>     109 |+def f(arg: Literal[1] | None = None):  # RUF013
> 109 110 |     pass
> 110 111 | 
> 111 112 | 
176,178c220,227
< 109 109 |     pass
< 110 110 | 
< 111 111 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 109 110 |     pass
> 110 111 | 
> 111 112 | 
180,183c229,232
<     112 |+def f(arg: Literal[1, "foo"] | None = None):  # RUF013
< 113 113 |     pass
< 114 114 | 
< 115 115 | 
---
>     113 |+def f(arg: Literal[1, "foo"] | None = None):  # RUF013
> 113 114 |     pass
> 114 115 | 
> 115 116 | 
194,196c243,250
< 128 128 |     pass
< 129 129 | 
< 130 130 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 128 129 |     pass
> 129 130 | 
> 130 131 | 
198,201c252,255
<     131 |+def f(arg: Annotated[int | None, ...] = None):  # RUF013
< 132 132 |     pass
< 133 133 | 
< 134 134 | 
---
>     132 |+def f(arg: Annotated[int | None, ...] = None):  # RUF013
> 132 133 |     pass
> 133 134 | 
> 134 135 | 
212,214c266,273
< 132 132 |     pass
< 133 133 | 
< 134 134 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 132 133 |     pass
> 133 134 | 
> 134 135 | 
216,219c275,278
<     135 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None):  # RUF013
< 136 136 |     pass
< 137 137 | 
< 138 138 | 
---
>     136 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None):  # RUF013
> 136 137 |     pass
> 137 138 | 
> 138 139 | 
232,234c291,298
< 148 148 | 
< 149 149 | 
< 150 150 | def f(
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 148 149 | 
> 149 150 | 
> 150 151 | def f(
236,239c300,303
<     151 |+    arg1: int | None = None,  # RUF013
< 152 152 |     arg2: Union[int, float] = None,  # RUF013
< 153 153 |     arg3: Literal[1, 2, 3] = None,  # RUF013
< 154 154 | ):
---
>     152 |+    arg1: int | None = None,  # RUF013
> 152 153 |     arg2: Union[int, float] = None,  # RUF013
> 153 154 |     arg3: Literal[1, 2, 3] = None,  # RUF013
> 154 155 | ):
253,255c317,324
< 149 149 | 
< 150 150 | def f(
< 151 151 |     arg1: int = None,  # RUF013
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 149 150 | 
> 150 151 | def f(
> 151 152 |     arg1: int = None,  # RUF013
257,260c326,329
<     152 |+    arg2: Union[int, float] | None = None,  # RUF013
< 153 153 |     arg3: Literal[1, 2, 3] = None,  # RUF013
< 154 154 | ):
< 155 155 |     pass
---
>     153 |+    arg2: Union[int, float] | None = None,  # RUF013
> 153 154 |     arg3: Literal[1, 2, 3] = None,  # RUF013
> 154 155 | ):
> 155 156 |     pass
274,276c343,350
< 150 150 | def f(
< 151 151 |     arg1: int = None,  # RUF013
< 152 152 |     arg2: Union[int, float] = None,  # RUF013
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 150 151 | def f(
> 151 152 |     arg1: int = None,  # RUF013
> 152 153 |     arg2: Union[int, float] = None,  # RUF013
278,281c352,355
<     153 |+    arg3: Literal[1, 2, 3] | None = None,  # RUF013
< 154 154 | ):
< 155 155 |     pass
< 156 156 | 
---
>     154 |+    arg3: Literal[1, 2, 3] | None = None,  # RUF013
> 154 155 | ):
> 155 156 |     pass
> 156 157 | 
292,294c366,373
< 178 178 |     pass
< 179 179 | 
< 180 180 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 178 179 |     pass
> 179 180 | 
> 180 181 | 
296,299c375,378
<     181 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None):  # RUF013
< 182 182 |     pass
< 183 183 | 
< 184 184 | 
---
>     182 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None):  # RUF013
> 182 183 |     pass
> 183 184 | 
> 184 185 | 
307c386
<     = help: Convert to `T | None`
---
>     = help: Convert to `Optional[T]`
314c393
<     188 |+def f(arg: "int | None" = None):  # RUF013
---
>     188 |+def f(arg: "Optional[int]" = None):  # RUF013
325c404
<     = help: Convert to `T | None`
---
>     = help: Convert to `Optional[T]`
332c411
<     192 |+def f(arg: "str | None" = None):  # RUF013
---
>     192 |+def f(arg: "Optional[str]" = None):  # RUF013
343c422
<     = help: Convert to `T | None`
---
>     = help: Convert to `Optional[T]`
354,356c433,440
< 201 201 |     pass
< 202 202 | 
< 203 203 | 
---
>     1   |+from __future__ import annotations
> 1   2   | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
> 2   3   | 
> 3   4   | 
> --------------------------------------------------------------------------------
> 201 202 |     pass
> 202 203 | 
> 203 204 | 
358,361c442,445
<     204 |+def f(arg: Union["int", "str"] | None = None):  # RUF013
< 205 205 |     pass
< 206 206 | 
< 207 207 |
---
>     205 |+def f(arg: Union["int", "str"] | None = None):  # RUF013
> 205 206 |     pass
> 206 207 | 
> 207 208 |
```

</details>

<details><summary>RUF013_1.py</summary>

```diff
3d2
< snapshot_kind: text
15,16c14,16
< 2 2 |
< 3 3 |
---
>   2 |+from __future__ import annotations
> 2 3 |
> 3 4 |
18,19c18,19
<   4 |+def f(arg: int | None = None):  # RUF013
< 5 5 |     pass
---
>   5 |+def f(arg: int | None = None):  # RUF013
> 5 6 |     pass
```

</details>

<details><summary>RUF013_3.py</summary>

```diff
3d2
< snapshot_kind: text
14,16c13,16
< 1 1 | import typing
< 2 2 | 
< 3 3 | 
---
>   1 |+from __future__ import annotations
> 1 2 | import typing
> 2 3 | 
> 3 4 | 
18,21c18,21
<   4 |+def f(arg: typing.List[str] | None = None):  # RUF013
< 5 5 |     pass
< 6 6 | 
< 7 7 | 
---
>   5 |+def f(arg: typing.List[str] | None = None):  # RUF013
> 5 6 |     pass
> 6 7 | 
> 7 8 | 
32,34c32,39
< 19 19 |     pass
< 20 20 | 
< 21 21 | 
---
>    1  |+from __future__ import annotations
> 1  2  | import typing
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 19 20 |     pass
> 20 21 | 
> 21 22 | 
36,39c41,44
<    22 |+def f(arg: typing.Union[int, str] | None = None):  # RUF013
< 23 23 |     pass
< 24 24 | 
< 25 25 | 
---
>    23 |+def f(arg: typing.Union[int, str] | None = None):  # RUF013
> 23 24 |     pass
> 24 25 | 
> 25 26 | 
50,52c55,62
< 26 26 | # Literal
< 27 27 | 
< 28 28 | 
---
>    1  |+from __future__ import annotations
> 1  2  | import typing
> 2  3  | 
> 3  4  | 
> --------------------------------------------------------------------------------
> 26 27 | # Literal
> 27 28 | 
> 28 29 | 
54,55c64,65
<    29 |+def f(arg: typing.Literal[1, "foo", True] | None = None):  # RUF013
< 30 30 |     pass
---
>    30 |+def f(arg: typing.Literal[1, "foo", True] | None = None):  # RUF013
> 30 31 |     pass
```

</details>

<details><summary>RUF013_4.py</summary>

```diff
3d2
< snapshot_kind: text
13,15c12,20
< 12 12 | def multiple_1(arg1: Optional, arg2: Optional = None): ...
< 13 13 |
< 14 14 |
---
> 1  1  | # https://github.com/astral-sh/ruff/issues/13833
>    2  |+from __future__ import annotations
> 2  3  |
> 3  4  | from typing import Optional
> 4  5  |
> --------------------------------------------------------------------------------
> 12 13 | def multiple_1(arg1: Optional, arg2: Optional = None): ...
> 13 14 |
> 14 15 |
17,20c22,25
<    15 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ...
< 16 16 |
< 17 17 |
< 18 18 | def return_type(arg: Optional = None) -> Optional: ...
---
>    16 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ...
> 16 17 |
> 17 18 |
> 18 19 | def return_type(arg: Optional = None) -> Optional: ...
```

</details>

## Future work

This PR does not touch UP006, UP007, or UP045, which are currently
coupled to FA100. If this new approach turns out well, we may eventually
want to deprecate FA100 and add a `__future__` import in those rules'
fixes too.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2025-07-16 08:50:52 -04:00
Cornelius Roemer
b8dddd514f chore: Document Material for MkDocs Insiders limitations in CONTRIBUTING.md in more detail (#19373)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-16 08:42:25 +00:00
793 changed files with 11928 additions and 4278 deletions

View File

@@ -238,13 +238,13 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
uses: rui314/setup-mold@702b1908b5edf30d71a8d1666b724e0f0c6fa035 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-insta
- name: ty mdtests (GitHub annotations)
@@ -296,13 +296,13 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
uses: rui314/setup-mold@702b1908b5edf30d71a8d1666b724e0f0c6fa035 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-insta
- name: "Run tests"
@@ -325,7 +325,7 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install cargo nextest"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-nextest
- name: "Run tests"
@@ -381,7 +381,7 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
uses: rui314/setup-mold@702b1908b5edf30d71a8d1666b724e0f0c6fa035 # v1
- name: "Build"
run: cargo build --release --locked
@@ -406,7 +406,7 @@ jobs:
MSRV: ${{ steps.msrv.outputs.value }}
run: rustup default "${MSRV}"
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
uses: rui314/setup-mold@702b1908b5edf30d71a8d1666b724e0f0c6fa035 # v1
- name: "Build tests"
shell: bash
env:
@@ -903,7 +903,7 @@ jobs:
run: rustup show
- name: "Install codspeed"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-codspeed
@@ -936,7 +936,7 @@ jobs:
run: rustup show
- name: "Install codspeed"
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
uses: taiki-e/install-action@c99cc51b309eee71a866715cfa08c922f11cf898 # v2.56.19
with:
tool: cargo-codspeed

View File

@@ -38,7 +38,7 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
uses: rui314/setup-mold@702b1908b5edf30d71a8d1666b724e0f0c6fa035 # v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
- name: Build ruff
# A debug build means the script runs slower once it gets started,

View File

@@ -34,6 +34,10 @@ env:
# and which all three workers push to.
UPSTREAM_BRANCH: typeshedbot/sync-typeshed
# The path to the directory that contains the vendored typeshed stubs,
# relative to the root of the Ruff repository.
VENDORED_TYPESHED: crates/ty_vendored/vendor/typeshed
jobs:
# Sync typeshed stubs, and sync all docstrings available on Linux.
# Push the changes to a new branch on the upstream repository.
@@ -64,20 +68,20 @@ jobs:
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- name: Sync typeshed stubs
run: |
rm -rf ruff/crates/ty_vendored/vendor/typeshed
mkdir ruff/crates/ty_vendored/vendor/typeshed
cp typeshed/README.md ruff/crates/ty_vendored/vendor/typeshed
cp typeshed/LICENSE ruff/crates/ty_vendored/vendor/typeshed
rm -rf "ruff/${VENDORED_TYPESHED}"
mkdir "ruff/${VENDORED_TYPESHED}"
cp typeshed/README.md "ruff/${VENDORED_TYPESHED}"
cp typeshed/LICENSE "ruff/${VENDORED_TYPESHED}"
# The pyproject.toml file is needed by a later job for the black configuration.
# It's deleted before creating the PR.
cp typeshed/pyproject.toml ruff/crates/ty_vendored/vendor/typeshed
cp typeshed/pyproject.toml "ruff/${VENDORED_TYPESHED}"
cp -r typeshed/stdlib ruff/crates/ty_vendored/vendor/typeshed/stdlib
rm -rf ruff/crates/ty_vendored/vendor/typeshed/stdlib/@tests
git -C typeshed rev-parse HEAD > ruff/crates/ty_vendored/vendor/typeshed/source_commit.txt
cp -r typeshed/stdlib "ruff/${VENDORED_TYPESHED}/stdlib"
rm -rf "ruff/${VENDORED_TYPESHED}/stdlib/@tests"
git -C typeshed rev-parse HEAD > "ruff/${VENDORED_TYPESHED}/source_commit.txt"
cd ruff
git checkout -b typeshedbot/sync-typeshed
git checkout -b "${UPSTREAM_BRANCH}"
git add .
git commit -m "Sync typeshed. Source commit: https://github.com/python/typeshed/commit/$(git -C ../typeshed rev-parse HEAD)" --allow-empty
- name: Sync Linux docstrings
@@ -167,17 +171,17 @@ jobs:
# consistent with the other typeshed stubs around them.
# Typeshed formats code using black in their CI, so we just invoke
# black on the stubs the same way that typeshed does.
uvx black crates/ty_vendored/vendor/typeshed/stdlib --config crates/ty_vendored/vendor/typeshed/pyproject.toml || true
uvx black "${VENDORED_TYPESHED}/stdlib" --config "${VENDORED_TYPESHED}/pyproject.toml" || true
git commit -am "Format codemodded docstrings" --allow-empty
rm crates/ty_vendored/vendor/typeshed/pyproject.toml
rm "${VENDORED_TYPESHED}/pyproject.toml"
git commit -am "Remove pyproject.toml file"
git push
- name: Create a PR
if: ${{ success() }}
run: |
gh pr list --repo "$GITHUB_REPOSITORY" --head typeshedbot/sync-typeshed --json id --jq length | grep 1 && exit 0 # exit if there is existing pr
gh pr list --repo "${GITHUB_REPOSITORY}" --head "${UPSTREAM_BRANCH}" --json id --jq length | grep 1 && exit 0 # exit if there is existing pr
gh pr create --title "[ty] Sync vendored typeshed stubs" --body "Close and reopen this PR to trigger CI" --label "ty"
create-issue-on-failure:

View File

@@ -81,7 +81,7 @@ repos:
pass_filenames: false # This makes it a lot faster
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.3
rev: v0.12.4
hooks:
- id: ruff-format
- id: ruff

View File

@@ -1,5 +1,36 @@
# Changelog
## 0.12.4
### Preview features
- \[`flake8-type-checking`, `pyupgrade`, `ruff`\] Add `from __future__ import annotations` when it would allow new fixes (`TC001`, `TC002`, `TC003`, `UP037`, `RUF013`) ([#19100](https://github.com/astral-sh/ruff/pull/19100))
- \[`flake8-use-pathlib`\] Add autofix for `PTH109` ([#19245](https://github.com/astral-sh/ruff/pull/19245))
- \[`pylint`\] Detect indirect `pathlib.Path` usages for `unspecified-encoding` (`PLW1514`) ([#19304](https://github.com/astral-sh/ruff/pull/19304))
### Bug fixes
- \[`flake8-bugbear`\] Fix `B017` false negatives for keyword exception arguments ([#19217](https://github.com/astral-sh/ruff/pull/19217))
- \[`flake8-use-pathlib`\] Fix false negative on direct `Path()` instantiation (`PTH210`) ([#19388](https://github.com/astral-sh/ruff/pull/19388))
- \[`flake8-django`\] Fix `DJ008` false positive for abstract models with type-annotated `abstract` field ([#19221](https://github.com/astral-sh/ruff/pull/19221))
- \[`isort`\] Fix `I002` import insertion after docstring with multiple string statements ([#19222](https://github.com/astral-sh/ruff/pull/19222))
- \[`isort`\] Treat form feed as valid whitespace before a semicolon ([#19343](https://github.com/astral-sh/ruff/pull/19343))
- \[`pydoclint`\] Fix `SyntaxError` from fixes with line continuations (`D201`, `D202`) ([#19246](https://github.com/astral-sh/ruff/pull/19246))
- \[`refurb`\] `FURB164` fix should validate arguments and should usually be marked unsafe ([#19136](https://github.com/astral-sh/ruff/pull/19136))
### Rule changes
- \[`flake8-use-pathlib`\] Skip single dots for `invalid-pathlib-with-suffix` (`PTH210`) on versions >= 3.14 ([#19331](https://github.com/astral-sh/ruff/pull/19331))
- \[`pep8_naming`\] Avoid false positives on standard library functions with uppercase names (`N802`) ([#18907](https://github.com/astral-sh/ruff/pull/18907))
- \[`pycodestyle`\] Handle brace escapes for t-strings in logical lines ([#19358](https://github.com/astral-sh/ruff/pull/19358))
- \[`pylint`\] Extend invalid string character rules to include t-strings ([#19355](https://github.com/astral-sh/ruff/pull/19355))
- \[`ruff`\] Allow `strict` kwarg when checking for `starmap-zip` (`RUF058`) in Python 3.14+ ([#19333](https://github.com/astral-sh/ruff/pull/19333))
### Documentation
- \[`flake8-type-checking`\] Make `TC010` docs example more realistic ([#19356](https://github.com/astral-sh/ruff/pull/19356))
- Make more documentation examples error out-of-the-box ([#19288](https://github.com/astral-sh/ruff/pull/19288),[#19272](https://github.com/astral-sh/ruff/pull/19272),[#19291](https://github.com/astral-sh/ruff/pull/19291),[#19296](https://github.com/astral-sh/ruff/pull/19296),[#19292](https://github.com/astral-sh/ruff/pull/19292),[#19295](https://github.com/astral-sh/ruff/pull/19295),[#19297](https://github.com/astral-sh/ruff/pull/19297),[#19309](https://github.com/astral-sh/ruff/pull/19309))
## 0.12.3
### Preview features

View File

@@ -266,6 +266,13 @@ Finally, regenerate the documentation and generated code with `cargo dev generat
## MkDocs
> [!NOTE]
>
> The documentation uses Material for MkDocs Insiders, which is closed-source software.
> This means only members of the Astral organization can preview the documentation exactly as it
> will appear in production.
> Outside contributors can still preview the documentation, but there will be some differences. Consult [the Material for MkDocs documentation](https://squidfunk.github.io/mkdocs-material/insiders/benefits/#features) for which features are exclusively available in the insiders version.
To preview any changes to the documentation locally:
1. Install the [Rust toolchain](https://www.rust-lang.org/tools/install).

67
Cargo.lock generated
View File

@@ -1557,6 +1557,15 @@ dependencies = [
"memoffset",
]
[[package]]
name = "inventory"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83"
dependencies = [
"rustversion",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -2123,16 +2132,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "papaya"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f"
dependencies = [
"equivalent",
"seize",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -2562,9 +2561,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -2711,7 +2710,7 @@ dependencies = [
[[package]]
name = "ruff"
version = "0.12.3"
version = "0.12.4"
dependencies = [
"anyhow",
"argfile",
@@ -2839,6 +2838,7 @@ dependencies = [
"insta",
"matchit",
"path-slash",
"quick-junit",
"ruff_annotate_snippets",
"ruff_cache",
"ruff_diagnostics",
@@ -2962,7 +2962,7 @@ dependencies = [
[[package]]
name = "ruff_linter"
version = "0.12.3"
version = "0.12.4"
dependencies = [
"aho-corasick",
"anyhow",
@@ -2987,7 +2987,6 @@ dependencies = [
"pathdiff",
"pep440_rs",
"pyproject-toml",
"quick-junit",
"regex",
"ruff_annotate_snippets",
"ruff_cache",
@@ -3042,7 +3041,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
"rand 0.9.1",
"rand 0.9.2",
"ruff_diagnostics",
"ruff_source_file",
"ruff_text_size",
@@ -3295,7 +3294,7 @@ dependencies = [
[[package]]
name = "ruff_wasm"
version = "0.12.3"
version = "0.12.4"
dependencies = [
"console_error_panic_hook",
"console_log",
@@ -3409,7 +3408,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "salsa"
version = "0.23.0"
source = "git+https://github.com/salsa-rs/salsa?rev=fc00eba89e5dcaa5edba51c41aa5f309b5cb126b#fc00eba89e5dcaa5edba51c41aa5f309b5cb126b"
source = "git+https://github.com/salsa-rs/salsa?rev=dba66f1a37acca014c2402f231ed5b361bd7d8fe#dba66f1a37acca014c2402f231ed5b361bd7d8fe"
dependencies = [
"boxcar",
"compact_str",
@@ -3419,7 +3418,7 @@ dependencies = [
"hashlink",
"indexmap",
"intrusive-collections",
"papaya",
"inventory",
"parking_lot",
"portable-atomic",
"rayon",
@@ -3434,12 +3433,12 @@ dependencies = [
[[package]]
name = "salsa-macro-rules"
version = "0.23.0"
source = "git+https://github.com/salsa-rs/salsa?rev=fc00eba89e5dcaa5edba51c41aa5f309b5cb126b#fc00eba89e5dcaa5edba51c41aa5f309b5cb126b"
source = "git+https://github.com/salsa-rs/salsa?rev=dba66f1a37acca014c2402f231ed5b361bd7d8fe#dba66f1a37acca014c2402f231ed5b361bd7d8fe"
[[package]]
name = "salsa-macros"
version = "0.23.0"
source = "git+https://github.com/salsa-rs/salsa?rev=fc00eba89e5dcaa5edba51c41aa5f309b5cb126b#fc00eba89e5dcaa5edba51c41aa5f309b5cb126b"
source = "git+https://github.com/salsa-rs/salsa?rev=dba66f1a37acca014c2402f231ed5b361bd7d8fe#dba66f1a37acca014c2402f231ed5b361bd7d8fe"
dependencies = [
"proc-macro2",
"quote",
@@ -3492,16 +3491,6 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "seize"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4b8d813387d566f627f3ea1b914c068aac94c40ae27ec43f5f33bde65abefe7"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "serde"
version = "1.0.219"
@@ -3546,9 +3535,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.141"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
dependencies = [
"itoa",
"memchr",
@@ -3738,23 +3727,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.1"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.1"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
@@ -4300,6 +4288,7 @@ name = "ty_server"
version = "0.0.0"
dependencies = [
"anyhow",
"bitflags 2.9.1",
"crossbeam",
"jod-thread",
"libc",
@@ -4574,7 +4563,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"rand 0.9.1",
"rand 0.9.2",
"uuid-macro-internal",
"wasm-bindgen",
]

View File

@@ -138,7 +138,7 @@ regex-automata = { version = "0.4.9" }
rustc-hash = { version = "2.0.0" }
rustc-stable-hash = { version = "0.1.2" }
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
salsa = { git = "https://github.com/salsa-rs/salsa", rev = "fc00eba89e5dcaa5edba51c41aa5f309b5cb126b" }
salsa = { git = "https://github.com/salsa-rs/salsa", rev = "dba66f1a37acca014c2402f231ed5b361bd7d8fe" }
schemars = { version = "0.8.16" }
seahash = { version = "4.1.0" }
serde = { version = "1.0.197", features = ["derive"] }
@@ -150,7 +150,7 @@ serde_with = { version = "3.6.0", default-features = false, features = [
] }
shellexpand = { version = "3.0.0" }
similar = { version = "2.4.0", features = ["inline"] }
smallvec = { version = "1.13.2" }
smallvec = { version = "1.13.2", features = ["union", "const_generics", "const_new"] }
snapbox = { version = "0.6.0", features = [
"diff",
"term-svg",

View File

@@ -148,8 +148,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh
powershell -c "irm https://astral.sh/ruff/install.ps1 | iex"
# For a specific version.
curl -LsSf https://astral.sh/ruff/0.12.3/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.12.3/install.ps1 | iex"
curl -LsSf https://astral.sh/ruff/0.12.4/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.12.4/install.ps1 | iex"
```
You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff),
@@ -182,7 +182,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff
```yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.3
rev: v0.12.4
hooks:
# Run the linter.
- id: ruff-check

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff"
version = "0.12.3"
version = "0.12.4"
publish = true
authors = { workspace = true }
edition = { workspace = true }

View File

@@ -18,14 +18,12 @@ use rustc_hash::FxHashMap;
use tempfile::NamedTempFile;
use ruff_cache::{CacheKey, CacheKeyHasher};
use ruff_db::diagnostic::Diagnostic;
use ruff_db::diagnostic::{Diagnostic, SerializableDiagnostics};
use ruff_diagnostics::Fix;
use ruff_linter::message::create_lint_diagnostic;
use ruff_linter::package::PackageRoot;
use ruff_linter::{VERSION, warn_user};
use ruff_macros::CacheKey;
use ruff_notebook::NotebookIndex;
use ruff_source_file::SourceFileBuilder;
use ruff_text_size::{TextRange, TextSize};
use ruff_workspace::Settings;
use ruff_workspace::resolver::Resolver;
@@ -345,22 +343,7 @@ impl FileCache {
let diagnostics = if lint.messages.is_empty() {
Vec::new()
} else {
let file = SourceFileBuilder::new(path.to_string_lossy(), &*lint.source).finish();
lint.messages
.iter()
.map(|msg| {
create_lint_diagnostic(
&msg.body,
msg.suggestion.as_ref(),
msg.range,
msg.fix.clone(),
msg.parent,
file.clone(),
msg.noqa_offset,
msg.rule,
)
})
.collect()
lint.messages.to_diagnostics()
};
let notebook_indexes = if let Some(notebook_index) = lint.notebook_index.as_ref() {
FxHashMap::from_iter([(path.to_string_lossy().to_string(), notebook_index.clone())])
@@ -415,7 +398,8 @@ pub(crate) struct LintCacheData {
/// Imports made.
// pub(super) imports: ImportMap,
/// Diagnostic messages.
pub(super) messages: Vec<CacheMessage>,
#[bincode(with_serde)]
pub(super) messages: SerializableDiagnostics,
/// Source code of the file.
///
/// # Notes
@@ -438,30 +422,7 @@ impl LintCacheData {
String::new() // No messages, no need to keep the source!
};
let messages = diagnostics
.iter()
// Parse the kebab-case rule name into a `Rule`. This will fail for syntax errors, so
// this also serves to filter them out, but we shouldn't be caching files with syntax
// errors anyway.
.filter_map(|msg| Some((msg.name().parse().ok()?, msg)))
.map(|(rule, msg)| {
// Make sure that all message use the same source file.
assert_eq!(
msg.expect_ruff_source_file(),
diagnostics.first().unwrap().expect_ruff_source_file(),
"message uses a different source file"
);
CacheMessage {
rule,
body: msg.body().to_string(),
suggestion: msg.suggestion().map(ToString::to_string),
range: msg.expect_range(),
parent: msg.parent(),
fix: msg.fix().cloned(),
noqa_offset: msg.noqa_offset(),
}
})
.collect();
let messages = SerializableDiagnostics::new(diagnostics);
Self {
messages,

View File

@@ -15,8 +15,8 @@ use ruff_db::diagnostic::{
use ruff_linter::fs::relativize_path;
use ruff_linter::logging::LogLevel;
use ruff_linter::message::{
Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, JunitEmitter,
SarifEmitter, TextEmitter,
Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, SarifEmitter,
TextEmitter,
};
use ruff_linter::notify_user;
use ruff_linter::settings::flags::{self};
@@ -252,7 +252,11 @@ impl Printer {
write!(writer, "{value}")?;
}
OutputFormat::Junit => {
JunitEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Junit)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::Concise | OutputFormat::Full => {
TextEmitter::default()

View File

@@ -993,6 +993,7 @@ fn value_given_to_table_key_is_not_inline_table_2() {
- `lint.exclude`
- `lint.preview`
- `lint.typing-extensions`
- `lint.future-annotations`
For more information, try '--help'.
");
@@ -5717,8 +5718,11 @@ match 42: # invalid-syntax
let snapshot = format!("output_format_{output_format}");
let project_dir = dunce::canonicalize(tempdir.path())?;
insta::with_settings!({
filters => vec![
(tempdir_filter(&project_dir).as_str(), "[TMP]/"),
(tempdir_filter(&tempdir).as_str(), "[TMP]/"),
(r#""[^"]+\\?/?input.py"#, r#""[TMP]/input.py"#),
(ruff_linter::VERSION, "[VERSION]"),
@@ -5744,3 +5748,25 @@ match 42: # invalid-syntax
Ok(())
}
#[test]
fn future_annotations_preview_warning() {
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "lint.future-annotations = true"])
.args(["--select", "F"])
.arg("--no-preview")
.arg("-")
.pass_stdin("1"),
@r"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: The `lint.future-annotations` setting will have no effect because `preview` is disabled
",
);
}

View File

@@ -25,7 +25,7 @@ exit_code: 1
<testcase name="org.ruff.F821" classname="[TMP]/input" line="2" column="5">
<failure message="Undefined name `y`">line 2, col 5, Undefined name `y`</failure>
</testcase>
<testcase name="org.ruff" classname="[TMP]/input" line="3" column="1">
<testcase name="org.ruff.invalid-syntax" classname="[TMP]/input" line="3" column="1">
<failure message="SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)">line 3, col 1, SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)</failure>
</testcase>
</testsuite>

View File

@@ -18,7 +18,7 @@ use ruff_python_ast::PythonVersion;
use ty_project::metadata::options::{EnvironmentOptions, Options};
use ty_project::metadata::value::{RangedValue, RelativePathBuf};
use ty_project::watch::{ChangeEvent, ChangedKind};
use ty_project::{Db, ProjectDatabase, ProjectMetadata};
use ty_project::{CheckMode, Db, ProjectDatabase, ProjectMetadata};
struct Case {
db: ProjectDatabase,
@@ -102,6 +102,7 @@ fn setup_tomllib_case() -> Case {
let re = re.unwrap();
db.set_check_mode(CheckMode::OpenFiles);
db.project().set_open_files(&mut db, tomllib_files);
let re_path = re.path(&db).as_system_path().unwrap().to_owned();
@@ -237,6 +238,7 @@ fn setup_micro_case(code: &str) -> Case {
let mut db = ProjectDatabase::new(metadata, system).unwrap();
let file = system_path_to_file(&db, SystemPathBuf::from(file_path)).unwrap();
db.set_check_mode(CheckMode::OpenFiles);
db.project()
.set_open_files(&mut db, FxHashSet::from_iter([file]));
@@ -525,14 +527,21 @@ impl<'a> ProjectBenchmark<'a> {
#[track_caller]
fn bench_project(benchmark: &ProjectBenchmark, criterion: &mut Criterion) {
fn check_project(db: &mut ProjectDatabase, max_diagnostics: usize) {
fn check_project(db: &mut ProjectDatabase, project_name: &str, max_diagnostics: usize) {
let result = db.check();
let diagnostics = result.len();
assert!(
diagnostics <= max_diagnostics,
"Expected <={max_diagnostics} diagnostics but got {diagnostics}"
);
if diagnostics > max_diagnostics {
let details = result
.into_iter()
.map(|diagnostic| diagnostic.concise_message().to_string())
.collect::<Vec<_>>()
.join("\n ");
assert!(
diagnostics <= max_diagnostics,
"{project_name}: Expected <={max_diagnostics} diagnostics but got {diagnostics}:\n {details}",
);
}
}
setup_rayon();
@@ -542,7 +551,7 @@ fn bench_project(benchmark: &ProjectBenchmark, criterion: &mut Criterion) {
group.bench_function(benchmark.project.config.name, |b| {
b.iter_batched_ref(
|| benchmark.setup_iteration(),
|db| check_project(db, benchmark.max_diagnostics),
|db| check_project(db, benchmark.project.config.name, benchmark.max_diagnostics),
BatchSize::SmallInput,
);
});
@@ -610,7 +619,7 @@ fn datetype(criterion: &mut Criterion) {
max_dep_date: "2025-07-04",
python_version: PythonVersion::PY313,
},
0,
2,
);
bench_project(&benchmark, criterion);

View File

@@ -34,6 +34,7 @@ glob = { workspace = true }
ignore = { workspace = true, optional = true }
matchit = { workspace = true }
path-slash = { workspace = true }
quick-junit = { workspace = true, optional = true }
rustc-hash = { workspace = true }
salsa = { workspace = true }
schemars = { workspace = true, optional = true }
@@ -56,6 +57,7 @@ tempfile = { workspace = true }
[features]
cache = ["ruff_cache"]
junit = ["dep:quick-junit"]
os = ["ignore", "dep:etcetera"]
serde = ["camino/serde1", "dep:serde", "dep:serde_json", "ruff_diagnostics/serde"]
# Exposes testing utilities.

View File

@@ -9,6 +9,9 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
pub use self::render::{DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input};
use crate::{Db, files::File};
#[cfg(feature = "serde")]
pub use serde_diagnostics::SerializableDiagnostics;
mod render;
mod stylesheet;
@@ -790,6 +793,7 @@ impl Annotation {
/// These tags are used to provide additional information about the annotation.
/// and are passed through to the language server protocol.
#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DiagnosticTag {
/// Unused or unnecessary code. Used for unused parameters, unreachable code, etc.
Unnecessary,
@@ -804,6 +808,7 @@ pub enum DiagnosticTag {
///
/// Rules use kebab case, e.g. `no-foo`.
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct LintName(&'static str);
impl LintName {
@@ -844,6 +849,7 @@ impl PartialEq<&str> for LintName {
/// Uniquely identifies the kind of a diagnostic.
#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DiagnosticId {
Panic,
@@ -1141,6 +1147,7 @@ impl From<crate::files::FileRange> for Span {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Severity {
Info,
Warning,
@@ -1282,6 +1289,9 @@ pub enum DiagnosticFormat {
Rdjson,
/// Print diagnostics in the format emitted by Pylint.
Pylint,
/// Print diagnostics in the format expected by JUnit.
#[cfg(feature = "junit")]
Junit,
}
/// A representation of the kinds of messages inside a diagnostic.
@@ -1341,6 +1351,7 @@ impl std::fmt::Display for ConciseMessage<'_> {
/// a blanket trait implementation for `IntoDiagnosticMessage` for
/// anything that implements `std::fmt::Display`.
#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DiagnosticMessage(Box<str>);
impl DiagnosticMessage {
@@ -1404,7 +1415,11 @@ impl<T: std::fmt::Display> IntoDiagnosticMessage for T {
///
/// For Ruff rules this means the noqa code.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize), serde(transparent))]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(transparent)
)]
pub struct SecondaryCode(String);
impl SecondaryCode {
@@ -1449,3 +1464,205 @@ impl From<&SecondaryCode> for SecondaryCode {
value.clone()
}
}
#[cfg(feature = "serde")]
mod serde_diagnostics {
use std::sync::Arc;
use ruff_diagnostics::Fix;
use ruff_source_file::{SourceFile, SourceFileBuilder};
use ruff_text_size::{TextRange, TextSize};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use super::{
Annotation, Diagnostic, DiagnosticId, DiagnosticInner, DiagnosticMessage, DiagnosticTag,
LintName, SecondaryCode, Severity, Span, SubDiagnostic, SubDiagnosticInner, UnifiedFile,
};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct SerializableDiagnostics {
source_files: FxHashMap<String, String>,
diagnostics: Vec<SerializableDiagnostic>,
}
impl SerializableDiagnostics {
pub fn new(diagnostics: &[Diagnostic]) -> Self {
let mut source_files = FxHashMap::default();
let mut serializable_diagnostics = Vec::with_capacity(diagnostics.len());
for diagnostic in diagnostics {
let mut subs = Vec::with_capacity(diagnostic.inner.subs.len());
for sub in &diagnostic.inner.subs {
subs.push(SerializableSubDiagnostic {
severity: sub.inner.severity,
message: sub.inner.message.clone(),
annotations: serializable_annotations(
&mut source_files,
&sub.inner.annotations,
),
});
}
serializable_diagnostics.push(SerializableDiagnostic {
id: diagnostic.inner.id,
severity: diagnostic.inner.severity,
message: diagnostic.inner.message.clone(),
annotations: serializable_annotations(
&mut source_files,
&diagnostic.inner.annotations,
),
subs,
fix: diagnostic.inner.fix.clone(),
parent: diagnostic.inner.parent,
noqa_offset: diagnostic.inner.noqa_offset,
secondary_code: diagnostic.inner.secondary_code.clone(),
});
}
Self {
source_files,
diagnostics: serializable_diagnostics,
}
}
pub fn is_empty(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn to_diagnostics(&self) -> Vec<Diagnostic> {
let source_files: FxHashMap<&str, SourceFile> = self
.source_files
.iter()
.map(|(name, contents)| {
(
name.as_str(),
SourceFileBuilder::new(name.clone(), contents.clone()).finish(),
)
})
.collect();
self.diagnostics
.iter()
.map(|diag| Diagnostic {
inner: Arc::new(DiagnosticInner {
id: diag.id,
severity: diag.severity,
message: diag.message.clone(),
annotations: annotations(&source_files, &diag.annotations),
subs: subdiagnostics(&source_files, &diag.subs),
fix: diag.fix.clone(),
parent: diag.parent,
noqa_offset: diag.noqa_offset,
secondary_code: diag.secondary_code.clone(),
}),
})
.collect()
}
}
fn serializable_annotations(
source_files: &mut FxHashMap<String, String>,
annotations: &[Annotation],
) -> Vec<SerializableAnnotation> {
let mut serializable_annotations = Vec::with_capacity(annotations.len());
for annotation in annotations {
let file = annotation.span.expect_ruff_file();
source_files.insert(file.name().to_string(), file.source_text().to_string());
serializable_annotations.push(SerializableAnnotation {
span: SerializableSpan {
name: file.name().to_string(),
range: annotation.span.range,
},
message: annotation.message.clone(),
is_primary: annotation.is_primary,
tags: annotation.tags.clone(),
});
}
serializable_annotations
}
fn annotations(
source_files: &FxHashMap<&str, SourceFile>,
serializable_annotations: &[SerializableAnnotation],
) -> Vec<Annotation> {
serializable_annotations
.iter()
.map(|ann| {
let span = Span {
file: UnifiedFile::Ruff(
source_files
.get(ann.span.name.as_str())
.expect("Expected source file in cache")
.clone(),
),
range: ann.span.range,
};
Annotation {
span,
message: ann.message.clone(),
is_primary: ann.is_primary,
tags: ann.tags.clone(),
}
})
.collect()
}
fn subdiagnostics(
source_files: &FxHashMap<&str, SourceFile>,
serializable_annotations: &[SerializableSubDiagnostic],
) -> Vec<SubDiagnostic> {
serializable_annotations
.iter()
.map(|sub| SubDiagnostic {
inner: Box::new(SubDiagnosticInner {
severity: sub.severity,
message: sub.message.clone(),
annotations: annotations(source_files, &sub.annotations),
}),
})
.collect()
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableDiagnostic {
id: DiagnosticId,
severity: Severity,
message: DiagnosticMessage,
annotations: Vec<SerializableAnnotation>,
subs: Vec<SerializableSubDiagnostic>,
fix: Option<Fix>,
parent: Option<TextSize>,
noqa_offset: Option<TextSize>,
secondary_code: Option<SecondaryCode>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableAnnotation {
span: SerializableSpan,
message: Option<DiagnosticMessage>,
is_primary: bool,
tags: Vec<DiagnosticTag>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableSubDiagnostic {
severity: Severity,
message: DiagnosticMessage,
annotations: Vec<SerializableAnnotation>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableSpan {
name: String,
range: Option<TextRange>,
}
impl<'de> Deserialize<'de> for LintName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?.into_boxed_str();
Ok(LintName::of(Box::leak(s)))
}
}
}

View File

@@ -30,6 +30,8 @@ mod azure;
mod json;
#[cfg(feature = "serde")]
mod json_lines;
#[cfg(feature = "junit")]
mod junit;
mod pylint;
#[cfg(feature = "serde")]
mod rdjson;
@@ -156,7 +158,8 @@ impl std::fmt::Display for DisplayDiagnostics<'_> {
AnnotateRenderer::styled()
} else {
AnnotateRenderer::plain()
};
}
.cut_indicator("");
renderer = renderer
.error(stylesheet.error)
@@ -196,6 +199,10 @@ impl std::fmt::Display for DisplayDiagnostics<'_> {
DiagnosticFormat::Pylint => {
PylintRenderer::new(self.resolver).render(f, self.diagnostics)?;
}
#[cfg(feature = "junit")]
DiagnosticFormat::Junit => {
junit::JunitRenderer::new(self.resolver).render(f, self.diagnostics)?;
}
}
Ok(())

View File

@@ -0,0 +1,195 @@
use std::{collections::BTreeMap, ops::Deref, path::Path};
use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite, XmlString};
use ruff_source_file::LineColumn;
use crate::diagnostic::{Diagnostic, SecondaryCode, render::FileResolver};
/// A renderer for diagnostics in the [JUnit] format.
///
/// See [`junit.xsd`] for the specification in the JUnit repository and an annotated [version]
/// linked from the [`quick_junit`] docs.
///
/// [JUnit]: https://junit.org/
/// [`junit.xsd`]: https://github.com/junit-team/junit-framework/blob/2870b7d8fd5bf7c1efe489d3991d3ed3900e82bb/platform-tests/src/test/resources/jenkins-junit.xsd
/// [version]: https://llg.cubic.org/docs/junit/
/// [`quick_junit`]: https://docs.rs/quick-junit/latest/quick_junit/
pub struct JunitRenderer<'a> {
resolver: &'a dyn FileResolver,
}
impl<'a> JunitRenderer<'a> {
pub fn new(resolver: &'a dyn FileResolver) -> Self {
Self { resolver }
}
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
let mut report = Report::new("ruff");
if diagnostics.is_empty() {
let mut test_suite = TestSuite::new("ruff");
test_suite
.extra
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
let mut case = TestCase::new("No errors found", TestCaseStatus::success());
case.set_classname("ruff");
test_suite.add_test_case(case);
report.add_test_suite(test_suite);
} else {
for (filename, diagnostics) in group_diagnostics_by_filename(diagnostics, self.resolver)
{
let mut test_suite = TestSuite::new(filename);
test_suite
.extra
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
let classname = Path::new(filename).with_extension("");
for diagnostic in diagnostics {
let DiagnosticWithLocation {
diagnostic,
start_location: location,
} = diagnostic;
let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
status.set_message(diagnostic.body());
if let Some(location) = location {
status.set_description(format!(
"line {row}, col {col}, {body}",
row = location.line,
col = location.column,
body = diagnostic.body()
));
} else {
status.set_description(diagnostic.body());
}
let code = diagnostic
.secondary_code()
.map_or_else(|| diagnostic.name(), SecondaryCode::as_str);
let mut case = TestCase::new(format!("org.ruff.{code}"), status);
case.set_classname(classname.to_str().unwrap());
if let Some(location) = location {
case.extra.insert(
XmlString::new("line"),
XmlString::new(location.line.to_string()),
);
case.extra.insert(
XmlString::new("column"),
XmlString::new(location.column.to_string()),
);
}
test_suite.add_test_case(case);
}
report.add_test_suite(test_suite);
}
}
let adapter = FmtAdapter { fmt: f };
report.serialize(adapter).map_err(|_| std::fmt::Error)
}
}
// TODO(brent) this and `group_diagnostics_by_filename` are also used by the `grouped` output
// format. I think they'd make more sense in that file, but I started here first. I'll move them to
// that module when adding the `grouped` output format.
struct DiagnosticWithLocation<'a> {
diagnostic: &'a Diagnostic,
start_location: Option<LineColumn>,
}
impl Deref for DiagnosticWithLocation<'_> {
type Target = Diagnostic;
fn deref(&self) -> &Self::Target {
self.diagnostic
}
}
fn group_diagnostics_by_filename<'a>(
diagnostics: &'a [Diagnostic],
resolver: &'a dyn FileResolver,
) -> BTreeMap<&'a str, Vec<DiagnosticWithLocation<'a>>> {
let mut grouped_diagnostics = BTreeMap::default();
for diagnostic in diagnostics {
let (filename, start_location) = diagnostic
.primary_span_ref()
.map(|span| {
let file = span.file();
let start_location =
span.range()
.filter(|_| !resolver.is_notebook(file))
.map(|range| {
file.diagnostic_source(resolver)
.as_source_code()
.line_column(range.start())
});
(span.file().path(resolver), start_location)
})
.unwrap_or_default();
grouped_diagnostics
.entry(filename)
.or_insert_with(Vec::new)
.push(DiagnosticWithLocation {
diagnostic,
start_location,
});
}
grouped_diagnostics
}
struct FmtAdapter<'a> {
fmt: &'a mut dyn std::fmt::Write,
}
impl std::io::Write for FmtAdapter<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.fmt
.write_str(std::str::from_utf8(buf).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid UTF-8 in JUnit report",
)
})?)
.map_err(std::io::Error::other)?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
self.fmt.write_fmt(args).map_err(std::io::Error::other)
}
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{create_diagnostics, create_syntax_error_diagnostics},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Junit);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Junit);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/junit.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/junit.rs
expression: env.render_diagnostics(&diagnostics)
---
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="ruff" tests="3" failures="3" errors="0">

View File

@@ -1,15 +1,14 @@
---
source: crates/ruff_linter/src/message/junit.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/junit.rs
expression: env.render_diagnostics(&diagnostics)
---
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="ruff" tests="2" failures="2" errors="0">
<testsuite name="syntax_errors.py" tests="2" disabled="0" errors="0" failures="2" package="org.ruff">
<testcase name="org.ruff" classname="syntax_errors" line="1" column="15">
<testcase name="org.ruff.invalid-syntax" classname="syntax_errors" line="1" column="15">
<failure message="SyntaxError: Expected one or more symbol names after import">line 1, col 15, SyntaxError: Expected one or more symbol names after import</failure>
</testcase>
<testcase name="org.ruff" classname="syntax_errors" line="3" column="12">
<testcase name="org.ruff.invalid-syntax" classname="syntax_errors" line="3" column="12">
<failure message="SyntaxError: Expected &apos;)&apos;, found newline">line 3, col 12, SyntaxError: Expected &apos;)&apos;, found newline</failure>
</testcase>
</testsuite>

View File

@@ -232,7 +232,7 @@ impl Files {
let roots = inner.roots.read().unwrap();
for root in roots.all() {
if root.path(db).starts_with(&path) {
if path.starts_with(root.path(db)) {
root.set_revision(db).to(FileRevision::now());
}
}
@@ -375,12 +375,25 @@ impl File {
}
/// Refreshes the file metadata by querying the file system if needed.
///
/// This also "touches" the file root associated with the given path.
/// This means that any Salsa queries that depend on the corresponding
/// root's revision will become invalidated.
pub fn sync_path(db: &mut dyn Db, path: &SystemPath) {
let absolute = SystemPath::absolute(path, db.system().current_directory());
Files::touch_root(db, &absolute);
Self::sync_system_path(db, &absolute, None);
}
/// Refreshes *only* the file metadata by querying the file system if needed.
///
/// This specifically does not touch any file root associated with the
/// given file path.
pub fn sync_path_only(db: &mut dyn Db, path: &SystemPath) {
let absolute = SystemPath::absolute(path, db.system().current_directory());
Self::sync_system_path(db, &absolute, None);
}
/// Increments the revision for the virtual file at `path`.
pub fn sync_virtual_path(db: &mut dyn Db, path: &SystemVirtualPath) {
if let Some(virtual_file) = db.files().try_virtual_file(path) {
@@ -486,7 +499,7 @@ impl fmt::Debug for File {
///
/// This is a wrapper around a [`File`] that provides additional methods to interact with a virtual
/// file.
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub struct VirtualFile(File);
impl VirtualFile {

View File

@@ -23,7 +23,7 @@ pub struct FileRoot {
pub path: SystemPathBuf,
/// The kind of the root at the time of its creation.
kind_at_time_of_creation: FileRootKind,
pub kind_at_time_of_creation: FileRootKind,
/// A revision that changes when the contents of the source root change.
///

View File

@@ -87,7 +87,7 @@ impl SourceDb for ModuleDb {
#[salsa::db]
impl Db for ModuleDb {
fn is_file_open(&self, file: File) -> bool {
fn should_check_file(&self, file: File) -> bool {
!file.path(self).is_vendored_path()
}

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff_linter"
version = "0.12.3"
version = "0.12.4"
publish = false
authors = { workspace = true }
edition = { workspace = true }
@@ -15,7 +15,7 @@ license = { workspace = true }
[dependencies]
ruff_annotate_snippets = { workspace = true }
ruff_cache = { workspace = true }
ruff_db = { workspace = true, features = ["serde"] }
ruff_db = { workspace = true, features = ["junit", "serde"] }
ruff_diagnostics = { workspace = true, features = ["serde"] }
ruff_notebook = { workspace = true }
ruff_macros = { workspace = true }
@@ -55,7 +55,6 @@ path-absolutize = { workspace = true, features = [
pathdiff = { workspace = true }
pep440_rs = { workspace = true }
pyproject-toml = { workspace = true }
quick-junit = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }

View File

@@ -0,0 +1,10 @@
from collections import Counter
from elsewhere import third_party
from . import first_party
def f(x: first_party.foo): ...
def g(x: third_party.bar): ...
def h(x: Counter): ...

View File

@@ -0,0 +1,68 @@
def f():
from . import first_party
def f(x: first_party.foo): ...
# Type parameter bounds
def g():
from . import foo
class C[T: foo.Ty]: ...
def h():
from . import foo
def f[T: foo.Ty](x: T): ...
def i():
from . import foo
type Alias[T: foo.Ty] = list[T]
# Type parameter defaults
def j():
from . import foo
class C[T = foo.Ty]: ...
def k():
from . import foo
def f[T = foo.Ty](x: T): ...
def l():
from . import foo
type Alias[T = foo.Ty] = list[T]
# non-generic type alias
def m():
from . import foo
type Alias = foo.Ty
# unions
from typing import Union
def n():
from . import foo
def f(x: Union[foo.Ty, int]): ...
def g(x: foo.Ty | int): ...
# runtime and typing usage
def o():
from . import foo
def f(x: foo.Ty):
return foo.Ty()

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
from . import first_party
def f(x: first_party.foo): ...

View File

@@ -54,6 +54,13 @@ windows_path.with_suffix(r"s")
windows_path.with_suffix(u'' "json")
windows_path.with_suffix(suffix="js")
Path().with_suffix(".")
Path().with_suffix("py")
PosixPath().with_suffix("py")
PurePath().with_suffix("py")
PurePosixPath().with_suffix("py")
PureWindowsPath().with_suffix("py")
WindowsPath().with_suffix("py")
### No errors
path.with_suffix()

View File

@@ -104,3 +104,6 @@ os.chmod(x)
os.replace("src", "dst", src_dir_fd=1, dst_dir_fd=2)
os.replace("src", "dst", src_dir_fd=1)
os.replace("src", "dst", dst_dir_fd=2)
os.getcwd()
os.getcwdb()

View File

@@ -0,0 +1,5 @@
# This is a regression test for https://github.com/astral-sh/ruff/issues/19310
# there is a (potentially invisible) unicode formfeed character (000C) between "docstring" and the semicolon
"docstring" ; print(
f"{__doc__=}",
)

View File

@@ -91,9 +91,16 @@ Path("foo.txt").write_text(text, encoding="utf-8")
Path("foo.txt").write_text(text, *args)
Path("foo.txt").write_text(text, **kwargs)
# Violation but not detectable
# https://github.com/astral-sh/ruff/issues/19294
x = Path("foo.txt")
x.open()
# https://github.com/astral-sh/ruff/issues/18107
codecs.open("plw1514.py", "r", "utf-8").close() # this is fine
# function argument annotated as Path
from pathlib import Path
def format_file(file: Path):
with file.open() as f:
contents = f.read()

View File

@@ -27,7 +27,24 @@ _ = Decimal.from_float(float(" -inF\n \t"))
_ = Decimal.from_float(float(" InfinIty\n\t "))
_ = Decimal.from_float(float(" -InfinIty\n \t"))
# OK
# Cases with keyword arguments - should produce unsafe fixes
_ = Fraction.from_decimal(dec=Decimal("4.2"))
_ = Decimal.from_float(f=4.2)
# Cases with invalid argument counts - should not get fixes
_ = Fraction.from_decimal(Decimal("4.2"), 1)
_ = Decimal.from_float(4.2, None)
# Cases with wrong keyword arguments - should not get fixes
_ = Fraction.from_decimal(numerator=Decimal("4.2"))
_ = Decimal.from_float(value=4.2)
# Cases with type validation issues - should produce unsafe fixes
_ = Decimal.from_float("4.2") # Invalid type for from_float
_ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
_ = Fraction.from_float("4.2") # Invalid type for from_float
# OK - should not trigger the rule
_ = Fraction(0.1)
_ = Fraction(-0.5)
_ = Fraction(5.0)

View File

@@ -79,3 +79,8 @@ def in_type_def():
from typing import cast
a = 'int'
cast('f"{a}"','11')
# Regression test for parser bug
# https://github.com/astral-sh/ruff/issues/18860
def fuzz_bug():
c('{\t"i}')

View File

@@ -0,0 +1,24 @@
import re
b_src = b"abc"
# Should be replaced with `b_src.replace(rb"x", b"y")`
re.sub(rb"x", b"y", b_src)
# Should be replaced with `b_src.startswith(rb"abc")`
if re.match(rb"abc", b_src):
pass
# Should be replaced with `rb"x" in b_src`
if re.search(rb"x", b_src):
pass
# Should be replaced with `b_src.split(rb"abc")`
re.split(rb"abc", b_src)
# Patterns containing metacharacters should NOT be replaced
re.sub(rb"ab[c]", b"", b_src)
re.match(rb"ab[c]", b_src)
re.search(rb"ab[c]", b_src)
re.fullmatch(rb"ab[c]", b_src)
re.split(rb"ab[c]", b_src)

View File

@@ -71,7 +71,7 @@ pub(crate) fn deferred_scopes(checker: &Checker) {
flake8_type_checking::helpers::is_valid_runtime_import(
binding,
&checker.semantic,
&checker.settings().flake8_type_checking,
checker.settings(),
)
})
.collect()

View File

@@ -1044,7 +1044,6 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
Rule::OsMakedirs,
Rule::OsRename,
Rule::OsReplace,
Rule::OsGetcwd,
Rule::OsStat,
Rule::OsPathJoin,
Rule::OsPathSamefile,
@@ -1110,6 +1109,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::OsReadlink) {
flake8_use_pathlib::rules::os_readlink(checker, call, segments);
}
if checker.is_rule_enabled(Rule::OsGetcwd) {
flake8_use_pathlib::rules::os_getcwd(checker, call, segments);
}
if checker.is_rule_enabled(Rule::PathConstructorCurrentDirectory) {
flake8_use_pathlib::rules::path_constructor_current_directory(
checker, call, segments,

View File

@@ -2770,11 +2770,10 @@ impl<'a> Checker<'a> {
self.semantic.restore(snapshot);
if self.semantic.in_typing_only_annotation() {
if self.is_rule_enabled(Rule::QuotedAnnotation) {
pyupgrade::rules::quoted_annotation(self, annotation, range);
}
if self.is_rule_enabled(Rule::QuotedAnnotation) {
pyupgrade::rules::quoted_annotation(self, annotation, range);
}
if self.source_type.is_stub() {
if self.is_rule_enabled(Rule::QuotedAnnotationInStub) {
flake8_pyi::rules::quoted_annotation_in_stub(

View File

@@ -928,7 +928,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8UsePathlib, "106") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsRmdir),
(Flake8UsePathlib, "107") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsRemove),
(Flake8UsePathlib, "108") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsUnlink),
(Flake8UsePathlib, "109") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsGetcwd),
(Flake8UsePathlib, "109") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsGetcwd),
(Flake8UsePathlib, "110") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathExists),
(Flake8UsePathlib, "111") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathExpanduser),
(Flake8UsePathlib, "112") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathIsdir),

View File

@@ -288,7 +288,7 @@ fn match_docstring_end(body: &[Stmt]) -> Option<TextSize> {
fn match_semicolon(s: &str) -> Option<TextSize> {
for (offset, c) in s.char_indices() {
match c {
' ' | '\t' => continue,
_ if is_python_whitespace(c) => continue,
';' => return Some(TextSize::try_from(offset).unwrap()),
_ => break,
}

View File

@@ -527,6 +527,17 @@ impl<'a> Importer<'a> {
None
}
}
/// Add a `from __future__ import annotations` import.
pub(crate) fn add_future_import(&self) -> Edit {
let import = &NameImport::ImportFrom(MemberNameImport::member(
"__future__".to_string(),
"annotations".to_string(),
));
// Note that `TextSize::default` should ensure that the import is added at the very
// beginning of the file via `Insertion::start_of_file`.
self.add_import(import, TextSize::default())
}
}
/// An edit to the top-level of a module, making it available at runtime.

View File

@@ -1,117 +0,0 @@
use std::io::Write;
use std::path::Path;
use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite, XmlString};
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::LineColumn;
use crate::message::{Emitter, EmitterContext, MessageWithLocation, group_diagnostics_by_filename};
#[derive(Default)]
pub struct JunitEmitter;
impl Emitter for JunitEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
context: &EmitterContext,
) -> anyhow::Result<()> {
let mut report = Report::new("ruff");
if diagnostics.is_empty() {
let mut test_suite = TestSuite::new("ruff");
test_suite
.extra
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
let mut case = TestCase::new("No errors found", TestCaseStatus::success());
case.set_classname("ruff");
test_suite.add_test_case(case);
report.add_test_suite(test_suite);
} else {
for (filename, messages) in group_diagnostics_by_filename(diagnostics) {
let mut test_suite = TestSuite::new(&filename);
test_suite
.extra
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
for message in messages {
let MessageWithLocation {
message,
start_location,
} = message;
let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
status.set_message(message.body());
let location = if context.is_notebook(&message.expect_ruff_filename()) {
// We can't give a reasonable location for the structured formats,
// so we show one that's clearly a fallback
LineColumn::default()
} else {
start_location
};
status.set_description(format!(
"line {row}, col {col}, {body}",
row = location.line,
col = location.column,
body = message.body()
));
let mut case = TestCase::new(
if let Some(code) = message.secondary_code() {
format!("org.ruff.{code}")
} else {
"org.ruff".to_string()
},
status,
);
let file_path = Path::new(&*filename);
let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
let classname = file_path.parent().unwrap().join(file_stem);
case.set_classname(classname.to_str().unwrap());
case.extra.insert(
XmlString::new("line"),
XmlString::new(location.line.to_string()),
);
case.extra.insert(
XmlString::new("column"),
XmlString::new(location.column.to_string()),
);
test_suite.add_test_case(case);
}
report.add_test_suite(test_suite);
}
}
report.serialize(writer)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::JunitEmitter;
use crate::message::tests::{
capture_emitter_output, create_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = JunitEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = JunitEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
}

View File

@@ -14,7 +14,6 @@ use ruff_db::files::File;
pub use github::GithubEmitter;
pub use gitlab::GitlabEmitter;
pub use grouped::GroupedEmitter;
pub use junit::JunitEmitter;
use ruff_notebook::NotebookIndex;
use ruff_source_file::{LineColumn, SourceFile};
use ruff_text_size::{Ranged, TextRange, TextSize};
@@ -28,7 +27,6 @@ mod diff;
mod github;
mod gitlab;
mod grouped;
mod junit;
mod sarif;
mod text;

View File

@@ -134,6 +134,11 @@ pub(crate) const fn is_fix_os_path_dirname_enabled(settings: &LinterSettings) ->
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19245
pub(crate) const fn is_fix_os_getcwd_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/11436
// https://github.com/astral-sh/ruff/pull/11168
pub(crate) const fn is_dunder_init_fix_unused_import_enabled(settings: &LinterSettings) -> bool {
@@ -195,3 +200,8 @@ pub(crate) const fn is_safe_super_call_with_parameters_fix_enabled(
pub(crate) const fn is_assert_raises_exception_call_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19100
pub(crate) const fn is_add_future_annotations_imports_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}

View File

@@ -2,8 +2,7 @@ use std::fmt;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::Expr;
use ruff_python_semantic::{MemberNameImport, NameImport};
use ruff_text_size::{Ranged, TextSize};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::{AlwaysFixableViolation, Fix};
@@ -85,15 +84,7 @@ impl AlwaysFixableViolation for FutureRequiredTypeAnnotation {
/// FA102
pub(crate) fn future_required_type_annotation(checker: &Checker, expr: &Expr, reason: Reason) {
let mut diagnostic =
checker.report_diagnostic(FutureRequiredTypeAnnotation { reason }, expr.range());
let required_import = NameImport::ImportFrom(MemberNameImport::member(
"__future__".to_string(),
"annotations".to_string(),
));
diagnostic.set_fix(Fix::unsafe_edit(
checker
.importer()
.add_import(&required_import, TextSize::default()),
));
checker
.report_diagnostic(FutureRequiredTypeAnnotation { reason }, expr.range())
.set_fix(Fix::unsafe_edit(checker.importer().add_future_import()));
}

View File

@@ -1,12 +1,10 @@
use ruff_diagnostics::Fix;
use ruff_python_ast::Expr;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_semantic::{MemberNameImport, NameImport};
use ruff_text_size::Ranged;
use crate::AlwaysFixableViolation;
use crate::checkers::ast::Checker;
use crate::{AlwaysFixableViolation, Fix};
/// ## What it does
/// Checks for missing `from __future__ import annotations` imports upon
@@ -95,15 +93,7 @@ pub(crate) fn future_rewritable_type_annotation(checker: &Checker, expr: &Expr)
let Some(name) = name else { return };
let import = &NameImport::ImportFrom(MemberNameImport::member(
"__future__".to_string(),
"annotations".to_string(),
));
checker
.report_diagnostic(FutureRewritableTypeAnnotation { name }, expr.range())
.set_fix(Fix::unsafe_edit(
checker
.importer()
.add_import(import, ruff_text_size::TextSize::default()),
));
.set_fix(Fix::unsafe_edit(checker.importer().add_future_import()));
}

View File

@@ -8,41 +8,110 @@ use ruff_python_ast::{self as ast, Decorator, Expr, StringLiteralFlags};
use ruff_python_codegen::{Generator, Stylist};
use ruff_python_parser::typing::parse_type_annotation;
use ruff_python_semantic::{
Binding, BindingKind, Modules, NodeId, ResolvedReference, ScopeKind, SemanticModel, analyze,
Binding, BindingKind, Modules, NodeId, ScopeKind, SemanticModel, analyze,
};
use ruff_text_size::{Ranged, TextRange};
use crate::Edit;
use crate::Locator;
use crate::rules::flake8_type_checking::settings::Settings;
use crate::settings::LinterSettings;
/// Returns `true` if the [`ResolvedReference`] is in a typing-only context _or_ a runtime-evaluated
/// context (with quoting enabled).
pub(crate) fn is_typing_reference(reference: &ResolvedReference, settings: &Settings) -> bool {
reference.in_type_checking_block()
// if we're not in a type checking block, we necessarily need to be within a
// type definition to be considered a typing reference
|| (reference.in_type_definition()
&& (reference.in_typing_only_annotation()
|| reference.in_string_type_definition()
|| (settings.quote_annotations && reference.in_runtime_evaluated_annotation())))
/// Represents the kind of an existing or potential typing-only annotation.
///
/// Note that the order of variants is important here. `Runtime` has the highest precedence when
/// calling [`TypingReference::combine`] on two references, followed by `Future`, `Quote`, and
/// `TypingOnly` with the lowest precedence.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum TypingReference {
/// The reference is in a runtime-evaluated context.
Runtime,
/// The reference is in a runtime-evaluated context, but the
/// `lint.future-annotations` setting is enabled.
///
/// This takes precedence if both quoting and future imports are enabled.
Future,
/// The reference is in a runtime-evaluated context, but the
/// `lint.flake8-type-checking.quote-annotations` setting is enabled.
Quote,
/// The reference is in a typing-only context.
TypingOnly,
}
impl TypingReference {
/// Determine the kind of [`TypingReference`] for all references to a binding.
pub(crate) fn from_references(
binding: &Binding,
semantic: &SemanticModel,
settings: &LinterSettings,
) -> Self {
let references = binding
.references()
.map(|reference_id| semantic.reference(reference_id));
let mut kind = Self::TypingOnly;
for reference in references {
if reference.in_type_checking_block() {
kind = kind.combine(Self::TypingOnly);
continue;
}
// if we're not in a type checking block, we necessarily need to be within a
// type definition to be considered a typing reference
if !reference.in_type_definition() {
return Self::Runtime;
}
if reference.in_typing_only_annotation() || reference.in_string_type_definition() {
kind = kind.combine(Self::TypingOnly);
continue;
}
// prefer `from __future__ import annotations` to quoting
if settings.future_annotations()
&& !reference.in_typing_only_annotation()
&& reference.in_runtime_evaluated_annotation()
{
kind = kind.combine(Self::Future);
continue;
}
if settings.flake8_type_checking.quote_annotations
&& reference.in_runtime_evaluated_annotation()
{
kind = kind.combine(Self::Quote);
continue;
}
return Self::Runtime;
}
kind
}
/// Logically combine two `TypingReference`s into one.
///
/// `TypingReference::Runtime` has the highest precedence, followed by
/// `TypingReference::Future`, `TypingReference::Quote`, and then `TypingReference::TypingOnly`.
fn combine(self, other: TypingReference) -> TypingReference {
self.min(other)
}
fn is_runtime(self) -> bool {
matches!(self, Self::Runtime)
}
}
/// Returns `true` if the [`Binding`] represents a runtime-required import.
pub(crate) fn is_valid_runtime_import(
binding: &Binding,
semantic: &SemanticModel,
settings: &Settings,
settings: &LinterSettings,
) -> bool {
if matches!(
binding.kind,
BindingKind::Import(..) | BindingKind::FromImport(..) | BindingKind::SubmoduleImport(..)
) {
binding.context.is_runtime()
&& binding
.references()
.map(|reference_id| semantic.reference(reference_id))
.any(|reference| !is_typing_reference(reference, settings))
&& TypingReference::from_references(binding, semantic, settings).is_runtime()
} else {
false
}

View File

@@ -13,6 +13,8 @@ pub(crate) struct ImportBinding<'a> {
pub(crate) range: TextRange,
/// The range of the import's parent statement.
pub(crate) parent_range: Option<TextRange>,
/// Whether the binding needs `from __future__ import annotations` to be imported.
pub(crate) needs_future_import: bool,
}
impl Ranged for ImportBinding<'_> {

View File

@@ -9,10 +9,12 @@ mod tests {
use std::path::Path;
use anyhow::Result;
use itertools::Itertools;
use ruff_python_ast::PythonVersion;
use test_case::test_case;
use crate::registry::{Linter, Rule};
use crate::settings::types::PreviewMode;
use crate::test::{test_path, test_snippet};
use crate::{assert_diagnostics, settings};
@@ -64,6 +66,40 @@ mod tests {
Ok(())
}
#[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001.py"))]
#[test_case(&[Rule::TypingOnlyThirdPartyImport], Path::new("TC002.py"))]
#[test_case(&[Rule::TypingOnlyStandardLibraryImport], Path::new("TC003.py"))]
#[test_case(
&[
Rule::TypingOnlyFirstPartyImport,
Rule::TypingOnlyThirdPartyImport,
Rule::TypingOnlyStandardLibraryImport,
],
Path::new("TC001-3_future.py")
)]
#[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future.py"))]
#[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future_present.py"))]
fn add_future_import(rules: &[Rule], path: &Path) -> Result<()> {
let name = rules.iter().map(Rule::noqa_code).join("-");
let snapshot = format!("add_future_import__{}_{}", name, path.to_string_lossy());
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
future_annotations: true,
preview: PreviewMode::Enabled,
// also enable quoting annotations to check the interaction. the future import
// should take precedence.
flake8_type_checking: super::settings::Settings {
quote_annotations: true,
..Default::default()
},
..settings::LinterSettings::for_rules(rules.iter().copied())
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
// we test these rules as a pair, since they're opposites of one another
// so we want to make sure their fixes are not going around in circles.
#[test_case(Rule::UnquotedTypeAlias, Path::new("TC007.py"))]

View File

@@ -139,6 +139,7 @@ pub(crate) fn runtime_import_in_type_checking_block(checker: &Checker, scope: &S
binding,
range: binding.range(),
parent_range: binding.parent_range(checker.semantic()),
needs_future_import: false, // TODO(brent) See #19359.
};
if checker.rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, import.start())

View File

@@ -13,7 +13,7 @@ use crate::fix;
use crate::importer::ImportedMembers;
use crate::preview::is_full_path_match_source_strategy_enabled;
use crate::rules::flake8_type_checking::helpers::{
filter_contained, is_typing_reference, quote_annotation,
TypingReference, filter_contained, quote_annotation,
};
use crate::rules::flake8_type_checking::imports::ImportBinding;
use crate::rules::isort::categorize::MatchSourceStrategy;
@@ -71,12 +71,19 @@ use crate::{Fix, FixAvailability, Violation};
/// the criterion for determining whether an import is first-party
/// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-third-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
///
/// If [`lint.future-annotations`] is set to `true`, `from __future__ import
/// annotations` will be added if doing so would enable an import to be moved into an `if
/// TYPE_CHECKING:` block. This takes precedence over the
/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are
/// enabled.
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.runtime-evaluated-base-classes`
/// - `lint.flake8-type-checking.runtime-evaluated-decorators`
/// - `lint.flake8-type-checking.strict`
/// - `lint.typing-modules`
/// - `lint.future-annotations`
///
/// ## References
/// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking)
@@ -151,12 +158,19 @@ impl Violation for TypingOnlyFirstPartyImport {
/// the criterion for determining whether an import is first-party
/// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-first-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
///
/// If [`lint.future-annotations`] is set to `true`, `from __future__ import
/// annotations` will be added if doing so would enable an import to be moved into an `if
/// TYPE_CHECKING:` block. This takes precedence over the
/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are
/// enabled.
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.runtime-evaluated-base-classes`
/// - `lint.flake8-type-checking.runtime-evaluated-decorators`
/// - `lint.flake8-type-checking.strict`
/// - `lint.typing-modules`
/// - `lint.future-annotations`
///
/// ## References
/// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking)
@@ -226,12 +240,22 @@ impl Violation for TypingOnlyThirdPartyImport {
/// return str(path)
/// ```
///
/// ## Preview
///
/// When [preview](https://docs.astral.sh/ruff/preview/) is enabled, if
/// [`lint.future-annotations`] is set to `true`, `from __future__ import
/// annotations` will be added if doing so would enable an import to be moved into an `if
/// TYPE_CHECKING:` block. This takes precedence over the
/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are
/// enabled.
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.runtime-evaluated-base-classes`
/// - `lint.flake8-type-checking.runtime-evaluated-decorators`
/// - `lint.flake8-type-checking.strict`
/// - `lint.typing-modules`
/// - `lint.future-annotations`
///
/// ## References
/// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking)
@@ -271,9 +295,10 @@ pub(crate) fn typing_only_runtime_import(
for binding_id in scope.binding_ids() {
let binding = checker.semantic().binding(binding_id);
// If we're in un-strict mode, don't flag typing-only imports that are
// implicitly loaded by way of a valid runtime import.
if !checker.settings().flake8_type_checking.strict
// If we can't add a `__future__` import and in un-strict mode, don't flag typing-only
// imports that are implicitly loaded by way of a valid runtime import.
if !checker.settings().future_annotations()
&& !checker.settings().flake8_type_checking.strict
&& runtime_imports
.iter()
.any(|import| is_implicit_import(binding, import))
@@ -289,95 +314,102 @@ pub(crate) fn typing_only_runtime_import(
continue;
};
if binding.context.is_runtime()
&& binding
.references()
.map(|reference_id| checker.semantic().reference(reference_id))
.all(|reference| {
is_typing_reference(reference, &checker.settings().flake8_type_checking)
})
{
let qualified_name = import.qualified_name();
if !binding.context.is_runtime() {
continue;
}
if is_exempt(
&qualified_name.to_string(),
&checker
.settings()
.flake8_type_checking
.exempt_modules
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
) {
continue;
}
let typing_reference =
TypingReference::from_references(binding, checker.semantic(), checker.settings());
let source_name = import.source_name().join(".");
let needs_future_import = match typing_reference {
TypingReference::Runtime => continue,
// We can only get the `Future` variant if `future_annotations` is
// enabled, so we can unconditionally set this here.
TypingReference::Future => true,
TypingReference::TypingOnly | TypingReference::Quote => false,
};
// Categorize the import, using coarse-grained categorization.
let match_source_strategy =
if is_full_path_match_source_strategy_enabled(checker.settings()) {
MatchSourceStrategy::FullPath
} else {
MatchSourceStrategy::Root
};
let qualified_name = import.qualified_name();
let import_type = match categorize(
&source_name,
qualified_name.is_unresolved_import(),
&checker.settings().src,
checker.package(),
checker.settings().isort.detect_same_package,
&checker.settings().isort.known_modules,
checker.target_version(),
checker.settings().isort.no_sections,
&checker.settings().isort.section_order,
&checker.settings().isort.default_section,
match_source_strategy,
) {
ImportSection::Known(ImportType::LocalFolder | ImportType::FirstParty) => {
ImportType::FirstParty
}
ImportSection::Known(ImportType::ThirdParty) | ImportSection::UserDefined(_) => {
ImportType::ThirdParty
}
ImportSection::Known(ImportType::StandardLibrary) => ImportType::StandardLibrary,
ImportSection::Known(ImportType::Future) => {
continue;
}
};
if is_exempt(
&qualified_name.to_string(),
&checker
.settings()
.flake8_type_checking
.exempt_modules
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
) {
continue;
}
if !checker.is_rule_enabled(rule_for(import_type)) {
continue;
}
let source_name = import.source_name().join(".");
let Some(node_id) = binding.source else {
continue;
};
let import = ImportBinding {
import,
reference_id,
binding,
range: binding.range(),
parent_range: binding.parent_range(checker.semantic()),
};
if checker.rule_is_ignored(rule_for(import_type), import.start())
|| import.parent_range.is_some_and(|parent_range| {
checker.rule_is_ignored(rule_for(import_type), parent_range.start())
})
{
ignores_by_statement
.entry((node_id, import_type))
.or_default()
.push(import);
// Categorize the import, using coarse-grained categorization.
let match_source_strategy =
if is_full_path_match_source_strategy_enabled(checker.settings()) {
MatchSourceStrategy::FullPath
} else {
errors_by_statement
.entry((node_id, import_type))
.or_default()
.push(import);
MatchSourceStrategy::Root
};
let import_type = match categorize(
&source_name,
qualified_name.is_unresolved_import(),
&checker.settings().src,
checker.package(),
checker.settings().isort.detect_same_package,
&checker.settings().isort.known_modules,
checker.target_version(),
checker.settings().isort.no_sections,
&checker.settings().isort.section_order,
&checker.settings().isort.default_section,
match_source_strategy,
) {
ImportSection::Known(ImportType::LocalFolder | ImportType::FirstParty) => {
ImportType::FirstParty
}
ImportSection::Known(ImportType::ThirdParty) | ImportSection::UserDefined(_) => {
ImportType::ThirdParty
}
ImportSection::Known(ImportType::StandardLibrary) => ImportType::StandardLibrary,
ImportSection::Known(ImportType::Future) => {
continue;
}
};
if !checker.is_rule_enabled(rule_for(import_type)) {
continue;
}
let Some(node_id) = binding.source else {
continue;
};
let import = ImportBinding {
import,
reference_id,
binding,
range: binding.range(),
parent_range: binding.parent_range(checker.semantic()),
needs_future_import,
};
if checker.rule_is_ignored(rule_for(import_type), import.start())
|| import.parent_range.is_some_and(|parent_range| {
checker.rule_is_ignored(rule_for(import_type), parent_range.start())
})
{
ignores_by_statement
.entry((node_id, import_type))
.or_default()
.push(import);
} else {
errors_by_statement
.entry((node_id, import_type))
.or_default()
.push(import);
}
}
@@ -509,6 +541,8 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) ->
.min()
.expect("Expected at least one import");
let add_future_import = imports.iter().any(|binding| binding.needs_future_import);
// Step 1) Remove the import.
let remove_import_edit = fix::edits::remove_unused_imports(
member_names.iter().map(AsRef::as_ref),
@@ -532,37 +566,52 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) ->
)?
.into_edits();
// Step 3) Quote any runtime usages of the referenced symbol.
let quote_reference_edits = filter_contained(
imports
.iter()
.flat_map(|ImportBinding { binding, .. }| {
binding.references.iter().filter_map(|reference_id| {
let reference = checker.semantic().reference(*reference_id);
if reference.in_runtime_context() {
Some(quote_annotation(
reference.expression_id()?,
checker.semantic(),
checker.stylist(),
checker.locator(),
checker.default_string_flags(),
))
} else {
None
}
})
})
.collect::<Vec<_>>(),
);
// Step 3) Either add a `__future__` import or quote any runtime usages of the referenced
// symbol.
let fix = if add_future_import {
let future_import = checker.importer().add_future_import();
Ok(Fix::unsafe_edits(
type_checking_edit,
add_import_edit
.into_iter()
.chain(std::iter::once(remove_import_edit))
.chain(quote_reference_edits),
)
.isolate(Checker::isolation(
// The order here is very important. We first need to add the `__future__` import, if
// needed, since it's a syntax error to come later. Then `type_checking_edit` imports
// `TYPE_CHECKING`, if available. Then we can add and/or remove existing imports.
Fix::unsafe_edits(
future_import,
std::iter::once(type_checking_edit)
.chain(add_import_edit)
.chain(std::iter::once(remove_import_edit)),
)
} else {
let quote_reference_edits = filter_contained(
imports
.iter()
.flat_map(|ImportBinding { binding, .. }| {
binding.references.iter().filter_map(|reference_id| {
let reference = checker.semantic().reference(*reference_id);
if reference.in_runtime_context() {
Some(quote_annotation(
reference.expression_id()?,
checker.semantic(),
checker.stylist(),
checker.locator(),
checker.default_string_flags(),
))
} else {
None
}
})
})
.collect::<Vec<_>>(),
);
Fix::unsafe_edits(
type_checking_edit,
add_import_edit
.into_iter()
.chain(std::iter::once(remove_import_edit))
.chain(quote_reference_edits),
)
};
Ok(fix.isolate(Checker::isolation(
checker.semantic().parent_statement_id(node_id),
)))
}

View File

@@ -0,0 +1,76 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC001-3_future.py:1:25: TC003 [*] Move standard library import `collections.Counter` into a type-checking block
|
1 | from collections import Counter
| ^^^^^^^ TC003
2 |
3 | from elsewhere import third_party
|
= help: Move into type-checking block
Unsafe fix
1 |-from collections import Counter
1 |+from __future__ import annotations
2 2 |
3 3 | from elsewhere import third_party
4 4 |
5 5 | from . import first_party
6 |+from typing import TYPE_CHECKING
7 |+
8 |+if TYPE_CHECKING:
9 |+ from collections import Counter
6 10 |
7 11 |
8 12 | def f(x: first_party.foo): ...
TC001-3_future.py:3:23: TC002 [*] Move third-party import `elsewhere.third_party` into a type-checking block
|
1 | from collections import Counter
2 |
3 | from elsewhere import third_party
| ^^^^^^^^^^^ TC002
4 |
5 | from . import first_party
|
= help: Move into type-checking block
Unsafe fix
1 |+from __future__ import annotations
1 2 | from collections import Counter
2 3 |
3 |-from elsewhere import third_party
4 4 |
5 5 | from . import first_party
6 |+from typing import TYPE_CHECKING
7 |+
8 |+if TYPE_CHECKING:
9 |+ from elsewhere import third_party
6 10 |
7 11 |
8 12 | def f(x: first_party.foo): ...
TC001-3_future.py:5:15: TC001 [*] Move application import `.first_party` into a type-checking block
|
3 | from elsewhere import third_party
4 |
5 | from . import first_party
| ^^^^^^^^^^^ TC001
|
= help: Move into type-checking block
Unsafe fix
1 |+from __future__ import annotations
1 2 | from collections import Counter
2 3 |
3 4 | from elsewhere import third_party
4 5 |
5 |-from . import first_party
6 |+from typing import TYPE_CHECKING
7 |+
8 |+if TYPE_CHECKING:
9 |+ from . import first_party
6 10 |
7 11 |
8 12 | def f(x: first_party.foo): ...

View File

@@ -0,0 +1,32 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC001.py:20:19: TC001 [*] Move application import `.TYP001` into a type-checking block
|
19 | def f():
20 | from . import TYP001
| ^^^^^^ TC001
21 |
22 | x: TYP001
|
= help: Move into type-checking block
Unsafe fix
2 2 |
3 3 | For typing-only import detection tests, see `TC002.py`.
4 4 | """
5 |+from typing import TYPE_CHECKING
6 |+
7 |+if TYPE_CHECKING:
8 |+ from . import TYP001
5 9 |
6 10 |
7 11 | def f():
--------------------------------------------------------------------------------
17 21 |
18 22 |
19 23 | def f():
20 |- from . import TYP001
21 24 |
22 25 | x: TYP001
23 26 |

View File

@@ -0,0 +1,56 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC001_future.py:2:19: TC001 [*] Move application import `.first_party` into a type-checking block
|
1 | def f():
2 | from . import first_party
| ^^^^^^^^^^^ TC001
3 |
4 | def f(x: first_party.foo): ...
|
= help: Move into type-checking block
Unsafe fix
1 |-def f():
1 |+from __future__ import annotations
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
2 5 | from . import first_party
6 |+def f():
3 7 |
4 8 | def f(x: first_party.foo): ...
5 9 |
TC001_future.py:57:19: TC001 [*] Move application import `.foo` into a type-checking block
|
56 | def n():
57 | from . import foo
| ^^^ TC001
58 |
59 | def f(x: Union[foo.Ty, int]): ...
|
= help: Move into type-checking block
Unsafe fix
1 |+from __future__ import annotations
1 2 | def f():
2 3 | from . import first_party
3 4 |
--------------------------------------------------------------------------------
50 51 |
51 52 |
52 53 | # unions
53 |-from typing import Union
54 |+from typing import Union, TYPE_CHECKING
54 55 |
56 |+if TYPE_CHECKING:
57 |+ from . import foo
58 |+
55 59 |
56 60 | def n():
57 |- from . import foo
58 61 |
59 62 | def f(x: Union[foo.Ty, int]): ...
60 63 | def g(x: foo.Ty | int): ...

View File

@@ -0,0 +1,23 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC001_future_present.py:3:15: TC001 [*] Move application import `.first_party` into a type-checking block
|
1 | from __future__ import annotations
2 |
3 | from . import first_party
| ^^^^^^^^^^^ TC001
|
= help: Move into type-checking block
Unsafe fix
1 1 | from __future__ import annotations
2 2 |
3 |-from . import first_party
3 |+from typing import TYPE_CHECKING
4 |+
5 |+if TYPE_CHECKING:
6 |+ from . import first_party
4 7 |
5 8 |
6 9 | def f(x: first_party.foo): ...

View File

@@ -0,0 +1,251 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC002.py:5:22: TC002 [*] Move third-party import `pandas` into a type-checking block
|
4 | def f():
5 | import pandas as pd # TC002
| ^^ TC002
6 |
7 | x: pd.DataFrame
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ import pandas as pd
2 6 |
3 7 |
4 8 | def f():
5 |- import pandas as pd # TC002
6 9 |
7 10 | x: pd.DataFrame
8 11 |
TC002.py:11:24: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block
|
10 | def f():
11 | from pandas import DataFrame # TC002
| ^^^^^^^^^ TC002
12 |
13 | x: DataFrame
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ from pandas import DataFrame
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
8 12 |
9 13 |
10 14 | def f():
11 |- from pandas import DataFrame # TC002
12 15 |
13 16 | x: DataFrame
14 17 |
TC002.py:17:37: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block
|
16 | def f():
17 | from pandas import DataFrame as df # TC002
| ^^ TC002
18 |
19 | x: df
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ from pandas import DataFrame as df
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
14 18 |
15 19 |
16 20 | def f():
17 |- from pandas import DataFrame as df # TC002
18 21 |
19 22 | x: df
20 23 |
TC002.py:23:22: TC002 [*] Move third-party import `pandas` into a type-checking block
|
22 | def f():
23 | import pandas as pd # TC002
| ^^ TC002
24 |
25 | x: pd.DataFrame = 1
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ import pandas as pd
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
20 24 |
21 25 |
22 26 | def f():
23 |- import pandas as pd # TC002
24 27 |
25 28 | x: pd.DataFrame = 1
26 29 |
TC002.py:29:24: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block
|
28 | def f():
29 | from pandas import DataFrame # TC002
| ^^^^^^^^^ TC002
30 |
31 | x: DataFrame = 2
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ from pandas import DataFrame
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
26 30 |
27 31 |
28 32 | def f():
29 |- from pandas import DataFrame # TC002
30 33 |
31 34 | x: DataFrame = 2
32 35 |
TC002.py:35:37: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block
|
34 | def f():
35 | from pandas import DataFrame as df # TC002
| ^^ TC002
36 |
37 | x: df = 3
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ from pandas import DataFrame as df
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
32 36 |
33 37 |
34 38 | def f():
35 |- from pandas import DataFrame as df # TC002
36 39 |
37 40 | x: df = 3
38 41 |
TC002.py:41:22: TC002 [*] Move third-party import `pandas` into a type-checking block
|
40 | def f():
41 | import pandas as pd # TC002
| ^^ TC002
42 |
43 | x: "pd.DataFrame" = 1
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ import pandas as pd
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
38 42 |
39 43 |
40 44 | def f():
41 |- import pandas as pd # TC002
42 45 |
43 46 | x: "pd.DataFrame" = 1
44 47 |
TC002.py:47:22: TC002 [*] Move third-party import `pandas` into a type-checking block
|
46 | def f():
47 | import pandas as pd # TC002
| ^^ TC002
48 |
49 | x = dict["pd.DataFrame", "pd.DataFrame"]
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ import pandas as pd
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
44 48 |
45 49 |
46 50 | def f():
47 |- import pandas as pd # TC002
48 51 |
49 52 | x = dict["pd.DataFrame", "pd.DataFrame"]
50 53 |
TC002.py:172:24: TC002 [*] Move third-party import `module.Member` into a type-checking block
|
170 | global Member
171 |
172 | from module import Member
| ^^^^^^ TC002
173 |
174 | x: Member = 1
|
= help: Move into type-checking block
Unsafe fix
1 1 | """Tests to determine accurate detection of typing-only imports."""
2 |+from typing import TYPE_CHECKING
3 |+
4 |+if TYPE_CHECKING:
5 |+ from module import Member
2 6 |
3 7 |
4 8 | def f():
--------------------------------------------------------------------------------
169 173 | def f():
170 174 | global Member
171 175 |
172 |- from module import Member
173 176 |
174 177 | x: Member = 1
175 178 |

View File

@@ -0,0 +1,28 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC003.py:8:12: TC003 [*] Move standard library import `os` into a type-checking block
|
7 | def f():
8 | import os
| ^^ TC003
9 |
10 | x: os
|
= help: Move into type-checking block
Unsafe fix
2 2 |
3 3 | For typing-only import detection tests, see `TC002.py`.
4 4 | """
5 |+from typing import TYPE_CHECKING
6 |+
7 |+if TYPE_CHECKING:
8 |+ import os
5 9 |
6 10 |
7 11 | def f():
8 |- import os
9 12 |
10 13 | x: os
11 14 |

View File

@@ -3,7 +3,7 @@ use crate::{Edit, Fix, FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, PythonVersion, StringFlags};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::typing;
use ruff_python_semantic::analyze::typing::{self, PathlibPathChecker, TypeChecker};
use ruff_text_size::Ranged;
/// ## What it does
@@ -141,12 +141,13 @@ fn is_path_with_suffix_call(semantic: &SemanticModel, func: &ast::Expr) -> bool
return false;
}
let ast::Expr::Name(name) = &**value else {
return false;
};
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return false;
};
typing::is_pathlib_path(binding, semantic)
match &**value {
ast::Expr::Name(name) => {
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return false;
};
typing::is_pathlib_path(binding, semantic)
}
expr => PathlibPathChecker::match_initializer(expr, semantic),
}
}

View File

@@ -1,5 +1,6 @@
pub(crate) use glob_rule::*;
pub(crate) use invalid_pathlib_with_suffix::*;
pub(crate) use os_getcwd::*;
pub(crate) use os_path_abspath::*;
pub(crate) use os_path_basename::*;
pub(crate) use os_path_dirname::*;
@@ -23,6 +24,7 @@ pub(crate) use replaceable_by_pathlib::*;
mod glob_rule;
mod invalid_pathlib_with_suffix;
mod os_getcwd;
mod os_path_abspath;
mod os_path_basename;
mod os_path_dirname;

View File

@@ -0,0 +1,100 @@
use crate::checkers::ast::Checker;
use crate::importer::ImportRequest;
use crate::preview::is_fix_os_getcwd_enabled;
use crate::{FixAvailability, Violation};
use ruff_diagnostics::{Applicability, Edit, Fix};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::ExprCall;
use ruff_text_size::Ranged;
/// ## What it does
/// Checks for uses of `os.getcwd` and `os.getcwdb`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.cwd()` can improve readability over the `os`
/// module's counterparts (e.g., `os.getcwd()`).
///
/// ## Examples
/// ```python
/// import os
///
/// cwd = os.getcwd()
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// cwd = Path.cwd()
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## Fix Safety
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
///
/// ## References
/// - [Python documentation: `Path.cwd`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.cwd)
/// - [Python documentation: `os.getcwd`](https://docs.python.org/3/library/os.html#os.getcwd)
/// - [Python documentation: `os.getcwdb`](https://docs.python.org/3/library/os.html#os.getcwdb)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsGetcwd;
impl Violation for OsGetcwd {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`os.getcwd()` should be replaced by `Path.cwd()`".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Replace with `Path.cwd()`".to_string())
}
}
/// PTH109
pub(crate) fn os_getcwd(checker: &Checker, call: &ExprCall, segments: &[&str]) {
if !matches!(segments, ["os", "getcwd" | "getcwdb"]) {
return;
}
let range = call.range();
let mut diagnostic = checker.report_diagnostic(OsGetcwd, call.func.range());
if !call.arguments.is_empty() {
return;
}
if is_fix_os_getcwd_enabled(checker.settings()) {
diagnostic.try_set_fix(|| {
let (import_edit, binding) = checker.importer().get_or_import_symbol(
&ImportRequest::import("pathlib", "Path"),
call.start(),
checker.semantic(),
)?;
let applicability = if checker.comment_ranges().intersects(range) {
Applicability::Unsafe
} else {
Applicability::Safe
};
let replacement = format!("{binding}.cwd()");
Ok(Fix::applicable_edits(
Edit::range_replacement(replacement, range),
[import_edit],
applicability,
))
});
}
}

View File

@@ -7,8 +7,8 @@ use crate::checkers::ast::Checker;
use crate::rules::flake8_use_pathlib::helpers::is_keyword_only_argument_non_default;
use crate::rules::flake8_use_pathlib::rules::Glob;
use crate::rules::flake8_use_pathlib::violations::{
BuiltinOpen, Joiner, OsChmod, OsGetcwd, OsListdir, OsMakedirs, OsMkdir, OsPathJoin,
OsPathSamefile, OsPathSplitext, OsRename, OsReplace, OsStat, OsSymlink, PyPath,
BuiltinOpen, Joiner, OsChmod, OsListdir, OsMakedirs, OsMkdir, OsPathJoin, OsPathSamefile,
OsPathSplitext, OsRename, OsReplace, OsStat, OsSymlink, PyPath,
};
pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
@@ -83,10 +83,6 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
}
checker.report_diagnostic_if_enabled(OsReplace, range)
}
// PTH109
["os", "getcwd"] => checker.report_diagnostic_if_enabled(OsGetcwd, range),
["os", "getcwdb"] => checker.report_diagnostic_if_enabled(OsGetcwd, range),
// PTH116
["os", "stat"] => {
// `dir_fd` is not supported by pathlib, so check if it's set to non-default values.

View File

@@ -536,7 +536,7 @@ PTH210.py:54:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
54 |+windows_path.with_suffix(u'.' "json")
55 55 | windows_path.with_suffix(suffix="js")
56 56 |
57 57 |
57 57 | Path().with_suffix(".")
PTH210.py:55:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
@@ -544,6 +544,8 @@ PTH210.py:55:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
54 | windows_path.with_suffix(u'' "json")
55 | windows_path.with_suffix(suffix="js")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
56 |
57 | Path().with_suffix(".")
|
= help: Add a leading dot
@@ -554,5 +556,140 @@ PTH210.py:55:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
55 |-windows_path.with_suffix(suffix="js")
55 |+windows_path.with_suffix(suffix=".js")
56 56 |
57 57 |
58 58 | ### No errors
57 57 | Path().with_suffix(".")
58 58 | Path().with_suffix("py")
PTH210.py:57:1: PTH210 Invalid suffix passed to `.with_suffix()`
|
55 | windows_path.with_suffix(suffix="js")
56 |
57 | Path().with_suffix(".")
| ^^^^^^^^^^^^^^^^^^^^^^^ PTH210
58 | Path().with_suffix("py")
59 | PosixPath().with_suffix("py")
|
= help: Remove "." or extend to valid suffix
PTH210.py:58:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
57 | Path().with_suffix(".")
58 | Path().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
59 | PosixPath().with_suffix("py")
60 | PurePath().with_suffix("py")
|
= help: Add a leading dot
Unsafe fix
55 55 | windows_path.with_suffix(suffix="js")
56 56 |
57 57 | Path().with_suffix(".")
58 |-Path().with_suffix("py")
58 |+Path().with_suffix(".py")
59 59 | PosixPath().with_suffix("py")
60 60 | PurePath().with_suffix("py")
61 61 | PurePosixPath().with_suffix("py")
PTH210.py:59:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
57 | Path().with_suffix(".")
58 | Path().with_suffix("py")
59 | PosixPath().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
60 | PurePath().with_suffix("py")
61 | PurePosixPath().with_suffix("py")
|
= help: Add a leading dot
Unsafe fix
56 56 |
57 57 | Path().with_suffix(".")
58 58 | Path().with_suffix("py")
59 |-PosixPath().with_suffix("py")
59 |+PosixPath().with_suffix(".py")
60 60 | PurePath().with_suffix("py")
61 61 | PurePosixPath().with_suffix("py")
62 62 | PureWindowsPath().with_suffix("py")
PTH210.py:60:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
58 | Path().with_suffix("py")
59 | PosixPath().with_suffix("py")
60 | PurePath().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
61 | PurePosixPath().with_suffix("py")
62 | PureWindowsPath().with_suffix("py")
|
= help: Add a leading dot
Unsafe fix
57 57 | Path().with_suffix(".")
58 58 | Path().with_suffix("py")
59 59 | PosixPath().with_suffix("py")
60 |-PurePath().with_suffix("py")
60 |+PurePath().with_suffix(".py")
61 61 | PurePosixPath().with_suffix("py")
62 62 | PureWindowsPath().with_suffix("py")
63 63 | WindowsPath().with_suffix("py")
PTH210.py:61:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
59 | PosixPath().with_suffix("py")
60 | PurePath().with_suffix("py")
61 | PurePosixPath().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
62 | PureWindowsPath().with_suffix("py")
63 | WindowsPath().with_suffix("py")
|
= help: Add a leading dot
Unsafe fix
58 58 | Path().with_suffix("py")
59 59 | PosixPath().with_suffix("py")
60 60 | PurePath().with_suffix("py")
61 |-PurePosixPath().with_suffix("py")
61 |+PurePosixPath().with_suffix(".py")
62 62 | PureWindowsPath().with_suffix("py")
63 63 | WindowsPath().with_suffix("py")
64 64 |
PTH210.py:62:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
60 | PurePath().with_suffix("py")
61 | PurePosixPath().with_suffix("py")
62 | PureWindowsPath().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
63 | WindowsPath().with_suffix("py")
|
= help: Add a leading dot
Unsafe fix
59 59 | PosixPath().with_suffix("py")
60 60 | PurePath().with_suffix("py")
61 61 | PurePosixPath().with_suffix("py")
62 |-PureWindowsPath().with_suffix("py")
62 |+PureWindowsPath().with_suffix(".py")
63 63 | WindowsPath().with_suffix("py")
64 64 |
65 65 | ### No errors
PTH210.py:63:1: PTH210 [*] Dotless suffix passed to `.with_suffix()`
|
61 | PurePosixPath().with_suffix("py")
62 | PureWindowsPath().with_suffix("py")
63 | WindowsPath().with_suffix("py")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PTH210
64 |
65 | ### No errors
|
= help: Add a leading dot
Unsafe fix
60 60 | PurePath().with_suffix("py")
61 61 | PurePosixPath().with_suffix("py")
62 62 | PureWindowsPath().with_suffix("py")
63 |-WindowsPath().with_suffix("py")
63 |+WindowsPath().with_suffix(".py")
64 64 |
65 65 | ### No errors
66 66 | path.with_suffix()

View File

@@ -103,6 +103,7 @@ full_name.py:16:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
17 | b = os.path.exists(p)
18 | bb = os.path.expanduser(p)
|
= help: Replace with `Path.cwd()`
full_name.py:17:5: PTH110 `os.path.exists()` should be replaced by `Path.exists()`
|
@@ -292,6 +293,7 @@ full_name.py:35:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
36 | os.path.join(p, *q)
37 | os.sep.join(p, *q)
|
= help: Replace with `Path.cwd()`
full_name.py:36:1: PTH118 `os.path.join()` should be replaced by `Path.joinpath()`
|
@@ -360,3 +362,21 @@ full_name.py:71:1: PTH123 `open()` should be replaced by `Path.open()`
72 |
73 | # https://github.com/astral-sh/ruff/issues/17693
|
full_name.py:108:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
|
106 | os.replace("src", "dst", dst_dir_fd=2)
107 |
108 | os.getcwd()
| ^^^^^^^^^ PTH109
109 | os.getcwdb()
|
= help: Replace with `Path.cwd()`
full_name.py:109:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
|
108 | os.getcwd()
109 | os.getcwdb()
| ^^^^^^^^^^ PTH109
|
= help: Replace with `Path.cwd()`

View File

@@ -103,6 +103,7 @@ import_as.py:16:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
17 | b = foo_p.exists(p)
18 | bb = foo_p.expanduser(p)
|
= help: Replace with `Path.cwd()`
import_as.py:17:5: PTH110 `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -103,6 +103,7 @@ import_from.py:18:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
19 | b = exists(p)
20 | bb = expanduser(p)
|
= help: Replace with `Path.cwd()`
import_from.py:19:5: PTH110 `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -103,6 +103,7 @@ import_from_as.py:23:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
24 | b = xexists(p)
25 | bb = xexpanduser(p)
|
= help: Replace with `Path.cwd()`
import_from_as.py:24:5: PTH110 `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -168,6 +168,7 @@ full_name.py:16:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
17 | b = os.path.exists(p)
18 | bb = os.path.expanduser(p)
|
= help: Replace with `Path.cwd()`
full_name.py:17:5: PTH110 [*] `os.path.exists()` should be replaced by `Path.exists()`
|
@@ -510,6 +511,7 @@ full_name.py:35:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
36 | os.path.join(p, *q)
37 | os.sep.join(p, *q)
|
= help: Replace with `Path.cwd()`
full_name.py:36:1: PTH118 `os.path.join()` should be replaced by `Path.joinpath()`
|
@@ -578,3 +580,50 @@ full_name.py:71:1: PTH123 `open()` should be replaced by `Path.open()`
72 |
73 | # https://github.com/astral-sh/ruff/issues/17693
|
full_name.py:108:1: PTH109 [*] `os.getcwd()` should be replaced by `Path.cwd()`
|
106 | os.replace("src", "dst", dst_dir_fd=2)
107 |
108 | os.getcwd()
| ^^^^^^^^^ PTH109
109 | os.getcwdb()
|
= help: Replace with `Path.cwd()`
Safe fix
1 1 | import os
2 2 | import os.path
3 |+import pathlib
3 4 |
4 5 | p = "/foo"
5 6 | q = "bar"
--------------------------------------------------------------------------------
105 106 | os.replace("src", "dst", src_dir_fd=1)
106 107 | os.replace("src", "dst", dst_dir_fd=2)
107 108 |
108 |-os.getcwd()
109 |+pathlib.Path.cwd()
109 110 | os.getcwdb()
full_name.py:109:1: PTH109 [*] `os.getcwd()` should be replaced by `Path.cwd()`
|
108 | os.getcwd()
109 | os.getcwdb()
| ^^^^^^^^^^ PTH109
|
= help: Replace with `Path.cwd()`
Safe fix
1 1 | import os
2 2 | import os.path
3 |+import pathlib
3 4 |
4 5 | p = "/foo"
5 6 | q = "bar"
--------------------------------------------------------------------------------
106 107 | os.replace("src", "dst", dst_dir_fd=2)
107 108 |
108 109 | os.getcwd()
109 |-os.getcwdb()
110 |+pathlib.Path.cwd()

View File

@@ -168,6 +168,7 @@ import_as.py:16:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
17 | b = foo_p.exists(p)
18 | bb = foo_p.expanduser(p)
|
= help: Replace with `Path.cwd()`
import_as.py:17:5: PTH110 [*] `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -172,6 +172,7 @@ import_from.py:18:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
19 | b = exists(p)
20 | bb = expanduser(p)
|
= help: Replace with `Path.cwd()`
import_from.py:19:5: PTH110 [*] `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -172,6 +172,7 @@ import_from_as.py:23:1: PTH109 `os.getcwd()` should be replaced by `Path.cwd()`
24 | b = xexists(p)
25 | bb = xexpanduser(p)
|
= help: Replace with `Path.cwd()`
import_from_as.py:24:5: PTH110 [*] `os.path.exists()` should be replaced by `Path.exists()`
|

View File

@@ -230,52 +230,6 @@ impl Violation for OsReplace {
}
}
/// ## What it does
/// Checks for uses of `os.getcwd` and `os.getcwdb`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.cwd()` can improve readability over the `os`
/// module's counterparts (e.g., `os.getcwd()`).
///
/// ## Examples
/// ```python
/// import os
///
/// cwd = os.getcwd()
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// cwd = Path.cwd()
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## References
/// - [Python documentation: `Path.cwd`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.cwd)
/// - [Python documentation: `os.getcwd`](https://docs.python.org/3/library/os.html#os.getcwd)
/// - [Python documentation: `os.getcwdb`](https://docs.python.org/3/library/os.html#os.getcwdb)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsGetcwd;
impl Violation for OsGetcwd {
#[derive_message_formats]
fn message(&self) -> String {
"`os.getcwd()` should be replaced by `Path.cwd()`".to_string()
}
}
/// ## What it does
/// Checks for uses of `os.stat`.
///

View File

@@ -801,6 +801,7 @@ mod tests {
#[test_case(Path::new("existing_import.py"))]
#[test_case(Path::new("multiline_docstring.py"))]
#[test_case(Path::new("off.py"))]
#[test_case(Path::new("whitespace.py"))]
fn required_import(path: &Path) -> Result<()> {
let snapshot = format!("required_import_{}", path.to_string_lossy());
let diagnostics = test_path(

View File

@@ -0,0 +1,11 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
whitespace.py:1:1: I002 [*] Missing required import: `from __future__ import annotations`
Safe fix
1 1 | # This is a regression test for https://github.com/astral-sh/ruff/issues/19310
2 2 | # there is a (potentially invisible) unicode formfeed character (000C) between "docstring" and the semicolon
3 |-"docstring" ; print(
3 |+"docstring" ; from __future__ import annotations; print(
4 4 | f"{__doc__=}",
5 5 | )

View File

@@ -4,6 +4,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::{self as ast, Expr};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::typing;
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
@@ -111,20 +112,34 @@ enum Callee<'a> {
}
impl<'a> Callee<'a> {
fn is_pathlib_path_call(expr: &Expr, semantic: &SemanticModel) -> bool {
if let Expr::Call(ast::ExprCall { func, .. }) = expr {
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["pathlib", "Path"])
})
} else {
false
}
}
fn try_from_call_expression(
call: &'a ast::ExprCall,
semantic: &'a SemanticModel,
) -> Option<Self> {
if let Expr::Attribute(ast::ExprAttribute { attr, value, .. }) = call.func.as_ref() {
// Check for `pathlib.Path(...).open(...)` or equivalent
if let Expr::Call(ast::ExprCall { func, .. }) = value.as_ref() {
if semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["pathlib", "Path"])
})
{
return Some(Callee::Pathlib(attr));
// Direct: Path(...).open()
if Self::is_pathlib_path_call(value, semantic) {
return Some(Callee::Pathlib(attr));
}
// Indirect: x.open() where x = Path(...)
else if let Expr::Name(name) = value.as_ref() {
if let Some(binding_id) = semantic.only_binding(name) {
let binding = semantic.binding(binding_id);
if typing::is_pathlib_path(binding, semantic) {
return Some(Callee::Pathlib(attr));
}
}
}
}

View File

@@ -435,3 +435,41 @@ unspecified_encoding.py:80:1: PLW1514 [*] `pathlib.Path(...).write_text` without
81 81 |
82 82 | # Non-errors.
83 83 | Path("foo.txt").open(encoding="utf-8")
unspecified_encoding.py:96:1: PLW1514 [*] `pathlib.Path(...).open` in text mode without explicit `encoding` argument
|
94 | # https://github.com/astral-sh/ruff/issues/19294
95 | x = Path("foo.txt")
96 | x.open()
| ^^^^^^ PLW1514
97 |
98 | # https://github.com/astral-sh/ruff/issues/18107
|
= help: Add explicit `encoding` argument
Unsafe fix
93 93 |
94 94 | # https://github.com/astral-sh/ruff/issues/19294
95 95 | x = Path("foo.txt")
96 |-x.open()
96 |+x.open(encoding="utf-8")
97 97 |
98 98 | # https://github.com/astral-sh/ruff/issues/18107
99 99 | codecs.open("plw1514.py", "r", "utf-8").close() # this is fine
unspecified_encoding.py:105:10: PLW1514 [*] `pathlib.Path(...).open` in text mode without explicit `encoding` argument
|
104 | def format_file(file: Path):
105 | with file.open() as f:
| ^^^^^^^^^ PLW1514
106 | contents = f.read()
|
= help: Add explicit `encoding` argument
Unsafe fix
102 102 | from pathlib import Path
103 103 |
104 104 | def format_file(file: Path):
105 |- with file.open() as f:
105 |+ with file.open(encoding="utf-8") as f:
106 106 | contents = f.read()

View File

@@ -136,6 +136,23 @@ mod tests {
Ok(())
}
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))]
fn up037_add_future_annotation(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("add_future_annotation_{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("pyupgrade").join(path).as_path(),
&settings::LinterSettings {
preview: PreviewMode::Enabled,
future_annotations: true,
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test]
fn async_timeout_error_alias_not_applied_py310() -> Result<()> {
let diagnostics = test_path(

View File

@@ -57,6 +57,22 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
/// bar: Bar
/// ```
///
/// ## Preview
///
/// When [preview] is enabled, if [`lint.future-annotations`] is set to `true`,
/// `from __future__ import annotations` will be added if doing so would allow an annotation to be
/// unquoted.
///
/// ## Fix safety
///
/// The rule's fix is marked as safe, unless [preview] and
/// [`lint.future_annotations`] are enabled and a `from __future__ import
/// annotations` import is added. Such an import may change the behavior of all annotations in the
/// file.
///
/// ## Options
/// - `lint.future-annotations`
///
/// ## See also
/// - [`quoted-annotation-in-stub`][PYI020]: A rule that
/// removes all quoted annotations from stub files
@@ -69,6 +85,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
///
/// [PYI020]: https://docs.astral.sh/ruff/rules/quoted-annotation-in-stub/
/// [TC008]: https://docs.astral.sh/ruff/rules/quoted-type-alias/
/// [preview]: https://docs.astral.sh/ruff/preview/
#[derive(ViolationMetadata)]
pub(crate) struct QuotedAnnotation;
@@ -85,6 +102,13 @@ impl AlwaysFixableViolation for QuotedAnnotation {
/// UP037
pub(crate) fn quoted_annotation(checker: &Checker, annotation: &str, range: TextRange) {
let add_future_import = checker.settings().future_annotations()
&& checker.semantic().in_runtime_evaluated_annotation();
if !(checker.semantic().in_typing_only_annotation() || add_future_import) {
return;
}
let placeholder_range = TextRange::up_to(annotation.text_len());
let spans_multiple_lines = annotation.contains_line_break(placeholder_range);
@@ -103,8 +127,14 @@ pub(crate) fn quoted_annotation(checker: &Checker, annotation: &str, range: Text
(true, false) => format!("({annotation})"),
(_, true) => format!("({annotation}\n)"),
};
let edit = Edit::range_replacement(new_content, range);
let fix = Fix::safe_edit(edit);
let unquote_edit = Edit::range_replacement(new_content, range);
let fix = if add_future_import {
let import_edit = checker.importer().add_future_import();
Fix::unsafe_edits(unquote_edit, [import_edit])
} else {
Fix::safe_edit(unquote_edit)
};
checker
.report_diagnostic(QuotedAnnotation, range)

View File

@@ -0,0 +1,625 @@
---
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
---
UP037_0.py:18:14: UP037 [*] Remove quotes from type annotation
|
18 | def foo(var: "MyClass") -> "MyClass":
| ^^^^^^^^^ UP037
19 | x: "MyClass"
|
= help: Remove quotes
Safe fix
15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg
16 16 |
17 17 |
18 |-def foo(var: "MyClass") -> "MyClass":
18 |+def foo(var: MyClass) -> "MyClass":
19 19 | x: "MyClass"
20 20 |
21 21 |
UP037_0.py:18:28: UP037 [*] Remove quotes from type annotation
|
18 | def foo(var: "MyClass") -> "MyClass":
| ^^^^^^^^^ UP037
19 | x: "MyClass"
|
= help: Remove quotes
Safe fix
15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg
16 16 |
17 17 |
18 |-def foo(var: "MyClass") -> "MyClass":
18 |+def foo(var: "MyClass") -> MyClass:
19 19 | x: "MyClass"
20 20 |
21 21 |
UP037_0.py:19:8: UP037 [*] Remove quotes from type annotation
|
18 | def foo(var: "MyClass") -> "MyClass":
19 | x: "MyClass"
| ^^^^^^^^^ UP037
|
= help: Remove quotes
Safe fix
16 16 |
17 17 |
18 18 | def foo(var: "MyClass") -> "MyClass":
19 |- x: "MyClass"
19 |+ x: MyClass
20 20 |
21 21 |
22 22 | def foo(*, inplace: "bool"):
UP037_0.py:22:21: UP037 [*] Remove quotes from type annotation
|
22 | def foo(*, inplace: "bool"):
| ^^^^^^ UP037
23 | pass
|
= help: Remove quotes
Safe fix
19 19 | x: "MyClass"
20 20 |
21 21 |
22 |-def foo(*, inplace: "bool"):
22 |+def foo(*, inplace: bool):
23 23 | pass
24 24 |
25 25 |
UP037_0.py:26:16: UP037 [*] Remove quotes from type annotation
|
26 | def foo(*args: "str", **kwargs: "int"):
| ^^^^^ UP037
27 | pass
|
= help: Remove quotes
Safe fix
23 23 | pass
24 24 |
25 25 |
26 |-def foo(*args: "str", **kwargs: "int"):
26 |+def foo(*args: str, **kwargs: "int"):
27 27 | pass
28 28 |
29 29 |
UP037_0.py:26:33: UP037 [*] Remove quotes from type annotation
|
26 | def foo(*args: "str", **kwargs: "int"):
| ^^^^^ UP037
27 | pass
|
= help: Remove quotes
Safe fix
23 23 | pass
24 24 |
25 25 |
26 |-def foo(*args: "str", **kwargs: "int"):
26 |+def foo(*args: "str", **kwargs: int):
27 27 | pass
28 28 |
29 29 |
UP037_0.py:30:10: UP037 [*] Remove quotes from type annotation
|
30 | x: Tuple["MyClass"]
| ^^^^^^^^^ UP037
31 |
32 | x: Callable[["MyClass"], None]
|
= help: Remove quotes
Safe fix
27 27 | pass
28 28 |
29 29 |
30 |-x: Tuple["MyClass"]
30 |+x: Tuple[MyClass]
31 31 |
32 32 | x: Callable[["MyClass"], None]
33 33 |
UP037_0.py:32:14: UP037 [*] Remove quotes from type annotation
|
30 | x: Tuple["MyClass"]
31 |
32 | x: Callable[["MyClass"], None]
| ^^^^^^^^^ UP037
|
= help: Remove quotes
Safe fix
29 29 |
30 30 | x: Tuple["MyClass"]
31 31 |
32 |-x: Callable[["MyClass"], None]
32 |+x: Callable[[MyClass], None]
33 33 |
34 34 |
35 35 | class Foo(NamedTuple):
UP037_0.py:36:8: UP037 [*] Remove quotes from type annotation
|
35 | class Foo(NamedTuple):
36 | x: "MyClass"
| ^^^^^^^^^ UP037
|
= help: Remove quotes
Safe fix
33 33 |
34 34 |
35 35 | class Foo(NamedTuple):
36 |- x: "MyClass"
36 |+ x: MyClass
37 37 |
38 38 |
39 39 | class D(TypedDict):
UP037_0.py:40:27: UP037 [*] Remove quotes from type annotation
|
39 | class D(TypedDict):
40 | E: TypedDict("E", foo="int", total=False)
| ^^^^^ UP037
|
= help: Remove quotes
Safe fix
37 37 |
38 38 |
39 39 | class D(TypedDict):
40 |- E: TypedDict("E", foo="int", total=False)
40 |+ E: TypedDict("E", foo=int, total=False)
41 41 |
42 42 |
43 43 | class D(TypedDict):
UP037_0.py:44:31: UP037 [*] Remove quotes from type annotation
|
43 | class D(TypedDict):
44 | E: TypedDict("E", {"foo": "int"})
| ^^^^^ UP037
|
= help: Remove quotes
Safe fix
41 41 |
42 42 |
43 43 | class D(TypedDict):
44 |- E: TypedDict("E", {"foo": "int"})
44 |+ E: TypedDict("E", {"foo": int})
45 45 |
46 46 |
47 47 | x: Annotated["str", "metadata"]
UP037_0.py:47:14: UP037 [*] Remove quotes from type annotation
|
47 | x: Annotated["str", "metadata"]
| ^^^^^ UP037
48 |
49 | x: Arg("str", "name")
|
= help: Remove quotes
Safe fix
44 44 | E: TypedDict("E", {"foo": "int"})
45 45 |
46 46 |
47 |-x: Annotated["str", "metadata"]
47 |+x: Annotated[str, "metadata"]
48 48 |
49 49 | x: Arg("str", "name")
50 50 |
UP037_0.py:49:8: UP037 [*] Remove quotes from type annotation
|
47 | x: Annotated["str", "metadata"]
48 |
49 | x: Arg("str", "name")
| ^^^^^ UP037
50 |
51 | x: DefaultArg("str", "name")
|
= help: Remove quotes
Safe fix
46 46 |
47 47 | x: Annotated["str", "metadata"]
48 48 |
49 |-x: Arg("str", "name")
49 |+x: Arg(str, "name")
50 50 |
51 51 | x: DefaultArg("str", "name")
52 52 |
UP037_0.py:51:15: UP037 [*] Remove quotes from type annotation
|
49 | x: Arg("str", "name")
50 |
51 | x: DefaultArg("str", "name")
| ^^^^^ UP037
52 |
53 | x: NamedArg("str", "name")
|
= help: Remove quotes
Safe fix
48 48 |
49 49 | x: Arg("str", "name")
50 50 |
51 |-x: DefaultArg("str", "name")
51 |+x: DefaultArg(str, "name")
52 52 |
53 53 | x: NamedArg("str", "name")
54 54 |
UP037_0.py:53:13: UP037 [*] Remove quotes from type annotation
|
51 | x: DefaultArg("str", "name")
52 |
53 | x: NamedArg("str", "name")
| ^^^^^ UP037
54 |
55 | x: DefaultNamedArg("str", "name")
|
= help: Remove quotes
Safe fix
50 50 |
51 51 | x: DefaultArg("str", "name")
52 52 |
53 |-x: NamedArg("str", "name")
53 |+x: NamedArg(str, "name")
54 54 |
55 55 | x: DefaultNamedArg("str", "name")
56 56 |
UP037_0.py:55:20: UP037 [*] Remove quotes from type annotation
|
53 | x: NamedArg("str", "name")
54 |
55 | x: DefaultNamedArg("str", "name")
| ^^^^^ UP037
56 |
57 | x: DefaultNamedArg("str", name="name")
|
= help: Remove quotes
Safe fix
52 52 |
53 53 | x: NamedArg("str", "name")
54 54 |
55 |-x: DefaultNamedArg("str", "name")
55 |+x: DefaultNamedArg(str, "name")
56 56 |
57 57 | x: DefaultNamedArg("str", name="name")
58 58 |
UP037_0.py:57:20: UP037 [*] Remove quotes from type annotation
|
55 | x: DefaultNamedArg("str", "name")
56 |
57 | x: DefaultNamedArg("str", name="name")
| ^^^^^ UP037
58 |
59 | x: VarArg("str")
|
= help: Remove quotes
Safe fix
54 54 |
55 55 | x: DefaultNamedArg("str", "name")
56 56 |
57 |-x: DefaultNamedArg("str", name="name")
57 |+x: DefaultNamedArg(str, name="name")
58 58 |
59 59 | x: VarArg("str")
60 60 |
UP037_0.py:59:11: UP037 [*] Remove quotes from type annotation
|
57 | x: DefaultNamedArg("str", name="name")
58 |
59 | x: VarArg("str")
| ^^^^^ UP037
60 |
61 | x: List[List[List["MyClass"]]]
|
= help: Remove quotes
Safe fix
56 56 |
57 57 | x: DefaultNamedArg("str", name="name")
58 58 |
59 |-x: VarArg("str")
59 |+x: VarArg(str)
60 60 |
61 61 | x: List[List[List["MyClass"]]]
62 62 |
UP037_0.py:61:19: UP037 [*] Remove quotes from type annotation
|
59 | x: VarArg("str")
60 |
61 | x: List[List[List["MyClass"]]]
| ^^^^^^^^^ UP037
62 |
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
|
= help: Remove quotes
Safe fix
58 58 |
59 59 | x: VarArg("str")
60 60 |
61 |-x: List[List[List["MyClass"]]]
61 |+x: List[List[List[MyClass]]]
62 62 |
63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 64 |
UP037_0.py:63:29: UP037 [*] Remove quotes from type annotation
|
61 | x: List[List[List["MyClass"]]]
62 |
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
|
= help: Remove quotes
Safe fix
60 60 |
61 61 | x: List[List[List["MyClass"]]]
62 62 |
63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
63 |+x: NamedTuple("X", [("foo", int), ("bar", "str")])
64 64 |
65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 66 |
UP037_0.py:63:45: UP037 [*] Remove quotes from type annotation
|
61 | x: List[List[List["MyClass"]]]
62 |
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
|
= help: Remove quotes
Safe fix
60 60 |
61 61 | x: List[List[List["MyClass"]]]
62 62 |
63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
63 |+x: NamedTuple("X", [("foo", "int"), ("bar", str)])
64 64 |
65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 66 |
UP037_0.py:65:29: UP037 [*] Remove quotes from type annotation
|
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
|
= help: Remove quotes
Safe fix
62 62 |
63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 64 |
65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
65 |+x: NamedTuple("X", fields=[(foo, "int"), ("bar", "str")])
66 66 |
67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
68 68 |
UP037_0.py:65:36: UP037 [*] Remove quotes from type annotation
|
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
|
= help: Remove quotes
Safe fix
62 62 |
63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 64 |
65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
65 |+x: NamedTuple("X", fields=[("foo", int), ("bar", "str")])
66 66 |
67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
68 68 |
UP037_0.py:65:45: UP037 [*] Remove quotes from type annotation
|
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
|
= help: Remove quotes
Safe fix
62 62 |
63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 64 |
65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
65 |+x: NamedTuple("X", fields=[("foo", "int"), (bar, "str")])
66 66 |
67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
68 68 |
UP037_0.py:65:52: UP037 [*] Remove quotes from type annotation
|
63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 |
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
| ^^^^^ UP037
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
|
= help: Remove quotes
Safe fix
62 62 |
63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")])
64 64 |
65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
65 |+x: NamedTuple("X", fields=[("foo", "int"), ("bar", str)])
66 66 |
67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
68 68 |
UP037_0.py:67:24: UP037 [*] Remove quotes from type annotation
|
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
| ^^^ UP037
68 |
69 | X: MyCallable("X")
|
= help: Remove quotes
Safe fix
64 64 |
65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 66 |
67 |-x: NamedTuple(typename="X", fields=[("foo", "int")])
67 |+x: NamedTuple(typename=X, fields=[("foo", "int")])
68 68 |
69 69 | X: MyCallable("X")
70 70 |
UP037_0.py:67:38: UP037 [*] Remove quotes from type annotation
|
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
| ^^^^^ UP037
68 |
69 | X: MyCallable("X")
|
= help: Remove quotes
Safe fix
64 64 |
65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 66 |
67 |-x: NamedTuple(typename="X", fields=[("foo", "int")])
67 |+x: NamedTuple(typename="X", fields=[(foo, "int")])
68 68 |
69 69 | X: MyCallable("X")
70 70 |
UP037_0.py:67:45: UP037 [*] Remove quotes from type annotation
|
65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 |
67 | x: NamedTuple(typename="X", fields=[("foo", "int")])
| ^^^^^ UP037
68 |
69 | X: MyCallable("X")
|
= help: Remove quotes
Safe fix
64 64 |
65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")])
66 66 |
67 |-x: NamedTuple(typename="X", fields=[("foo", "int")])
67 |+x: NamedTuple(typename="X", fields=[("foo", int)])
68 68 |
69 69 | X: MyCallable("X")
70 70 |
UP037_0.py:112:12: UP037 [*] Remove quotes from type annotation
|
110 | # Handle end of line comment in string annotation
111 | # See https://github.com/astral-sh/ruff/issues/15816
112 | def f() -> "Literal[0]#":
| ^^^^^^^^^^^^^ UP037
113 | return 0
|
= help: Remove quotes
Safe fix
109 109 |
110 110 | # Handle end of line comment in string annotation
111 111 | # See https://github.com/astral-sh/ruff/issues/15816
112 |-def f() -> "Literal[0]#":
112 |+def f() -> (Literal[0]#
113 |+):
113 114 | return 0
114 115 |
115 116 | def g(x: "Literal['abc']#") -> None:
UP037_0.py:115:10: UP037 [*] Remove quotes from type annotation
|
113 | return 0
114 |
115 | def g(x: "Literal['abc']#") -> None:
| ^^^^^^^^^^^^^^^^^ UP037
116 | return
|
= help: Remove quotes
Safe fix
112 112 | def f() -> "Literal[0]#":
113 113 | return 0
114 114 |
115 |-def g(x: "Literal['abc']#") -> None:
115 |+def g(x: (Literal['abc']#
116 |+)) -> None:
116 117 | return
117 118 |
118 119 | def f() -> """Literal[0]
UP037_0.py:118:12: UP037 [*] Remove quotes from type annotation
|
116 | return
117 |
118 | def f() -> """Literal[0]
| ____________^
119 | | #
120 | |
121 | | """:
| |_______^ UP037
122 | return 0
|
= help: Remove quotes
Safe fix
115 115 | def g(x: "Literal['abc']#") -> None:
116 116 | return
117 117 |
118 |-def f() -> """Literal[0]
118 |+def f() -> (Literal[0]
119 119 | #
120 120 |
121 |- """:
121 |+ ):
122 122 | return 0

View File

@@ -0,0 +1,42 @@
---
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
---
UP037_1.py:9:8: UP037 [*] Remove quotes from type annotation
|
7 | def foo():
8 | # UP037
9 | x: "Tuple[int, int]" = (0, 0)
| ^^^^^^^^^^^^^^^^^ UP037
10 | print(x)
|
= help: Remove quotes
Safe fix
6 6 |
7 7 | def foo():
8 8 | # UP037
9 |- x: "Tuple[int, int]" = (0, 0)
9 |+ x: Tuple[int, int] = (0, 0)
10 10 | print(x)
11 11 |
12 12 |
UP037_1.py:14:4: UP037 [*] Remove quotes from type annotation
|
13 | # OK
14 | X: "Tuple[int, int]" = (0, 0)
| ^^^^^^^^^^^^^^^^^ UP037
|
= help: Remove quotes
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import TYPE_CHECKING
2 3 |
3 4 | if TYPE_CHECKING:
--------------------------------------------------------------------------------
11 12 |
12 13 |
13 14 | # OK
14 |-X: "Tuple[int, int]" = (0, 0)
15 |+X: Tuple[int, int] = (0, 0)

View File

@@ -0,0 +1,232 @@
---
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
---
UP037_2.pyi:3:14: UP037 [*] Remove quotes from type annotation
|
1 | # https://github.com/astral-sh/ruff/issues/7102
2 |
3 | def f(a: Foo['SingleLine # Comment']): ...
| ^^^^^^^^^^^^^^^^^^^^^^^ UP037
|
= help: Remove quotes
Safe fix
1 1 | # https://github.com/astral-sh/ruff/issues/7102
2 2 |
3 |-def f(a: Foo['SingleLine # Comment']): ...
3 |+def f(a: Foo[(SingleLine # Comment
4 |+)]): ...
4 5 |
5 6 |
6 7 | def f(a: Foo['''Bar[
UP037_2.pyi:6:14: UP037 [*] Remove quotes from type annotation
|
6 | def f(a: Foo['''Bar[
| ______________^
7 | | Multi |
8 | | Line]''']): ...
| |____________^ UP037
|
= help: Remove quotes
Safe fix
3 3 | def f(a: Foo['SingleLine # Comment']): ...
4 4 |
5 5 |
6 |-def f(a: Foo['''Bar[
6 |+def f(a: Foo[Bar[
7 7 | Multi |
8 |- Line]''']): ...
8 |+ Line]]): ...
9 9 |
10 10 |
11 11 | def f(a: Foo['''Bar[
UP037_2.pyi:11:14: UP037 [*] Remove quotes from type annotation
|
11 | def f(a: Foo['''Bar[
| ______________^
12 | | Multi |
13 | | Line # Comment
14 | | ]''']): ...
| |____^ UP037
|
= help: Remove quotes
Safe fix
8 8 | Line]''']): ...
9 9 |
10 10 |
11 |-def f(a: Foo['''Bar[
11 |+def f(a: Foo[Bar[
12 12 | Multi |
13 13 | Line # Comment
14 |-]''']): ...
14 |+]]): ...
15 15 |
16 16 |
17 17 | def f(a: Foo['''Bar[
UP037_2.pyi:17:14: UP037 [*] Remove quotes from type annotation
|
17 | def f(a: Foo['''Bar[
| ______________^
18 | | Multi |
19 | | Line] # Comment''']): ...
| |_______________________^ UP037
|
= help: Remove quotes
Safe fix
14 14 | ]''']): ...
15 15 |
16 16 |
17 |-def f(a: Foo['''Bar[
17 |+def f(a: Foo[(Bar[
18 18 | Multi |
19 |- Line] # Comment''']): ...
19 |+ Line] # Comment
20 |+)]): ...
20 21 |
21 22 |
22 23 | def f(a: Foo['''
UP037_2.pyi:22:14: UP037 [*] Remove quotes from type annotation
|
22 | def f(a: Foo['''
| ______________^
23 | | Bar[
24 | | Multi |
25 | | Line] # Comment''']): ...
| |_______________________^ UP037
|
= help: Remove quotes
Safe fix
19 19 | Line] # Comment''']): ...
20 20 |
21 21 |
22 |-def f(a: Foo['''
22 |+def f(a: Foo[(
23 23 | Bar[
24 24 | Multi |
25 |- Line] # Comment''']): ...
25 |+ Line] # Comment
26 |+)]): ...
26 27 |
27 28 |
28 29 | def f(a: '''list[int]
UP037_2.pyi:28:10: UP037 [*] Remove quotes from type annotation
|
28 | def f(a: '''list[int]
| __________^
29 | | ''' = []): ...
| |_______^ UP037
|
= help: Remove quotes
Safe fix
25 25 | Line] # Comment''']): ...
26 26 |
27 27 |
28 |-def f(a: '''list[int]
29 |- ''' = []): ...
28 |+def f(a: list[int]
29 |+ = []): ...
30 30 |
31 31 |
32 32 | a: '''\\
UP037_2.pyi:32:4: UP037 [*] Remove quotes from type annotation
|
32 | a: '''\\
| ____^
33 | | list[int]''' = [42]
| |____________^ UP037
|
= help: Remove quotes
Safe fix
29 29 | ''' = []): ...
30 30 |
31 31 |
32 |-a: '''\\
33 |-list[int]''' = [42]
32 |+a: (\
33 |+list[int]) = [42]
34 34 |
35 35 |
36 36 | def f(a: '''
UP037_2.pyi:36:10: UP037 [*] Remove quotes from type annotation
|
36 | def f(a: '''
| __________^
37 | | list[int]
38 | | ''' = []): ...
| |_______^ UP037
|
= help: Remove quotes
Safe fix
33 33 | list[int]''' = [42]
34 34 |
35 35 |
36 |-def f(a: '''
36 |+def f(a:
37 37 | list[int]
38 |- ''' = []): ...
38 |+ = []): ...
39 39 |
40 40 |
41 41 | def f(a: Foo['''
UP037_2.pyi:41:14: UP037 [*] Remove quotes from type annotation
|
41 | def f(a: Foo['''
| ______________^
42 | | Bar
43 | | [
44 | | Multi |
45 | | Line
46 | | ] # Comment''']): ...
| |___________________^ UP037
|
= help: Remove quotes
Safe fix
38 38 | ''' = []): ...
39 39 |
40 40 |
41 |-def f(a: Foo['''
41 |+def f(a: Foo[(
42 42 | Bar
43 43 | [
44 44 | Multi |
45 45 | Line
46 |- ] # Comment''']): ...
46 |+ ] # Comment
47 |+)]): ...
47 48 |
48 49 |
49 50 | a: '''list
UP037_2.pyi:49:4: UP037 [*] Remove quotes from type annotation
|
49 | a: '''list
| ____^
50 | | [int]''' = [42]
| |________^ UP037
|
= help: Remove quotes
Safe fix
46 46 | ] # Comment''']): ...
47 47 |
48 48 |
49 |-a: '''list
50 |-[int]''' = [42]
49 |+a: (list
50 |+[int]) = [42]

View File

@@ -1,10 +1,12 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Expr, ExprCall};
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
use ruff_python_semantic::analyze::typing;
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::linter::float::as_non_finite_float_string_literal;
use crate::{Edit, Fix, FixAvailability, Violation};
use crate::{Applicability, Edit, Fix, FixAvailability, Violation};
/// ## What it does
/// Checks for unnecessary `from_float` and `from_decimal` usages to construct
@@ -16,6 +18,12 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// the use of `from_float` and `from_decimal` methods is unnecessary, and
/// should be avoided in favor of the more concise constructor syntax.
///
/// However, there are important behavioral differences between the `from_*` methods
/// and the constructors:
/// - The `from_*` methods validate their argument types and raise `TypeError` for invalid types
/// - The constructors accept broader argument types without validation
/// - The `from_*` methods have different parameter names than the constructors
///
/// ## Example
/// ```python
/// Decimal.from_float(4.2)
@@ -32,6 +40,16 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// Fraction(Decimal(4.2))
/// ```
///
/// ## Fix safety
/// This rule's fix is marked as unsafe by default because:
/// - The `from_*` methods provide type validation that the constructors don't
/// - Removing type validation can change program behavior
/// - The parameter names are different between methods and constructors
///
/// The fix is marked as safe only when:
/// - The argument type is known to be valid for the target constructor
/// - No keyword arguments are used, or they match the constructor's parameters
///
/// ## References
/// - [Python documentation: `decimal`](https://docs.python.org/3/library/decimal.html)
/// - [Python documentation: `fractions`](https://docs.python.org/3/library/fractions.html)
@@ -101,62 +119,178 @@ pub(crate) fn unnecessary_from_float(checker: &Checker, call: &ExprCall) {
call.range(),
);
let edit = Edit::range_replacement(
checker.locator().slice(&**value).to_string(),
call.func.range(),
);
// Validate that the method call has correct arguments and get the argument value
let Some(arg_value) = has_valid_method_arguments(call, method_name, constructor) else {
// Don't suggest a fix for invalid calls
return;
};
// Short-circuit case for special values, such as: `Decimal.from_float(float("inf"))` to `Decimal("inf")`.
'short_circuit: {
if !matches!(constructor, Constructor::Decimal) {
break 'short_circuit;
}
if !(method_name == MethodName::FromFloat) {
break 'short_circuit;
}
let Some(value) = (match method_name {
MethodName::FromFloat => call.arguments.find_argument_value("f", 0),
MethodName::FromDecimal => call.arguments.find_argument_value("dec", 0),
}) else {
return;
};
let Expr::Call(
call @ ast::ExprCall {
func, arguments, ..
},
) = value
else {
break 'short_circuit;
};
// Must have exactly one argument, which is a string literal.
if !arguments.keywords.is_empty() {
break 'short_circuit;
}
let [float] = arguments.args.as_ref() else {
break 'short_circuit;
};
if as_non_finite_float_string_literal(float).is_none() {
break 'short_circuit;
}
// Must be a call to the `float` builtin.
if !semantic.match_builtin_expr(func, "float") {
break 'short_circuit;
}
let replacement = checker.locator().slice(float).to_string();
diagnostic.set_fix(Fix::safe_edits(
edit,
[Edit::range_replacement(replacement, call.range())],
));
let constructor_name = checker.locator().slice(&**value).to_string();
// Special case for non-finite float literals: Decimal.from_float(float("inf")) -> Decimal("inf")
if let Some(replacement) = handle_non_finite_float_special_case(
call,
method_name,
constructor,
arg_value,
&constructor_name,
checker,
) {
diagnostic.set_fix(Fix::safe_edit(replacement));
return;
}
diagnostic.set_fix(Fix::safe_edit(edit));
// Check if we should suppress the fix due to type validation concerns
let is_type_safe = is_valid_argument_type(arg_value, method_name, constructor, checker);
let has_keywords = !call.arguments.keywords.is_empty();
// Determine fix safety
let applicability = if is_type_safe && !has_keywords {
Applicability::Safe
} else {
Applicability::Unsafe
};
// Build the replacement
let arg_text = checker.locator().slice(arg_value);
let replacement_text = format!("{constructor_name}({arg_text})");
let edit = Edit::range_replacement(replacement_text, call.range());
diagnostic.set_fix(Fix::applicable_edit(edit, applicability));
}
/// Check if the argument would be valid for the target constructor
fn is_valid_argument_type(
arg_expr: &Expr,
method_name: MethodName,
constructor: Constructor,
checker: &Checker,
) -> bool {
let semantic = checker.semantic();
let resolved_type = ResolvedPythonType::from(arg_expr);
let (is_int, is_float) = if let ResolvedPythonType::Unknown = resolved_type {
arg_expr
.as_name_expr()
.and_then(|name| semantic.only_binding(name).map(|id| semantic.binding(id)))
.map(|binding| {
(
typing::is_int(binding, semantic),
typing::is_float(binding, semantic),
)
})
.unwrap_or_default()
} else {
(false, false)
};
match (method_name, constructor) {
// Decimal.from_float accepts int, bool, float
(MethodName::FromFloat, Constructor::Decimal) => match resolved_type {
ResolvedPythonType::Atom(PythonType::Number(
NumberLike::Integer | NumberLike::Bool | NumberLike::Float,
)) => true,
ResolvedPythonType::Unknown => is_int || is_float,
_ => false,
},
// Fraction.from_float accepts int, bool, float
(MethodName::FromFloat, Constructor::Fraction) => match resolved_type {
ResolvedPythonType::Atom(PythonType::Number(
NumberLike::Integer | NumberLike::Bool | NumberLike::Float,
)) => true,
ResolvedPythonType::Unknown => is_int || is_float,
_ => false,
},
// Fraction.from_decimal accepts int, bool, Decimal
(MethodName::FromDecimal, Constructor::Fraction) => match resolved_type {
ResolvedPythonType::Atom(PythonType::Number(
NumberLike::Integer | NumberLike::Bool,
)) => true,
ResolvedPythonType::Unknown => is_int,
_ => {
// Check if it's a Decimal instance
arg_expr
.as_call_expr()
.and_then(|call| semantic.resolve_qualified_name(&call.func))
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["decimal", "Decimal"])
})
}
},
_ => false,
}
}
/// Check if the call has valid arguments for the from_* method
fn has_valid_method_arguments(
call: &ExprCall,
method_name: MethodName,
constructor: Constructor,
) -> Option<&Expr> {
if call.arguments.len() != 1 {
return None;
}
match method_name {
MethodName::FromFloat => {
// Decimal.from_float is positional-only; Fraction.from_float allows keyword 'f'.
if constructor == Constructor::Decimal {
// Only allow positional argument for Decimal.from_float
call.arguments.find_positional(0)
} else {
// Fraction.from_float allows either positional or 'f' keyword
call.arguments.find_argument_value("f", 0)
}
}
MethodName::FromDecimal => {
// from_decimal(dec) - should have exactly one positional argument or 'dec' keyword
call.arguments.find_argument_value("dec", 0)
}
}
}
/// Handle the special case for non-finite float literals
fn handle_non_finite_float_special_case(
call: &ExprCall,
method_name: MethodName,
constructor: Constructor,
arg_value: &Expr,
constructor_name: &str,
checker: &Checker,
) -> Option<Edit> {
// Only applies to Decimal.from_float
if !matches!(
(method_name, constructor),
(MethodName::FromFloat, Constructor::Decimal)
) {
return None;
}
let Expr::Call(ast::ExprCall {
func, arguments, ..
}) = arg_value
else {
return None;
};
// Must be a call to the `float` builtin.
if !checker.semantic().match_builtin_expr(func, "float") {
return None;
}
// Must have exactly one argument, which is a string literal.
if !arguments.keywords.is_empty() {
return None;
}
let [float_arg] = arguments.args.as_ref() else {
return None;
};
as_non_finite_float_string_literal(float_arg)?;
let replacement_arg = checker.locator().slice(float_arg).to_string();
let replacement_text = format!("{constructor_name}({replacement_arg})");
Some(Edit::range_replacement(replacement_text, call.range()))
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]

View File

@@ -95,7 +95,7 @@ FURB164.py:11:5: FURB164 [*] Verbose method `from_decimal` in `Fraction` constru
|
= help: Replace with `Fraction` constructor
Safe fix
Unsafe fix
8 8 | _ = Fraction.from_float(-0.5)
9 9 | _ = Fraction.from_float(5.0)
10 10 | _ = fractions.Fraction.from_float(4.2)
@@ -116,7 +116,7 @@ FURB164.py:12:5: FURB164 [*] Verbose method `from_decimal` in `Fraction` constru
|
= help: Replace with `Fraction` constructor
Safe fix
Unsafe fix
9 9 | _ = Fraction.from_float(5.0)
10 10 | _ = fractions.Fraction.from_float(4.2)
11 11 | _ = Fraction.from_decimal(Decimal("4.2"))
@@ -137,7 +137,7 @@ FURB164.py:13:5: FURB164 [*] Verbose method `from_decimal` in `Fraction` constru
|
= help: Replace with `Fraction` constructor
Safe fix
Unsafe fix
10 10 | _ = fractions.Fraction.from_float(4.2)
11 11 | _ = Fraction.from_decimal(Decimal("4.2"))
12 12 | _ = Fraction.from_decimal(Decimal("-4.2"))
@@ -459,7 +459,7 @@ FURB164.py:27:5: FURB164 [*] Verbose method `from_float` in `Decimal` constructi
27 |+_ = Decimal(" InfinIty\n\t ")
28 28 | _ = Decimal.from_float(float(" -InfinIty\n \t"))
29 29 |
30 30 | # OK
30 30 | # Cases with keyword arguments - should produce unsafe fixes
FURB164.py:28:5: FURB164 [*] Verbose method `from_float` in `Decimal` construction
|
@@ -468,7 +468,7 @@ FURB164.py:28:5: FURB164 [*] Verbose method `from_float` in `Decimal` constructi
28 | _ = Decimal.from_float(float(" -InfinIty\n \t"))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
29 |
30 | # OK
30 | # Cases with keyword arguments - should produce unsafe fixes
|
= help: Replace with `Decimal` constructor
@@ -479,5 +479,136 @@ FURB164.py:28:5: FURB164 [*] Verbose method `from_float` in `Decimal` constructi
28 |-_ = Decimal.from_float(float(" -InfinIty\n \t"))
28 |+_ = Decimal(" -InfinIty\n \t")
29 29 |
30 30 | # OK
31 31 | _ = Fraction(0.1)
30 30 | # Cases with keyword arguments - should produce unsafe fixes
31 31 | _ = Fraction.from_decimal(dec=Decimal("4.2"))
FURB164.py:31:5: FURB164 [*] Verbose method `from_decimal` in `Fraction` construction
|
30 | # Cases with keyword arguments - should produce unsafe fixes
31 | _ = Fraction.from_decimal(dec=Decimal("4.2"))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
32 | _ = Decimal.from_float(f=4.2)
|
= help: Replace with `Fraction` constructor
Unsafe fix
28 28 | _ = Decimal.from_float(float(" -InfinIty\n \t"))
29 29 |
30 30 | # Cases with keyword arguments - should produce unsafe fixes
31 |-_ = Fraction.from_decimal(dec=Decimal("4.2"))
31 |+_ = Fraction(Decimal("4.2"))
32 32 | _ = Decimal.from_float(f=4.2)
33 33 |
34 34 | # Cases with invalid argument counts - should not get fixes
FURB164.py:32:5: FURB164 Verbose method `from_float` in `Decimal` construction
|
30 | # Cases with keyword arguments - should produce unsafe fixes
31 | _ = Fraction.from_decimal(dec=Decimal("4.2"))
32 | _ = Decimal.from_float(f=4.2)
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
33 |
34 | # Cases with invalid argument counts - should not get fixes
|
= help: Replace with `Decimal` constructor
FURB164.py:35:5: FURB164 Verbose method `from_decimal` in `Fraction` construction
|
34 | # Cases with invalid argument counts - should not get fixes
35 | _ = Fraction.from_decimal(Decimal("4.2"), 1)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
36 | _ = Decimal.from_float(4.2, None)
|
= help: Replace with `Fraction` constructor
FURB164.py:36:5: FURB164 Verbose method `from_float` in `Decimal` construction
|
34 | # Cases with invalid argument counts - should not get fixes
35 | _ = Fraction.from_decimal(Decimal("4.2"), 1)
36 | _ = Decimal.from_float(4.2, None)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
37 |
38 | # Cases with wrong keyword arguments - should not get fixes
|
= help: Replace with `Decimal` constructor
FURB164.py:39:5: FURB164 Verbose method `from_decimal` in `Fraction` construction
|
38 | # Cases with wrong keyword arguments - should not get fixes
39 | _ = Fraction.from_decimal(numerator=Decimal("4.2"))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
40 | _ = Decimal.from_float(value=4.2)
|
= help: Replace with `Fraction` constructor
FURB164.py:40:5: FURB164 Verbose method `from_float` in `Decimal` construction
|
38 | # Cases with wrong keyword arguments - should not get fixes
39 | _ = Fraction.from_decimal(numerator=Decimal("4.2"))
40 | _ = Decimal.from_float(value=4.2)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
41 |
42 | # Cases with type validation issues - should produce unsafe fixes
|
= help: Replace with `Decimal` constructor
FURB164.py:43:5: FURB164 [*] Verbose method `from_float` in `Decimal` construction
|
42 | # Cases with type validation issues - should produce unsafe fixes
43 | _ = Decimal.from_float("4.2") # Invalid type for from_float
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
44 | _ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
45 | _ = Fraction.from_float("4.2") # Invalid type for from_float
|
= help: Replace with `Decimal` constructor
Unsafe fix
40 40 | _ = Decimal.from_float(value=4.2)
41 41 |
42 42 | # Cases with type validation issues - should produce unsafe fixes
43 |-_ = Decimal.from_float("4.2") # Invalid type for from_float
43 |+_ = Decimal("4.2") # Invalid type for from_float
44 44 | _ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
45 45 | _ = Fraction.from_float("4.2") # Invalid type for from_float
46 46 |
FURB164.py:44:5: FURB164 [*] Verbose method `from_decimal` in `Fraction` construction
|
42 | # Cases with type validation issues - should produce unsafe fixes
43 | _ = Decimal.from_float("4.2") # Invalid type for from_float
44 | _ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
45 | _ = Fraction.from_float("4.2") # Invalid type for from_float
|
= help: Replace with `Fraction` constructor
Unsafe fix
41 41 |
42 42 | # Cases with type validation issues - should produce unsafe fixes
43 43 | _ = Decimal.from_float("4.2") # Invalid type for from_float
44 |-_ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
44 |+_ = Fraction(4.2) # Invalid type for from_decimal
45 45 | _ = Fraction.from_float("4.2") # Invalid type for from_float
46 46 |
47 47 | # OK - should not trigger the rule
FURB164.py:45:5: FURB164 [*] Verbose method `from_float` in `Fraction` construction
|
43 | _ = Decimal.from_float("4.2") # Invalid type for from_float
44 | _ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
45 | _ = Fraction.from_float("4.2") # Invalid type for from_float
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB164
46 |
47 | # OK - should not trigger the rule
|
= help: Replace with `Fraction` constructor
Unsafe fix
42 42 | # Cases with type validation issues - should produce unsafe fixes
43 43 | _ = Decimal.from_float("4.2") # Invalid type for from_float
44 44 | _ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
45 |-_ = Fraction.from_float("4.2") # Invalid type for from_float
45 |+_ = Fraction("4.2") # Invalid type for from_float
46 46 |
47 47 | # OK - should not trigger the rule
48 48 | _ = Fraction(0.1)

View File

@@ -534,6 +534,7 @@ mod tests {
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_0.py"))]
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_1.py"))]
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_2.py"))]
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_3.py"))]
#[test_case(Rule::PytestRaisesAmbiguousPattern, Path::new("RUF043.py"))]
#[test_case(Rule::IndentedFormFeed, Path::new("RUF054.py"))]
#[test_case(Rule::ImplicitClassVarInDataclass, Path::new("RUF045.py"))]
@@ -599,4 +600,24 @@ mod tests {
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_0.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_1.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_2.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_3.py"))]
#[test_case(Rule::ImplicitOptional, Path::new("RUF013_4.py"))]
fn ruf013_add_future_import(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("add_future_import_{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("ruff").join(path).as_path(),
&settings::LinterSettings {
preview: PreviewMode::Enabled,
future_annotations: true,
unresolved_target_version: PythonVersion::PY39.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
}

View File

@@ -71,6 +71,13 @@ use crate::rules::ruff::typing::type_hint_explicitly_allows_none;
///
/// ## Options
/// - `target-version`
/// - `lint.future-annotations`
///
/// ## Preview
///
/// When [preview] is enabled, if [`lint.future-annotations`] is set to `true`,
/// `from __future__ import annotations` will be added if doing so would allow using the `|`
/// operator on a Python version before 3.10.
///
/// ## Fix safety
///
@@ -136,10 +143,15 @@ fn generate_fix(checker: &Checker, conversion_type: ConversionType, expr: &Expr)
node_index: ruff_python_ast::AtomicNodeIndex::dummy(),
});
let content = checker.generator().expr(&new_expr);
Ok(Fix::unsafe_edit(Edit::range_replacement(
content,
expr.range(),
)))
let edit = Edit::range_replacement(content, expr.range());
if checker.target_version() < PythonVersion::PY310 {
Ok(Fix::unsafe_edits(
edit,
[checker.importer().add_future_import()],
))
} else {
Ok(Fix::unsafe_edit(edit))
}
}
ConversionType::Optional => {
let importer = checker
@@ -187,6 +199,7 @@ pub(crate) fn implicit_optional(checker: &Checker, parameters: &Parameters) {
) else {
continue;
};
let conversion_type = checker.target_version().into();
let mut diagnostic =
@@ -202,7 +215,14 @@ pub(crate) fn implicit_optional(checker: &Checker, parameters: &Parameters) {
else {
continue;
};
let conversion_type = checker.target_version().into();
let conversion_type = if checker.target_version() >= PythonVersion::PY310
|| checker.settings().future_annotations()
{
ConversionType::BinOpOr
} else {
ConversionType::Optional
};
let mut diagnostic =
checker.report_diagnostic(ImplicitOptional { conversion_type }, expr.range());

View File

@@ -1,8 +1,8 @@
use itertools::Itertools;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{
Arguments, CmpOp, Expr, ExprAttribute, ExprCall, ExprCompare, ExprContext, ExprStringLiteral,
ExprUnaryOp, Identifier, UnaryOp,
Arguments, CmpOp, Expr, ExprAttribute, ExprBytesLiteral, ExprCall, ExprCompare, ExprContext,
ExprStringLiteral, ExprUnaryOp, Identifier, UnaryOp,
};
use ruff_python_semantic::analyze::typing::find_binding_value;
use ruff_python_semantic::{Modules, SemanticModel};
@@ -72,6 +72,9 @@ impl Violation for UnnecessaryRegularExpression {
}
}
const METACHARACTERS: [char; 12] = ['.', '^', '$', '*', '+', '?', '{', '[', '\\', '|', '(', ')'];
const ESCAPABLE_SINGLE_CHARACTERS: &str = "abfnrtv";
/// RUF055
pub(crate) fn unnecessary_regular_expression(checker: &Checker, call: &ExprCall) {
// adapted from unraw_re_pattern
@@ -96,16 +99,19 @@ pub(crate) fn unnecessary_regular_expression(checker: &Checker, call: &ExprCall)
};
// For now, restrict this rule to string literals and variables that can be resolved to literals
let Some(string_lit) = resolve_string_literal(re_func.pattern, semantic) else {
let Some(literal) = resolve_literal(re_func.pattern, semantic) else {
return;
};
// For now, reject any regex metacharacters. Compare to the complete list
// from https://docs.python.org/3/howto/regex.html#matching-characters
let has_metacharacters = string_lit
.value
.to_str()
.contains(['.', '^', '$', '*', '+', '?', '{', '[', '\\', '|', '(', ')']);
let has_metacharacters = match &literal {
Literal::Str(str_lit) => str_lit.value.to_str().contains(METACHARACTERS),
Literal::Bytes(bytes_lit) => bytes_lit
.value
.iter()
.any(|part| part.iter().any(|&b| METACHARACTERS.contains(&(b as char)))),
};
if has_metacharacters {
return;
@@ -186,28 +192,48 @@ impl<'a> ReFunc<'a> {
// version
("sub", 3) => {
let repl = call.arguments.find_argument_value("repl", 1)?;
let lit = resolve_string_literal(repl, semantic)?;
let lit = resolve_literal(repl, semantic)?;
let mut fixable = true;
for (c, next) in lit.value.chars().tuple_windows() {
// `\0` (or any other ASCII digit) and `\g` have special meaning in `repl` strings.
// Meanwhile, nearly all other escapes of ASCII letters in a `repl` string causes
// `re.PatternError` to be raised at runtime.
//
// If we see that the escaped character is an alphanumeric ASCII character,
// we should only emit a diagnostic suggesting to replace the `re.sub()` call with
// `str.replace`if we can detect that the escaped character is one that is both
// valid in a `repl` string *and* does not have any special meaning in a REPL string.
//
// It's out of scope for this rule to change invalid `re.sub()` calls into something
// that would not raise an exception at runtime. They should be left as-is.
if c == '\\' && next.is_ascii_alphanumeric() {
if "abfnrtv".contains(next) {
fixable = false;
} else {
return None;
match lit {
Literal::Str(lit_str) => {
// Perform escape analysis for replacement literals.
for (c, next) in lit_str.value.to_str().chars().tuple_windows() {
// `\\0` (or any other ASCII digit) and `\\g` have special meaning in `repl` strings.
// Meanwhile, nearly all other escapes of ASCII letters in a `repl` string causes
// `re.PatternError` to be raised at runtime.
//
// If we see that the escaped character is an alphanumeric ASCII character,
// we should only emit a diagnostic suggesting to replace the `re.sub()` call with
// `str.replace`if we can detect that the escaped character is one that is both
// valid in a `repl` string *and* does not have any special meaning in a REPL string.
//
// It's out of scope for this rule to change invalid `re.sub()` calls into something
// that would not raise an exception at runtime. They should be left as-is.
if c == '\\' && next.is_ascii_alphanumeric() {
if ESCAPABLE_SINGLE_CHARACTERS.contains(next) {
fixable = false;
} else {
return None;
}
}
}
}
Literal::Bytes(lit_bytes) => {
for part in &lit_bytes.value {
for (byte, next) in part.iter().copied().tuple_windows() {
if byte == b'\\' && (next as char).is_ascii_alphanumeric() {
if ESCAPABLE_SINGLE_CHARACTERS.contains(next as char) {
fixable = false;
} else {
return None;
}
}
}
}
}
}
Some(ReFunc {
kind: ReFuncKind::Sub {
repl: fixable.then_some(repl),
@@ -329,6 +355,43 @@ impl<'a> ReFunc<'a> {
}
}
/// A literal that can be either a string or a bytes literal.
enum Literal<'a> {
Str(&'a ExprStringLiteral),
Bytes(&'a ExprBytesLiteral),
}
/// Try to resolve `name` to either a string or bytes literal in `semantic`.
fn resolve_literal<'a>(name: &'a Expr, semantic: &'a SemanticModel) -> Option<Literal<'a>> {
if let Some(str_lit) = resolve_string_literal(name, semantic) {
return Some(Literal::Str(str_lit));
}
if let Some(bytes_lit) = resolve_bytes_literal(name, semantic) {
return Some(Literal::Bytes(bytes_lit));
}
None
}
/// Try to resolve `name` to an [`ExprBytesLiteral`] in `semantic`.
fn resolve_bytes_literal<'a>(
name: &'a Expr,
semantic: &'a SemanticModel,
) -> Option<&'a ExprBytesLiteral> {
if name.is_bytes_literal_expr() {
return name.as_bytes_literal_expr();
}
if let Some(name_expr) = name.as_name_expr() {
let binding = semantic.binding(semantic.only_binding(name_expr)?);
let value = find_binding_value(binding, semantic)?;
if value.is_bytes_literal_expr() {
return value.as_bytes_literal_expr();
}
}
None
}
/// Try to resolve `name` to an [`ExprStringLiteral`] in `semantic`.
fn resolve_string_literal<'a>(
name: &'a Expr,

View File

@@ -0,0 +1,445 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF013_0.py:20:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
20 | def f(arg: int = None): # RUF013
| ^^^ RUF013
21 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
17 18 | pass
18 19 |
19 20 |
20 |-def f(arg: int = None): # RUF013
21 |+def f(arg: int | None = None): # RUF013
21 22 | pass
22 23 |
23 24 |
RUF013_0.py:24:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
24 | def f(arg: str = None): # RUF013
| ^^^ RUF013
25 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
21 22 | pass
22 23 |
23 24 |
24 |-def f(arg: str = None): # RUF013
25 |+def f(arg: str | None = None): # RUF013
25 26 | pass
26 27 |
27 28 |
RUF013_0.py:28:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
28 | def f(arg: Tuple[str] = None): # RUF013
| ^^^^^^^^^^ RUF013
29 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
25 26 | pass
26 27 |
27 28 |
28 |-def f(arg: Tuple[str] = None): # RUF013
29 |+def f(arg: Tuple[str] | None = None): # RUF013
29 30 | pass
30 31 |
31 32 |
RUF013_0.py:58:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
58 | def f(arg: Union = None): # RUF013
| ^^^^^ RUF013
59 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
55 56 | pass
56 57 |
57 58 |
58 |-def f(arg: Union = None): # RUF013
59 |+def f(arg: Union | None = None): # RUF013
59 60 | pass
60 61 |
61 62 |
RUF013_0.py:62:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
62 | def f(arg: Union[int] = None): # RUF013
| ^^^^^^^^^^ RUF013
63 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
59 60 | pass
60 61 |
61 62 |
62 |-def f(arg: Union[int] = None): # RUF013
63 |+def f(arg: Union[int] | None = None): # RUF013
63 64 | pass
64 65 |
65 66 |
RUF013_0.py:66:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
66 | def f(arg: Union[int, str] = None): # RUF013
| ^^^^^^^^^^^^^^^ RUF013
67 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
63 64 | pass
64 65 |
65 66 |
66 |-def f(arg: Union[int, str] = None): # RUF013
67 |+def f(arg: Union[int, str] | None = None): # RUF013
67 68 | pass
68 69 |
69 70 |
RUF013_0.py:85:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
85 | def f(arg: int | float = None): # RUF013
| ^^^^^^^^^^^ RUF013
86 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
82 83 | pass
83 84 |
84 85 |
85 |-def f(arg: int | float = None): # RUF013
86 |+def f(arg: int | float | None = None): # RUF013
86 87 | pass
87 88 |
88 89 |
RUF013_0.py:89:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
89 | def f(arg: int | float | str | bytes = None): # RUF013
| ^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013
90 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
86 87 | pass
87 88 |
88 89 |
89 |-def f(arg: int | float | str | bytes = None): # RUF013
90 |+def f(arg: int | float | str | bytes | None = None): # RUF013
90 91 | pass
91 92 |
92 93 |
RUF013_0.py:108:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
108 | def f(arg: Literal[1] = None): # RUF013
| ^^^^^^^^^^ RUF013
109 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
105 106 | pass
106 107 |
107 108 |
108 |-def f(arg: Literal[1] = None): # RUF013
109 |+def f(arg: Literal[1] | None = None): # RUF013
109 110 | pass
110 111 |
111 112 |
RUF013_0.py:112:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
112 | def f(arg: Literal[1, "foo"] = None): # RUF013
| ^^^^^^^^^^^^^^^^^ RUF013
113 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
109 110 | pass
110 111 |
111 112 |
112 |-def f(arg: Literal[1, "foo"] = None): # RUF013
113 |+def f(arg: Literal[1, "foo"] | None = None): # RUF013
113 114 | pass
114 115 |
115 116 |
RUF013_0.py:131:22: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
131 | def f(arg: Annotated[int, ...] = None): # RUF013
| ^^^ RUF013
132 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
128 129 | pass
129 130 |
130 131 |
131 |-def f(arg: Annotated[int, ...] = None): # RUF013
132 |+def f(arg: Annotated[int | None, ...] = None): # RUF013
132 133 | pass
133 134 |
134 135 |
RUF013_0.py:135:32: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
135 | def f(arg: Annotated[Annotated[int | str, ...], ...] = None): # RUF013
| ^^^^^^^^^ RUF013
136 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
132 133 | pass
133 134 |
134 135 |
135 |-def f(arg: Annotated[Annotated[int | str, ...], ...] = None): # RUF013
136 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None): # RUF013
136 137 | pass
137 138 |
138 139 |
RUF013_0.py:151:11: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
150 | def f(
151 | arg1: int = None, # RUF013
| ^^^ RUF013
152 | arg2: Union[int, float] = None, # RUF013
153 | arg3: Literal[1, 2, 3] = None, # RUF013
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
148 149 |
149 150 |
150 151 | def f(
151 |- arg1: int = None, # RUF013
152 |+ arg1: int | None = None, # RUF013
152 153 | arg2: Union[int, float] = None, # RUF013
153 154 | arg3: Literal[1, 2, 3] = None, # RUF013
154 155 | ):
RUF013_0.py:152:11: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
150 | def f(
151 | arg1: int = None, # RUF013
152 | arg2: Union[int, float] = None, # RUF013
| ^^^^^^^^^^^^^^^^^ RUF013
153 | arg3: Literal[1, 2, 3] = None, # RUF013
154 | ):
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
149 150 |
150 151 | def f(
151 152 | arg1: int = None, # RUF013
152 |- arg2: Union[int, float] = None, # RUF013
153 |+ arg2: Union[int, float] | None = None, # RUF013
153 154 | arg3: Literal[1, 2, 3] = None, # RUF013
154 155 | ):
155 156 | pass
RUF013_0.py:153:11: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
151 | arg1: int = None, # RUF013
152 | arg2: Union[int, float] = None, # RUF013
153 | arg3: Literal[1, 2, 3] = None, # RUF013
| ^^^^^^^^^^^^^^^^ RUF013
154 | ):
155 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
150 151 | def f(
151 152 | arg1: int = None, # RUF013
152 153 | arg2: Union[int, float] = None, # RUF013
153 |- arg3: Literal[1, 2, 3] = None, # RUF013
154 |+ arg3: Literal[1, 2, 3] | None = None, # RUF013
154 155 | ):
155 156 | pass
156 157 |
RUF013_0.py:181:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
181 | def f(arg: Union[Annotated[int, ...], Union[str, bytes]] = None): # RUF013
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013
182 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
178 179 | pass
179 180 |
180 181 |
181 |-def f(arg: Union[Annotated[int, ...], Union[str, bytes]] = None): # RUF013
182 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None): # RUF013
182 183 | pass
183 184 |
184 185 |
RUF013_0.py:188:13: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
188 | def f(arg: "int" = None): # RUF013
| ^^^ RUF013
189 | pass
|
= help: Convert to `Optional[T]`
Unsafe fix
185 185 | # Quoted
186 186 |
187 187 |
188 |-def f(arg: "int" = None): # RUF013
188 |+def f(arg: "Optional[int]" = None): # RUF013
189 189 | pass
190 190 |
191 191 |
RUF013_0.py:192:13: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
192 | def f(arg: "str" = None): # RUF013
| ^^^ RUF013
193 | pass
|
= help: Convert to `Optional[T]`
Unsafe fix
189 189 | pass
190 190 |
191 191 |
192 |-def f(arg: "str" = None): # RUF013
192 |+def f(arg: "Optional[str]" = None): # RUF013
193 193 | pass
194 194 |
195 195 |
RUF013_0.py:196:12: RUF013 PEP 484 prohibits implicit `Optional`
|
196 | def f(arg: "st" "r" = None): # RUF013
| ^^^^^^^^ RUF013
197 | pass
|
= help: Convert to `Optional[T]`
RUF013_0.py:204:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
204 | def f(arg: Union["int", "str"] = None): # RUF013
| ^^^^^^^^^^^^^^^^^^^ RUF013
205 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable
2 3 |
3 4 |
--------------------------------------------------------------------------------
201 202 | pass
202 203 |
203 204 |
204 |-def f(arg: Union["int", "str"] = None): # RUF013
205 |+def f(arg: Union["int", "str"] | None = None): # RUF013
205 206 | pass
206 207 |
207 208 |

View File

@@ -0,0 +1,19 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF013_1.py:4:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
4 | def f(arg: int = None): # RUF013
| ^^^ RUF013
5 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 1 | # No `typing.Optional` import
2 |+from __future__ import annotations
2 3 |
3 4 |
4 |-def f(arg: int = None): # RUF013
5 |+def f(arg: int | None = None): # RUF013
5 6 | pass

View File

@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---

View File

@@ -0,0 +1,65 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF013_3.py:4:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
4 | def f(arg: typing.List[str] = None): # RUF013
| ^^^^^^^^^^^^^^^^ RUF013
5 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | import typing
2 3 |
3 4 |
4 |-def f(arg: typing.List[str] = None): # RUF013
5 |+def f(arg: typing.List[str] | None = None): # RUF013
5 6 | pass
6 7 |
7 8 |
RUF013_3.py:22:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
22 | def f(arg: typing.Union[int, str] = None): # RUF013
| ^^^^^^^^^^^^^^^^^^^^^^ RUF013
23 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | import typing
2 3 |
3 4 |
--------------------------------------------------------------------------------
19 20 | pass
20 21 |
21 22 |
22 |-def f(arg: typing.Union[int, str] = None): # RUF013
23 |+def f(arg: typing.Union[int, str] | None = None): # RUF013
23 24 | pass
24 25 |
25 26 |
RUF013_3.py:29:12: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
29 | def f(arg: typing.Literal[1, "foo", True] = None): # RUF013
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013
30 | pass
|
= help: Convert to `T | None`
Unsafe fix
1 |+from __future__ import annotations
1 2 | import typing
2 3 |
3 4 |
--------------------------------------------------------------------------------
26 27 | # Literal
27 28 |
28 29 |
29 |-def f(arg: typing.Literal[1, "foo", True] = None): # RUF013
30 |+def f(arg: typing.Literal[1, "foo", True] | None = None): # RUF013
30 31 | pass

View File

@@ -0,0 +1,25 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF013_4.py:15:61: RUF013 [*] PEP 484 prohibits implicit `Optional`
|
15 | def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int = None): ...
| ^^^ RUF013
|
= help: Convert to `T | None`
Unsafe fix
1 1 | # https://github.com/astral-sh/ruff/issues/13833
2 |+from __future__ import annotations
2 3 |
3 4 | from typing import Optional
4 5 |
--------------------------------------------------------------------------------
12 13 | def multiple_1(arg1: Optional, arg2: Optional = None): ...
13 14 |
14 15 |
15 |-def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int = None): ...
16 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ...
16 17 |
17 18 |
18 19 | def return_type(arg: Optional = None) -> Optional: ...

View File

@@ -0,0 +1,80 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF055_3.py:6:1: RUF055 [*] Plain string pattern passed to `re` function
|
5 | # Should be replaced with `b_src.replace(rb"x", b"y")`
6 | re.sub(rb"x", b"y", b_src)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF055
7 |
8 | # Should be replaced with `b_src.startswith(rb"abc")`
|
= help: Replace with `b_src.replace(rb"x", b"y")`
Safe fix
3 3 | b_src = b"abc"
4 4 |
5 5 | # Should be replaced with `b_src.replace(rb"x", b"y")`
6 |-re.sub(rb"x", b"y", b_src)
6 |+b_src.replace(rb"x", b"y")
7 7 |
8 8 | # Should be replaced with `b_src.startswith(rb"abc")`
9 9 | if re.match(rb"abc", b_src):
RUF055_3.py:9:4: RUF055 [*] Plain string pattern passed to `re` function
|
8 | # Should be replaced with `b_src.startswith(rb"abc")`
9 | if re.match(rb"abc", b_src):
| ^^^^^^^^^^^^^^^^^^^^^^^^ RUF055
10 | pass
|
= help: Replace with `b_src.startswith(rb"abc")`
Safe fix
6 6 | re.sub(rb"x", b"y", b_src)
7 7 |
8 8 | # Should be replaced with `b_src.startswith(rb"abc")`
9 |-if re.match(rb"abc", b_src):
9 |+if b_src.startswith(rb"abc"):
10 10 | pass
11 11 |
12 12 | # Should be replaced with `rb"x" in b_src`
RUF055_3.py:13:4: RUF055 [*] Plain string pattern passed to `re` function
|
12 | # Should be replaced with `rb"x" in b_src`
13 | if re.search(rb"x", b_src):
| ^^^^^^^^^^^^^^^^^^^^^^^ RUF055
14 | pass
|
= help: Replace with `rb"x" in b_src`
Safe fix
10 10 | pass
11 11 |
12 12 | # Should be replaced with `rb"x" in b_src`
13 |-if re.search(rb"x", b_src):
13 |+if rb"x" in b_src:
14 14 | pass
15 15 |
16 16 | # Should be replaced with `b_src.split(rb"abc")`
RUF055_3.py:17:1: RUF055 [*] Plain string pattern passed to `re` function
|
16 | # Should be replaced with `b_src.split(rb"abc")`
17 | re.split(rb"abc", b_src)
| ^^^^^^^^^^^^^^^^^^^^^^^^ RUF055
18 |
19 | # Patterns containing metacharacters should NOT be replaced
|
= help: Replace with `b_src.split(rb"abc")`
Safe fix
14 14 | pass
15 15 |
16 16 | # Should be replaced with `b_src.split(rb"abc")`
17 |-re.split(rb"abc", b_src)
17 |+b_src.split(rb"abc")
18 18 |
19 19 | # Patterns containing metacharacters should NOT be replaced
20 20 | re.sub(rb"ab[c]", b"", b_src)

View File

@@ -210,6 +210,7 @@ macro_rules! display_settings {
}
#[derive(Debug, Clone, CacheKey)]
#[expect(clippy::struct_excessive_bools)]
pub struct LinterSettings {
pub exclude: FilePatternSet,
pub extension: ExtensionMapping,
@@ -251,6 +252,7 @@ pub struct LinterSettings {
pub task_tags: Vec<String>,
pub typing_modules: Vec<String>,
pub typing_extensions: bool,
pub future_annotations: bool,
// Plugins
pub flake8_annotations: flake8_annotations::settings::Settings,
@@ -453,6 +455,7 @@ impl LinterSettings {
explicit_preview_rules: false,
extension: ExtensionMapping::default(),
typing_extensions: true,
future_annotations: false,
}
}
@@ -472,6 +475,11 @@ impl LinterSettings {
.is_match(path)
.map_or(self.unresolved_target_version, TargetVersion::from)
}
pub fn future_annotations(&self) -> bool {
// TODO(brent) we can just access the field directly once this is stabilized.
self.future_annotations && crate::preview::is_add_future_annotations_imports_enabled(self)
}
}
impl Default for LinterSettings {

View File

@@ -1527,7 +1527,7 @@ impl<'src> Parser<'src> {
self.bump(kind.start_token());
let elements = self.parse_interpolated_string_elements(
flags,
InterpolatedStringElementsKind::Regular,
InterpolatedStringElementsKind::Regular(kind),
kind,
);

View File

@@ -8,6 +8,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::error::UnsupportedSyntaxError;
use crate::parser::expression::ExpressionContext;
use crate::parser::progress::{ParserProgress, TokenId};
use crate::string::InterpolatedStringKind;
use crate::token::TokenValue;
use crate::token_set::TokenSet;
use crate::token_source::{TokenSource, TokenSourceCheckpoint};
@@ -799,7 +800,7 @@ impl WithItemKind {
}
}
#[derive(Debug, PartialEq, Copy, Clone)]
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum InterpolatedStringElementsKind {
/// The regular f-string elements.
///
@@ -807,7 +808,7 @@ enum InterpolatedStringElementsKind {
/// ```py
/// f"hello {x:.2f} world"
/// ```
Regular,
Regular(InterpolatedStringKind),
/// The f-string elements are part of the format specifier.
///
@@ -819,15 +820,13 @@ enum InterpolatedStringElementsKind {
}
impl InterpolatedStringElementsKind {
const fn list_terminators(self) -> TokenSet {
const fn list_terminator(self) -> TokenKind {
match self {
InterpolatedStringElementsKind::Regular => {
TokenSet::new([TokenKind::FStringEnd, TokenKind::TStringEnd])
}
InterpolatedStringElementsKind::Regular(string_kind) => string_kind.end_token(),
// test_ok fstring_format_spec_terminator
// f"hello {x:} world"
// f"hello {x:.3f} world"
InterpolatedStringElementsKind::FormatSpec => TokenSet::new([TokenKind::Rbrace]),
InterpolatedStringElementsKind::FormatSpec => TokenKind::Rbrace,
}
}
}
@@ -1121,7 +1120,7 @@ impl RecoveryContextKind {
.then_some(ListTerminatorKind::Regular),
},
RecoveryContextKind::InterpolatedStringElements(kind) => {
if p.at_ts(kind.list_terminators()) {
if p.at(kind.list_terminator()) {
Some(ListTerminatorKind::Regular)
} else {
// test_err unterminated_fstring_newline_recovery
@@ -1177,13 +1176,23 @@ impl RecoveryContextKind {
) || p.at_name_or_soft_keyword()
}
RecoveryContextKind::WithItems(_) => p.at_expr(),
RecoveryContextKind::InterpolatedStringElements(_) => matches!(
p.current_token_kind(),
// Literal element
TokenKind::FStringMiddle | TokenKind::TStringMiddle
// Expression element
| TokenKind::Lbrace
),
RecoveryContextKind::InterpolatedStringElements(elements_kind) => {
match elements_kind {
InterpolatedStringElementsKind::Regular(interpolated_string_kind) => {
p.current_token_kind() == interpolated_string_kind.middle_token()
|| p.current_token_kind() == TokenKind::Lbrace
}
InterpolatedStringElementsKind::FormatSpec => {
matches!(
p.current_token_kind(),
// Literal element
TokenKind::FStringMiddle | TokenKind::TStringMiddle
// Expression element
| TokenKind::Lbrace
)
}
}
}
}
}
@@ -1272,8 +1281,8 @@ impl RecoveryContextKind {
),
},
RecoveryContextKind::InterpolatedStringElements(kind) => match kind {
InterpolatedStringElementsKind::Regular => ParseErrorType::OtherError(
"Expected an f-string or t-string element or the end of the f-string or t-string".to_string(),
InterpolatedStringElementsKind::Regular(string_kind) => ParseErrorType::OtherError(
format!("Expected an element of or the end of the {string_kind}"),
),
InterpolatedStringElementsKind::FormatSpec => ParseErrorType::OtherError(
"Expected an f-string or t-string element or a '}'".to_string(),
@@ -1316,8 +1325,9 @@ bitflags! {
const WITH_ITEMS_PARENTHESIZED = 1 << 25;
const WITH_ITEMS_PARENTHESIZED_EXPRESSION = 1 << 26;
const WITH_ITEMS_UNPARENTHESIZED = 1 << 28;
const FT_STRING_ELEMENTS = 1 << 29;
const FT_STRING_ELEMENTS_IN_FORMAT_SPEC = 1 << 30;
const F_STRING_ELEMENTS = 1 << 29;
const T_STRING_ELEMENTS = 1 << 30;
const FT_STRING_ELEMENTS_IN_FORMAT_SPEC = 1 << 31;
}
}
@@ -1371,7 +1381,13 @@ impl RecoveryContext {
WithItemKind::Unparenthesized => RecoveryContext::WITH_ITEMS_UNPARENTHESIZED,
},
RecoveryContextKind::InterpolatedStringElements(kind) => match kind {
InterpolatedStringElementsKind::Regular => RecoveryContext::FT_STRING_ELEMENTS,
InterpolatedStringElementsKind::Regular(InterpolatedStringKind::FString) => {
RecoveryContext::F_STRING_ELEMENTS
}
InterpolatedStringElementsKind::Regular(InterpolatedStringKind::TString) => {
RecoveryContext::T_STRING_ELEMENTS
}
InterpolatedStringElementsKind::FormatSpec => {
RecoveryContext::FT_STRING_ELEMENTS_IN_FORMAT_SPEC
}
@@ -1442,8 +1458,11 @@ impl RecoveryContext {
RecoveryContext::WITH_ITEMS_UNPARENTHESIZED => {
RecoveryContextKind::WithItems(WithItemKind::Unparenthesized)
}
RecoveryContext::FT_STRING_ELEMENTS => RecoveryContextKind::InterpolatedStringElements(
InterpolatedStringElementsKind::Regular,
RecoveryContext::F_STRING_ELEMENTS => RecoveryContextKind::InterpolatedStringElements(
InterpolatedStringElementsKind::Regular(InterpolatedStringKind::FString),
),
RecoveryContext::T_STRING_ELEMENTS => RecoveryContextKind::InterpolatedStringElements(
InterpolatedStringElementsKind::Regular(InterpolatedStringKind::TString),
),
RecoveryContext::FT_STRING_ELEMENTS_IN_FORMAT_SPEC => {
RecoveryContextKind::InterpolatedStringElements(

View File

@@ -0,0 +1,10 @@
---
source: crates/ruff_python_parser/src/parser/tests.rs
expression: error
---
ParseError {
error: Lexical(
LineContinuationError,
),
location: 3..4,
}

View File

@@ -0,0 +1,12 @@
---
source: crates/ruff_python_parser/src/parser/tests.rs
expression: error
---
ParseError {
error: Lexical(
TStringError(
SingleRbrace,
),
),
location: 8..9,
}

View File

@@ -134,3 +134,26 @@ foo.bar[0].baz[2].egg??
.unwrap();
insta::assert_debug_snapshot!(parsed.syntax());
}
#[test]
fn test_fstring_expr_inner_line_continuation_and_t_string() {
let source = r#"f'{\t"i}'"#;
let parsed = parse_expression(source);
let error = parsed.unwrap_err();
insta::assert_debug_snapshot!(error);
}
#[test]
fn test_fstring_expr_inner_line_continuation_newline_t_string() {
let source = r#"f'{\
t"i}'"#;
let parsed = parse_expression(source);
let error = parsed.unwrap_err();
insta::assert_debug_snapshot!(error);
}

View File

@@ -41,7 +41,7 @@ impl From<StringType> for Expr {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InterpolatedStringKind {
FString,
TString,

View File

@@ -124,5 +124,5 @@ Module(
|
1 | f"{lambda x: x}"
| ^ Syntax Error: Expected an f-string or t-string element or the end of the f-string or t-string
| ^ Syntax Error: Expected an element of or the end of the f-string
|

View File

@@ -221,7 +221,7 @@ Module(
2 | 'hello'
3 | f'world {x}
4 | )
| ^ Syntax Error: Expected an f-string or t-string element or the end of the f-string or t-string
| ^ Syntax Error: Expected an element of or the end of the f-string
5 | 1 + 1
6 | (
|

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