Compare commits

...

35 Commits

Author SHA1 Message Date
David Peter
7e5358de2b [red-knot] T is disjoint from ~T 2025-02-10 22:15:47 +01:00
Dhruv Manilawala
1f3ff48b4f Undeprecate ruff.nativeServer (#16039)
Related to https://github.com/astral-sh/ruff-vscode/pull/684.
2025-02-08 22:29:07 +05:30
Carlos Martin
5e027a43ff Add JAX to users list. (#16031)
This PR adds [JAX](https://github.com/jax-ml/jax) to the
[list](https://github.com/astral-sh/ruff?tab=readme-ov-file#whos-using-ruff)
of open-source projects using Ruff.
2025-02-08 16:45:30 +00:00
Alex Waygood
22728808aa [pyupgrade] Ensure we do not rename two type parameters to the same name (UP049) (#16038)
Fixes #16024

## Summary

This PR adds proper isolation for `UP049` fixes so that two type
parameters are not renamed to the same name, which would introduce
invalid syntax. E.g. for this:

```py
class Foo[_T, __T]: ...
```

we cannot apply two autofixes to the class, as that would produce
invalid syntax -- this:

```py
class Foo[T, T]: ...
```

The "isolation" here means that Ruff won't apply more than one fix to
the same type-parameter list in a single iteration of the loop it does
to apply all autofixes. This means that after the first autofix has been
done, the semantic model will have recalculated which variables are
available in the scope, meaning that the diagnostic for the second
parameter will be deemed unfixable since it collides with an existing
name in the same scope (the name we autofixed the first parameter to in
an earlier iteration of the autofix loop).

Cc. @ntBre, for interest!

## Test Plan

I added an integration test that reproduces the bug on `main`.
2025-02-08 15:44:04 +00:00
InSync
a04ddf2a55 [pyupgrade] [ruff] Don't apply renamings if the new name is shadowed in a scope of one of the references to the binding (UP049, RUF052) (#16032)
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-02-08 11:25:23 +00:00
Dylan
3a806ecaa1 [flake8-annotations] Correct syntax for typing.Union in suggested return type fixes for ANN20x rules (#16025)
When suggesting a return type as a union in Python <=3.9, we now avoid a
`TypeError` by correctly suggesting syntax like `Union[int,str,None]`
instead of `Union[int | str | None]`.
2025-02-07 17:17:20 -06:00
InSync
a29009e4ed [pyupgrade] Comments within parenthesized value ranges should not affect applicability (UP040) (#16027)
## Summary

Follow-up to #16026.

Previously, the fix for this would be marked as unsafe, even though all
comments are preserved:

```python
# .pyi
T: TypeAlias = (  # Comment
	int | str
)
```

Now it is safe: comments within the parenthesized range no longer affect
applicability.

## Test Plan

`cargo nextest run` and `cargo insta test`.

---------

Co-authored-by: Dylan <53534755+dylwil3@users.noreply.github.com>
2025-02-07 14:44:33 -06:00
InSync
19f3424a1a [pylint] Do not report calls when object type and argument type mismatch, remove custom escape handling logic (PLE1310) (#15984)
## Summary

Resolves #15968.

Previously, these would be considered violations:

```python
b''.strip('//')
''.lstrip('//', foo = "bar")
```

...while these are not:

```python
b''.strip(b'//')
''.strip('\\b\\x08')
```

Ruff will now not report when the types of the object and that of the
argument mismatch, or when there are extra arguments.

## Test Plan

`cargo nextest run` and `cargo insta test`.
2025-02-07 14:31:07 -06:00
Brent Westbrook
d4a5772d96 [flake8-builtins] Match upstream module name comparison (A005) (#16006)
See #15951 for the original discussion and reviews. This is just the
first half of that PR (reaching parity with `flake8-builtins` without
adding any new configuration options) split out for nicer changelog
entries.

For posterity, here's a script for generating the module structure that
was useful for interactive testing and creating the table
[here](https://github.com/astral-sh/ruff/pull/15951#issuecomment-2640662041).
The results for this branch are the same as the `Strict` column there,
as expected.

```shell
mkdir abc collections foobar urlparse

for i in */
do
	touch $i/__init__.py
done	

cp -r abc foobar collections/.
cp -r abc collections foobar/.

touch ruff.toml

touch foobar/logging.py
```

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-02-07 13:55:56 -05:00
Alex Waygood
efa8a3ddcc [pyupgrade] Don't introduce invalid syntax when upgrading old-style type aliases with parenthesized multiline values (UP040) (#16026) 2025-02-07 17:05:17 +00:00
Dylan
46fe17767d Pass Checker by immutable reference to lint rules (#16012)
This very large PR changes the field `.diagnostics` in the `Checker`
from a `Vec<Diagnostic>` to a `RefCell<Vec<Diagnostic>>`, adds methods
to push new diagnostics to this cell, and then removes unnecessary
mutability throughout all of our lint rule implementations.

Consequently, the compiler may now enforce what was, till now, the
_convention_ that the only changes to the `Checker` that can happen
during a lint are the addition of diagnostics[^1].

The PR is best reviewed commit-by-commit. I have tried to keep the large
commits limited to "bulk actions that you can easily see are performing
the same find/replace on a large number of files", and separate anything
ad-hoc or with larger diffs. Please let me know if there's anything else
I can do to make this easier to review!

Many thanks to [`ast-grep`](https://github.com/ast-grep/ast-grep),
[`helix`](https://github.com/helix-editor/helix), and good ol'
fashioned`git` magic, without which this PR would have taken the rest of
my natural life.

[^1]: And randomly also the seen variables violating `flake8-bugbear`?
2025-02-07 09:05:50 -06:00
David Peter
1f7a29d347 [red-knot] Unpacker: Make invariant explicit and directly return a Type (#16018)
## Summary

- Do not return `Option<Type<…>>` from `Unpacker::get`, but just `Type`.
Panic otherwise.
- Rename `Unpacker::get` to `Unpacker::expression_type`
2025-02-07 12:00:04 +00:00
Wei Lee
618bfaf884 [airflow] Add external_task.{ExternalTaskMarker, ExternalTaskSensor} for AIR302 (#16014)
## Summary

Apply suggestions similar to
https://github.com/astral-sh/ruff/pull/15922#discussion_r1940697704


## Test Plan

a test fixture has been updated
2025-02-07 16:38:34 +05:30
Alex Waygood
b1c61cb2ee [ruff] Fix invalid annotation in docs example (#16016) 2025-02-07 10:45:51 +00:00
David Peter
97e6fc3793 [red-knot] Unpacking and for loop assignments to attributes (#16004)
## Summary

* Support assignments to attributes in more cases:
    - assignments in `for` loops
    - in unpacking assignments
* Add test for multi-target assignments
* Add tests for all other possible assignments to attributes that could
   possibly occur (in decreasing order of likeliness):
    - augmented attribute assignments
    - attribute assignments in `with` statements
    - attribute assignments in comprehensions
- Note: assignments to attributes in named expressions are not
   syntactically allowed

closes #15962

## Test Plan

New Markdown tests
2025-02-07 11:30:51 +01:00
Micha Reiser
38351e00ee [red-knot] Partial revert of relative import handling for files in the root of a search path (#16001)
## Summary

This PR reverts the behavior changes from
https://github.com/astral-sh/ruff/pull/15990

But it isn't just a revert, it also:

* Adds a test covering this specific behavior
* Preserves the improvement to use `saturating_sub` in the package case
to avoid overflows in the case of invalid syntax
* Use `ancestors` instead of a `for` loop

## Test Plan

Added test
2025-02-07 11:04:09 +01:00
Micha Reiser
26c37b1e0e Add knot.toml schema (#15735)
## Summary

Adds a JSON schema generation step for Red Knot. This PR doesn't yet add
a publishing step because it's still a bit early for that


## Test plan

I tested the schema in Zed, VS Code and PyCharm:

* PyCharm: You have to manually add a schema mapping (settings JSON
Schema Mappings)
* Zed and VS code support the inline schema specification

```toml
#:schema /Users/micha/astral/ruff/knot.schema.json


[environment]
extra-paths = []


[rules]
call-possibly-unbound-method = "error"
unknown-rule = "error"

# duplicate-base = "error"
```

```json
{
    "$schema": "file:///Users/micha/astral/ruff/knot.schema.json",

    "environment": {
        "python-version": "3.13",
        "python-platform": "linux2"
    },

    "rules": {
        "unknown-rule": "error"
    }
}
```


https://github.com/user-attachments/assets/a18fcd96-7cbe-4110-985b-9f1935584411


The Schema overall works but all editors have their own quirks:

* PyCharm: Hovering a name always shows the section description instead
of the description of the specific setting. But it's the same for other
settings in `pyproject.toml` files 🤷
* VS Code (JSON): Using the generated schema in a JSON file gives
exactly the experience I want
* VS Code (TOML): 
* Properties with multiple possible values are repeated during
auto-completion without giving any hint how they're different. ![Screen
Shot 2025-02-06 at 14 05 35
PM](https://github.com/user-attachments/assets/d7f3c2a9-2351-4226-9fc1-b91aa192a237)
* The property description mushes together the description of the
property and the value, which looks sort of ridiculous. ![Screen Shot
2025-02-06 at 14 04 40
PM](https://github.com/user-attachments/assets/8b72f04a-c62a-49b5-810f-7ddd472884d0)
* Autocompletion and documentation hovering works (except the
limitations mentioned above)
* Zed:
* Very similar to VS Code with the exception that it uses the
description attribute to distinguish settings with multiple possible
values ![Screen Shot 2025-02-06 at 14 08 19
PM](https://github.com/user-attachments/assets/78a7f849-ff4e-44ff-8317-708eaf02dc1f)


I don't think there's much we can do here other than hope (or help)
editors improve their auto completion. The same short comings also apply
to ruff, so this isn't something new. For now, I think this is good
enough
2025-02-07 10:59:40 +01:00
InSync
7db5a924af [flake8-comprehensions] Detect overshadowed list/set/dict, ignore variadics and named expressions (C417) (#15955)
## Summary

Part of #15809 and #15876.

This change brings several bugfixes:

* The nested `map()` call in `list(map(lambda x: x, []))` where `list`
is overshadowed is now correctly reported.
* The call will no longer reported if:
	* Any arguments given to `map()` are variadic.
	* Any of the iterables contain a named expression.

## Test Plan

`cargo nextest run` and `cargo insta test`.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-02-07 08:58:05 +00:00
Junhson Jean-Baptiste
349f93389e [flake8-simplify] Only trigger SIM401 on known dictionaries (SIM401) (#15995)
## Summary

This change resolves #15814 to ensure that `SIM401` is only triggered on
known dictionary types. Before, the rule was getting triggered even on
types that _resemble_ a dictionary but are not actually a dictionary.

I did this using the `is_known_to_be_of_type_dict(...)` functionality.
The logic for this function was duplicated in a few spots, so I moved
the code to a central location, removed redundant definitions, and
updated existing calls to use the single definition of the function!

## Test Plan

Since this PR only modifies an existing rule, I made changes to the
existing test instead of adding new ones. I made sure that `SIM401` is
triggered on types that are clearly dictionaries and that it's not
triggered on a simple custom dictionary-like type (using a modified
version of [the code in the issue](#15814))

The additional changes to de-duplicate `is_known_to_be_of_type_dict`
don't break any existing tests -- I think this should be fine since the
logic remains the same (please let me know if you think otherwise, I'm
excited to get feedback and work towards a good fix 🙂).

---------

Co-authored-by: Junhson Jean-Baptiste <junhsonjb@naan.mynetworksettings.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-02-07 08:25:20 +00:00
InSync
bb979e05ac [flake8-pie] Remove following comma correctly when the unpacked dictionary is empty (PIE800) (#16008)
## Summary

Resolves #15997.

Ruff used to introduce syntax errors while fixing these cases, but no
longer will:

```python
{"a": [], **{},}
#         ^^^^ Removed, leaving two contiguous commas

{"a": [], **({})}
#         ^^^^^ Removed, leaving a stray closing parentheses
```

Previously, the function would take a shortcut if the unpacked
dictionary is empty; now, both cases are handled using the same logic
introduced in #15394. This change slightly modifies that logic to also
remove the first comma following the dictionary, if and only if it is
empty.

## Test Plan

`cargo nextest run` and `cargo insta test`.
2025-02-07 08:52:10 +01:00
Dylan
10d3e64ccd Bump version to 0.9.5 (#16002) 2025-02-06 13:24:45 -06:00
InSync
84ceddcbd9 [ruff] Classes with mixed type variable style (RUF053) (#15841) 2025-02-06 18:35:51 +00:00
Ayush Baweja
ba2f0e998d [flake8-pyi] Add autofix for unused-private-type-var (PYI018) (#15999)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2025-02-06 18:08:36 +00:00
Alex Waygood
18b497a913 [red-knot] Fixup a couple of nits in the red_knot_test README (#15996) 2025-02-06 15:04:27 +00:00
Micha Reiser
7cac0da44d Workaround Even Better TOML crash related to allOf (#15992)
## Summary

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

Even Better TOML doesn't support `allOf` well. In fact, it just crashes.

This PR works around this limitation by avoid using `allOf` in the
automatically
derived schema for the docstring formatting setting. 

### Alternatives

schemars introduces `allOf` whenver it sees a `$ref` alongside other
object properties
because this is no longer valid according to Draft 7. We could replace
the
visitor performing the rewrite but I prefer not to because replacing
`allOf` with `oneOf`
is only valid for objects that don't have any other `oneOf` or `anyOf`
schema.

## Test Plan


https://github.com/user-attachments/assets/25d73b2a-fee1-4ba6-9ffe-869b2c3bc64e
2025-02-06 16:00:50 +01:00
Dhruv Manilawala
b66cc94f9b Add deprecation warning for ruff-lsp related settings (#15850)
## Summary

This PR updates the documentation to add deprecated warning for
`ruff-lsp` specific settings

### Preview


https://github.com/user-attachments/assets/64e11e4b-7178-43ab-be5b-421e7f4689de

## Test Plan

Build the documentation locally and test out the links. Refer to the
preview video above.
2025-02-06 20:12:41 +05:30
David Peter
e345307260 [red-knot] Fix diagnostic range for non-iterable unpacking assignments (#15994)
## Summary

I noticed that the diagnostic range in specific unpacking assignments is
wrong. For this example

```py
a, b = 1
```

we previously got (see first commit):

```
error: lint:not-iterable
 --> /src/mdtest_snippet.py:1:1
  |
1 | a, b = 1
  | ^^^^ Object of type `Literal[1]` is not iterable
  |
```

and with this change, we get:

```
error: lint:not-iterable
 --> /src/mdtest_snippet.py:1:8
  |
1 | a, b = 1
  |        ^ Object of type `Literal[1]` is not iterable
  |
```

## Test Plan

New snapshot tests.
2025-02-06 15:36:22 +01:00
Micha Reiser
5588c75d65 [red-knot] Fix relative imports in src.root (#15990)
## Summary

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

Red Knot failed to resolve relative imports if the importing module is
located at a search path root.

The issue was that the module resolver returned an `Err(TooManyDots)` as
soon as the parent of the current module is `None` (which is the case
for a module at the search path root).
However, this is incorrect if a `tail` (a module name) exists.
2025-02-06 14:08:20 +00:00
Raymond Berger
9d2105b863 add instance variable examples to RUF012 (#15982)
## Summary

Closes #15804 

Add more examples to the documentation.

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-02-06 14:01:09 +00:00
David Salvisberg
8fcac0ff36 Recognize all symbols named TYPE_CHECKING for in_type_checking_block (#15719)
Closes #15681

## Summary

This changes `analyze::typing::is_type_checking_block` to recognize all
symbols named "TYPE_CHECKING".
This matches the current behavior of mypy and pyright as well as
`flake8-type-checking`.

It also drops support for detecting `if False:` and `if 0:` as type
checking blocks. This used to be an option for
providing backwards compatibility with Python versions that did not have
a `typing` module, but has since
been removed from the typing spec and is no longer supported by any of
the mainstream type checkers.

## Test Plan

`cargo nextest run`

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-02-06 14:45:12 +01:00
Vasco Schiavo
81059d05fc [pep8-naming] Consider any number of leading underscore for N801 (#15988)
## Summary

The PR addresses the issue #15939 

Let me know if you think there are other test cases I should add ;-)
2025-02-06 14:08:27 +05:30
Vasco Schiavo
24bab7e82e [pycodestyle] Exempt sys.path += ... calls (E402) (#15980)
## Summary

The PR addresses issue #15886 .
2025-02-06 08:51:51 +01:00
David Peter
d0555f7b5c [red-knot] Litate tests: minor follow-up (#15987)
## Summary

- Minor wording update
- Code improvement (thanks Alex)
- Removed all unnecessary filenames throughout our Markdown tests (two
new ones were added in the meantime)
- Minor rewording of the statically-known-branches introduction
2025-02-06 07:15:26 +00:00
Douglas Creager
0906554357 [red-knot] Combine terminal statement support with statically known branches (#15817)
This example from @sharkdp shows how terminal statements can appear in
statically known branches:
https://github.com/astral-sh/ruff/pull/15676#issuecomment-2618809716

```py
def _(cond: bool):
    x = "a"
    if cond:
        x = "b"
        if True:
            return

    reveal_type(x)  # revealed: "a", "b"; should be "a"
```

We now use visibility constraints to track reachability, which allows us
to model this correctly. There are two related changes as a result:

- New bindings are not assumed to be visible; they inherit the current
"scope start" visibility, which effectively means that new bindings are
visible if/when the current flow is reachable

- When simplifying visibility constraints after branching control flow,
we only simplify if none of the intervening branches included a terminal
statement. That is, earlier unaffected bindings are only _actually_
unaffected if all branches make it to the merge point.
2025-02-05 17:47:49 -05:00
David Peter
d296f602e7 [red-knot] Merge Markdown code blocks inside a single section (#15950)
## Summary

Allow for literate style in Markdown tests and merge multiple (unnamed)
code blocks into a single embedded file.

closes #15941

## Test Plan

- Interactively made sure that error-lines were reported correctly in
  multi-snippet sections.
2025-02-05 22:26:15 +01:00
685 changed files with 7667 additions and 4074 deletions

View File

@@ -1,5 +1,64 @@
# Changelog
## 0.9.5
### Preview features
- Recognize all symbols named `TYPE_CHECKING` for `in_type_checking_block` ([#15719](https://github.com/astral-sh/ruff/pull/15719))
- \[`flake8-comprehensions`\] Handle builtins at top of file correctly for `unnecessary-dict-comprehension-for-iterable` (`C420`) ([#15837](https://github.com/astral-sh/ruff/pull/15837))
- \[`flake8-logging`\] `.exception()` and `exc_info=` outside exception handlers (`LOG004`, `LOG014`) ([#15799](https://github.com/astral-sh/ruff/pull/15799))
- \[`flake8-pyi`\] Fix incorrect behaviour of `custom-typevar-return-type` preview-mode autofix if `typing` was already imported (`PYI019`) ([#15853](https://github.com/astral-sh/ruff/pull/15853))
- \[`flake8-pyi`\] Fix more complex cases (`PYI019`) ([#15821](https://github.com/astral-sh/ruff/pull/15821))
- \[`flake8-pyi`\] Make `PYI019` autofixable for `.py` files in preview mode as well as stubs ([#15889](https://github.com/astral-sh/ruff/pull/15889))
- \[`flake8-pyi`\] Remove type parameter correctly when it is the last (`PYI019`) ([#15854](https://github.com/astral-sh/ruff/pull/15854))
- \[`pylint`\] Fix missing parens in unsafe fix for `unnecessary-dunder-call` (`PLC2801`) ([#15762](https://github.com/astral-sh/ruff/pull/15762))
- \[`pyupgrade`\] Better messages and diagnostic range (`UP015`) ([#15872](https://github.com/astral-sh/ruff/pull/15872))
- \[`pyupgrade`\] Rename private type parameters in PEP 695 generics (`UP049`) ([#15862](https://github.com/astral-sh/ruff/pull/15862))
- \[`refurb`\] Also report non-name expressions (`FURB169`) ([#15905](https://github.com/astral-sh/ruff/pull/15905))
- \[`refurb`\] Mark fix as unsafe if there are comments (`FURB171`) ([#15832](https://github.com/astral-sh/ruff/pull/15832))
- \[`ruff`\] Classes with mixed type variable style (`RUF053`) ([#15841](https://github.com/astral-sh/ruff/pull/15841))
- \[`airflow`\] `BashOperator` has been moved to `airflow.providers.standard.operators.bash.BashOperator` (`AIR302`) ([#15922](https://github.com/astral-sh/ruff/pull/15922))
- \[`flake8-pyi`\] Add autofix for unused-private-type-var (`PYI018`) ([#15999](https://github.com/astral-sh/ruff/pull/15999))
- \[`flake8-pyi`\] Significantly improve accuracy of `PYI019` if preview mode is enabled ([#15888](https://github.com/astral-sh/ruff/pull/15888))
### Rule changes
- Preserve triple quotes and prefixes for strings ([#15818](https://github.com/astral-sh/ruff/pull/15818))
- \[`flake8-comprehensions`\] Skip when `TypeError` present from too many (kw)args for `C410`,`C411`, and `C418` ([#15838](https://github.com/astral-sh/ruff/pull/15838))
- \[`flake8-pyi`\] Rename `PYI019` and improve its diagnostic message ([#15885](https://github.com/astral-sh/ruff/pull/15885))
- \[`pep8-naming`\] Ignore `@override` methods (`N803`) ([#15954](https://github.com/astral-sh/ruff/pull/15954))
- \[`pyupgrade`\] Reuse replacement logic from `UP046` and `UP047` to preserve more comments (`UP040`) ([#15840](https://github.com/astral-sh/ruff/pull/15840))
- \[`ruff`\] Analyze deferred annotations before enforcing `mutable-(data)class-default` and `function-call-in-dataclass-default-argument` (`RUF008`,`RUF009`,`RUF012`) ([#15921](https://github.com/astral-sh/ruff/pull/15921))
- \[`pycodestyle`\] Exempt `sys.path += ...` calls (`E402`) ([#15980](https://github.com/astral-sh/ruff/pull/15980))
### Configuration
- Config error only when `flake8-import-conventions` alias conflicts with `isort.required-imports` bound name ([#15918](https://github.com/astral-sh/ruff/pull/15918))
- Workaround Even Better TOML crash related to `allOf` ([#15992](https://github.com/astral-sh/ruff/pull/15992))
### Bug fixes
- \[`flake8-comprehensions`\] Unnecessary `list` comprehension (rewrite as a `set` comprehension) (`C403`) - Handle extraneous parentheses around list comprehension ([#15877](https://github.com/astral-sh/ruff/pull/15877))
- \[`flake8-comprehensions`\] Handle trailing comma in fixes for `unnecessary-generator-list/set` (`C400`,`C401`) ([#15929](https://github.com/astral-sh/ruff/pull/15929))
- \[`flake8-pyi`\] Fix several correctness issues with `custom-type-var-return-type` (`PYI019`) ([#15851](https://github.com/astral-sh/ruff/pull/15851))
- \[`pep8-naming`\] Consider any number of leading underscore for `N801` ([#15988](https://github.com/astral-sh/ruff/pull/15988))
- \[`pyflakes`\] Visit forward annotations in `TypeAliasType` as types (`F401`) ([#15829](https://github.com/astral-sh/ruff/pull/15829))
- \[`pylint`\] Correct min/max auto-fix and suggestion for (`PL1730`) ([#15930](https://github.com/astral-sh/ruff/pull/15930))
- \[`refurb`\] Handle unparenthesized tuples correctly (`FURB122`, `FURB142`) ([#15953](https://github.com/astral-sh/ruff/pull/15953))
- \[`refurb`\] Avoid `None | None` as well as better detection and fix (`FURB168`) ([#15779](https://github.com/astral-sh/ruff/pull/15779))
### Documentation
- Add deprecation warning for `ruff-lsp` related settings ([#15850](https://github.com/astral-sh/ruff/pull/15850))
- Docs (`linter.md`): clarify that Python files are always searched for in subdirectories ([#15882](https://github.com/astral-sh/ruff/pull/15882))
- Fix a typo in `non_pep695_generic_class.rs` ([#15946](https://github.com/astral-sh/ruff/pull/15946))
- Improve Docs: Pylint subcategories' codes ([#15909](https://github.com/astral-sh/ruff/pull/15909))
- Remove non-existing `lint.extendIgnore` editor setting ([#15844](https://github.com/astral-sh/ruff/pull/15844))
- Update black deviations ([#15928](https://github.com/astral-sh/ruff/pull/15928))
- Mention `UP049` in `UP046` and `UP047`, add `See also` section to `UP040` ([#15956](https://github.com/astral-sh/ruff/pull/15956))
- Add instance variable examples to `RUF012` ([#15982](https://github.com/astral-sh/ruff/pull/15982))
- Explain precedence for `ignore` and `select` config ([#15883](https://github.com/astral-sh/ruff/pull/15883))
## 0.9.4
### Preview features

11
Cargo.lock generated
View File

@@ -2439,6 +2439,7 @@ dependencies = [
"ruff_text_size",
"rustc-hash 2.1.0",
"salsa",
"schemars",
"serde",
"thiserror 2.0.11",
"toml",
@@ -2478,6 +2479,7 @@ dependencies = [
"ruff_text_size",
"rustc-hash 2.1.0",
"salsa",
"schemars",
"serde",
"smallvec",
"static_assertions",
@@ -2525,6 +2527,7 @@ dependencies = [
"regex",
"ruff_db",
"ruff_index",
"ruff_python_ast",
"ruff_python_trivia",
"ruff_source_file",
"ruff_text_size",
@@ -2639,7 +2642,7 @@ dependencies = [
[[package]]
name = "ruff"
version = "0.9.4"
version = "0.9.5"
dependencies = [
"anyhow",
"argfile",
@@ -2767,6 +2770,7 @@ dependencies = [
"ruff_text_size",
"rustc-hash 2.1.0",
"salsa",
"schemars",
"serde",
"tempfile",
"thiserror 2.0.11",
@@ -2791,6 +2795,7 @@ dependencies = [
"libcst",
"pretty_assertions",
"rayon",
"red_knot_project",
"regex",
"ruff",
"ruff_diagnostics",
@@ -2871,7 +2876,7 @@ dependencies = [
[[package]]
name = "ruff_linter"
version = "0.9.4"
version = "0.9.5"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3189,7 +3194,7 @@ dependencies = [
[[package]]
name = "ruff_wasm"
version = "0.9.4"
version = "0.9.5"
dependencies = [
"console_error_panic_hook",
"console_log",

View File

@@ -149,8 +149,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.9.4/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.9.4/install.ps1 | iex"
curl -LsSf https://astral.sh/ruff/0.9.5/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.9.5/install.ps1 | iex"
```
You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff),
@@ -183,7 +183,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.9.4
rev: v0.9.5
hooks:
# Run the linter.
- id: ruff
@@ -452,6 +452,7 @@ Ruff is used by a number of major open-source projects and companies, including:
- ING Bank ([popmon](https://github.com/ing-bank/popmon), [probatus](https://github.com/ing-bank/probatus))
- [Ibis](https://github.com/ibis-project/ibis)
- [ivy](https://github.com/unifyai/ivy)
- [JAX](https://github.com/jax-ml/jax)
- [Jupyter](https://github.com/jupyter-server/jupyter_server)
- [Kraken Tech](https://kraken.tech/)
- [LangChain](https://github.com/hwchase17/langchain)

View File

@@ -28,6 +28,7 @@ pep440_rs = { workspace = true }
rayon = { workspace = true }
rustc-hash = { workspace = true }
salsa = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
@@ -40,8 +41,9 @@ insta = { workspace = true, features = ["redactions", "ron"] }
[features]
default = ["zstd"]
zstd = ["red_knot_vendored/zstd"]
deflate = ["red_knot_vendored/deflate"]
schemars = ["dep:schemars", "ruff_db/schemars", "red_knot_python_semantic/schemars"]
zstd = ["red_knot_vendored/zstd"]
[lints]
workspace = true

View File

@@ -18,13 +18,16 @@ use thiserror::Error;
/// The options for the project.
#[derive(Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Options {
/// Configures the type checking environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<EnvironmentOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub src: Option<SrcOptions>,
/// Configures the enabled lints and their severity.
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Rules>,
}
@@ -177,10 +180,22 @@ impl Options {
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EnvironmentOptions {
/// Specifies the version of Python that will be used to execute the source code.
/// The version should be specified as a string in the format `M.m` where `M` is the major version
/// and `m` is the minor (e.g. "3.0" or "3.6").
/// If a version is provided, knot will generate errors if the source code makes use of language features
/// that are not supported in that version.
/// It will also tailor its use of type stub files, which conditionalizes type definitions based on the version.
#[serde(skip_serializing_if = "Option::is_none")]
pub python_version: Option<RangedValue<PythonVersion>>,
/// Specifies the target platform that will be used to execute the source code.
/// If specified, Red Knot will tailor its use of type stub files,
/// which conditionalize type definitions based on the platform.
///
/// If no platform is specified, knot will use `all` or the current platform in the LSP use case.
#[serde(skip_serializing_if = "Option::is_none")]
pub python_platform: Option<RangedValue<PythonPlatform>>,
@@ -204,6 +219,7 @@ pub struct EnvironmentOptions {
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SrcOptions {
/// The root of the project, used for finding first-party modules.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -212,7 +228,9 @@ pub struct SrcOptions {
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Rules {
#[cfg_attr(feature = "schemars", schemars(with = "schema::Rules"))]
inner: FxHashMap<RangedValue<String>, RangedValue<Level>>,
}
@@ -226,6 +244,69 @@ impl FromIterator<(RangedValue<String>, RangedValue<Level>)> for Rules {
}
}
#[cfg(feature = "schemars")]
mod schema {
use crate::DEFAULT_LINT_REGISTRY;
use red_knot_python_semantic::lint::Level;
use schemars::gen::SchemaGenerator;
use schemars::schema::{
InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SubschemaValidation,
};
use schemars::JsonSchema;
pub(super) struct Rules;
impl JsonSchema for Rules {
fn schema_name() -> String {
"Rules".to_string()
}
fn json_schema(gen: &mut SchemaGenerator) -> Schema {
let registry = &*DEFAULT_LINT_REGISTRY;
let level_schema = gen.subschema_for::<Level>();
let properties: schemars::Map<String, Schema> = registry
.lints()
.iter()
.map(|lint| {
(
lint.name().to_string(),
Schema::Object(SchemaObject {
metadata: Some(Box::new(Metadata {
title: Some(lint.summary().to_string()),
description: Some(lint.documentation()),
deprecated: lint.status.is_deprecated(),
default: Some(lint.default_level.to_string().into()),
..Metadata::default()
})),
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(vec![level_schema.clone()]),
..Default::default()
})),
..Default::default()
}),
)
})
.collect();
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Object.into()),
object: Some(Box::new(ObjectValidation {
properties,
// Allow unknown rules: Red Knot will warn about them.
// It gives a better experience when using an older Red Knot version because
// the schema will not deny rules that have been removed in newer versions.
additional_properties: Some(Box::new(level_schema)),
..ObjectValidation::default()
})),
..Default::default()
})
}
}
}
#[derive(Error, Debug)]
pub enum KnotTomlError {
#[error(transparent)]

View File

@@ -1,8 +1,9 @@
use crate::combine::Combine;
use crate::Db;
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_macros::Combine;
use ruff_text_size::{TextRange, TextSize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::{Deserialize, Deserializer};
use std::cell::RefCell;
use std::cmp::Ordering;
use std::fmt;
@@ -70,15 +71,19 @@ impl Drop for ValueSourceGuard {
///
/// This ensures that two resolved configurations are identical even if the position of a value has changed
/// or if the values were loaded from different sources.
#[derive(Clone)]
#[derive(Clone, serde::Serialize)]
#[serde(transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RangedValue<T> {
value: T,
#[serde(skip)]
source: ValueSource,
/// The byte range of `value` in `source`.
///
/// Can be `None` because not all sources support a range.
/// For example, arguments provided on the CLI won't have a range attached.
#[serde(skip)]
range: Option<TextRange>,
}
@@ -266,18 +271,6 @@ where
}
}
impl<T> Serialize for RangedValue<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.value.serialize(serializer)
}
}
/// A possibly relative path in a configuration file.
///
/// Relative paths in configuration files or from CLI options
@@ -286,9 +279,19 @@ where
/// * CLI: The path is relative to the current working directory
/// * Configuration file: The path is relative to the project's root.
#[derive(
Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
Debug,
Clone,
serde::Serialize,
serde::Deserialize,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Combine,
)]
#[serde(transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RelativePathBuf(RangedValue<SystemPathBuf>);
impl RelativePathBuf {
@@ -325,13 +328,3 @@ impl RelativePathBuf {
SystemPath::absolute(&self.0, relative_to)
}
}
impl Combine for RelativePathBuf {
fn combine(self, other: Self) -> Self {
Self(self.0.combine(other.0))
}
fn combine_with(&mut self, other: Self) {
self.0.combine_with(other.0);
}
}

View File

@@ -270,6 +270,8 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
/// Whether or not the .py/.pyi version of this file is expected to fail
#[rustfmt::skip]
const KNOWN_FAILURES: &[(&str, bool, bool)] = &[
// related to circular references in nested functions
("crates/ruff_linter/resources/test/fixtures/flake8_return/RET503.py", false, true),
// related to circular references in class definitions
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py", true, true),
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py", true, true),

View File

@@ -36,6 +36,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
rustc-hash = { workspace = true }
hashbrown = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
smallvec = { workspace = true }
static_assertions = { workspace = true }

View File

@@ -232,6 +232,36 @@ reveal_type(c_instance.y) # revealed: int
reveal_type(c_instance.z) # revealed: int
```
#### Attributes defined in multi-target assignments
```py
class C:
def __init__(self) -> None:
self.a = self.b = 1
c_instance = C()
reveal_type(c_instance.a) # revealed: Unknown | Literal[1]
reveal_type(c_instance.b) # revealed: Unknown | Literal[1]
```
#### Augmented assignments
```py
class Weird:
def __iadd__(self, other: None) -> str:
return "a"
class C:
def __init__(self) -> None:
self.w = Weird()
self.w += None
# TODO: Mypy and pyright do not support this, but it would be great if we could
# infer `Unknown | str` or at least `Unknown | Weird | str` here.
reveal_type(C().w) # revealed: Unknown | Weird
```
#### Attributes defined in tuple unpackings
```py
@@ -253,19 +283,24 @@ reveal_type(c_instance.b1) # revealed: Unknown | Literal["a"]
reveal_type(c_instance.c1) # revealed: Unknown | int
reveal_type(c_instance.d1) # revealed: Unknown | str
# TODO: This should be supported (no error; type should be: `Unknown | Literal[1]`)
# error: [unresolved-attribute]
reveal_type(c_instance.a2) # revealed: Unknown
reveal_type(c_instance.a2) # revealed: Unknown | Literal[1]
# TODO: This should be supported (no error; type should be: `Unknown | Literal["a"]`)
# error: [unresolved-attribute]
reveal_type(c_instance.b2) # revealed: Unknown
reveal_type(c_instance.b2) # revealed: Unknown | Literal["a"]
# TODO: Similar for these two (should be `Unknown | int` and `Unknown | str`, respectively)
# error: [unresolved-attribute]
reveal_type(c_instance.c2) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(c_instance.d2) # revealed: Unknown
reveal_type(c_instance.c2) # revealed: Unknown | int
reveal_type(c_instance.d2) # revealed: Unknown | str
```
#### Starred assignments
```py
class C:
def __init__(self) -> None:
self.a, *self.b = (1, 2, 3)
c_instance = C()
reveal_type(c_instance.a) # revealed: Unknown | Literal[1]
reveal_type(c_instance.b) # revealed: Unknown | @Todo(starred unpacking)
```
#### Attributes defined in for-loop (unpacking)
@@ -287,6 +322,8 @@ class TupleIterable:
def __iter__(self) -> TupleIterator:
return TupleIterator()
class NonIterable: ...
class C:
def __init__(self):
for self.x in IntIterable():
@@ -295,14 +332,54 @@ class C:
for _, self.y in TupleIterable():
pass
# TODO: Pyright fully supports these, mypy detects the presence of the attributes,
# but infers type `Any` for both of them. We should infer `int` and `str` here:
# TODO: We should emit a diagnostic here
for self.z in NonIterable():
pass
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: Unknown
reveal_type(C().x) # revealed: Unknown | int
reveal_type(C().y) # revealed: Unknown | str
```
#### Attributes defined in `with` statements
```py
class ContextManager:
def __enter__(self) -> int | None: ...
def __exit__(self, exc_type, exc_value, traceback) -> None: ...
class C:
def __init__(self) -> None:
with ContextManager() as self.x:
pass
c_instance = C()
# TODO: Should be `Unknown | int | None`
# error: [unresolved-attribute]
reveal_type(C().y) # revealed: Unknown
reveal_type(c_instance.x) # revealed: Unknown
```
#### Attributes defined in comprehensions
```py
class IntIterator:
def __next__(self) -> int:
return 1
class IntIterable:
def __iter__(self) -> IntIterator:
return IntIterator()
class C:
def __init__(self) -> None:
[... for self.a in IntIterable()]
c_instance = C()
# TODO: Should be `Unknown | int`
# error: [unresolved-attribute]
reveal_type(c_instance.a) # revealed: Unknown
```
#### Conditionally declared / bound attributes
@@ -393,8 +470,6 @@ reveal_type(D().x) # revealed: Unknown | Literal[1]
If `staticmethod` is something else, that should not influence the behavior:
`other.py`:
```py
def staticmethod(f):
return f
@@ -409,8 +484,6 @@ reveal_type(C().x) # revealed: Unknown | Literal[1]
And if `staticmethod` is fully qualified, that should also be recognized:
`fully_qualified.py`:
```py
import builtins
@@ -447,6 +520,15 @@ class C:
reveal_type(C().x) # revealed: str
```
#### Diagnostics are reported for the right-hand side of attribute assignments
```py
class C:
def __init__(self) -> None:
# error: [too-many-positional-arguments]
self.x: int = len(1, 2, 3)
```
### Pure class variables (`ClassVar`)
#### Annotated with `ClassVar` type qualifier
@@ -849,8 +931,6 @@ outer.nested.inner.Outer.Nested.Inner.attr = "a"
Most attribute accesses on function-literal types are delegated to `types.FunctionType`, since all
functions are instances of that class:
`a.py`:
```py
def f(): ...
@@ -860,11 +940,7 @@ reveal_type(f.__kwdefaults__) # revealed: @Todo(generics) | None
Some attributes are special-cased, however:
`b.py`:
```py
def f(): ...
reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions)
reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
```
@@ -874,8 +950,6 @@ reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
Most attribute accesses on int-literal types are delegated to `builtins.int`, since all literal
integers are instances of that class:
`a.py`:
```py
reveal_type((2).bit_length) # revealed: @Todo(bound method)
reveal_type((2).denominator) # revealed: @Todo(@property)
@@ -883,8 +957,6 @@ reveal_type((2).denominator) # revealed: @Todo(@property)
Some attributes are special-cased, however:
`b.py`:
```py
reveal_type((2).numerator) # revealed: Literal[2]
reveal_type((2).real) # revealed: Literal[2]
@@ -895,8 +967,6 @@ reveal_type((2).real) # revealed: Literal[2]
Most attribute accesses on bool-literal types are delegated to `builtins.bool`, since all literal
bols are instances of that class:
`a.py`:
```py
reveal_type(True.__and__) # revealed: @Todo(bound method)
reveal_type(False.__or__) # revealed: @Todo(bound method)
@@ -904,8 +974,6 @@ reveal_type(False.__or__) # revealed: @Todo(bound method)
Some attributes are special-cased, however:
`b.py`:
```py
reveal_type(True.numerator) # revealed: Literal[1]
reveal_type(False.real) # revealed: Literal[0]

View File

@@ -33,8 +33,6 @@ reveal_type(a >= b) # revealed: Literal[False]
Even when tuples have different lengths, comparisons should be handled appropriately.
`different_length.py`:
```py
a = (1, 2, 3)
b = (1, 2, 3, 4)
@@ -104,8 +102,6 @@ reveal_type(a >= b) # revealed: bool
However, if the lexicographic comparison completes without reaching a point where str and int are
compared, Python will still produce a result based on the prior elements.
`short_circuit.py`:
```py
a = (1, 2)
b = (999999, "hello")

View File

@@ -0,0 +1,21 @@
# Unpacking
<!-- snapshot-diagnostics -->
## Right hand side not iterable
```py
a, b = 1 # error: [not-iterable]
```
## Too many values to unpack
```py
a, b = (1, 2, 3) # error: [invalid-assignment]
```
## Too few values to unpack
```py
a, b = (1,) # error: [invalid-assignment]
```

View File

@@ -124,42 +124,49 @@ def _(e: Exception | type[Exception] | None):
## Exception cause is not an exception
```py
try:
raise EOFError() from GeneratorExit # fine
except:
...
def _():
try:
raise EOFError() from GeneratorExit # fine
except:
...
try:
raise StopIteration from MemoryError() # fine
except:
...
def _():
try:
raise StopIteration from MemoryError() # fine
except:
...
try:
raise BufferError() from None # fine
except:
...
def _():
try:
raise BufferError() from None # fine
except:
...
try:
raise ZeroDivisionError from False # error: [invalid-raise]
except:
...
def _():
try:
raise ZeroDivisionError from False # error: [invalid-raise]
except:
...
try:
raise SystemExit from bool() # error: [invalid-raise]
except:
...
def _():
try:
raise SystemExit from bool() # error: [invalid-raise]
except:
...
try:
raise
except KeyboardInterrupt as e: # fine
reveal_type(e) # revealed: KeyboardInterrupt
raise LookupError from e # fine
def _():
try:
raise
except KeyboardInterrupt as e: # fine
reveal_type(e) # revealed: KeyboardInterrupt
raise LookupError from e # fine
try:
raise
except int as e: # error: [invalid-exception-caught]
reveal_type(e) # revealed: Unknown
raise KeyError from e
def _():
try:
raise
except int as e: # error: [invalid-exception-caught]
reveal_type(e) # revealed: Unknown
raise KeyError from e
def _(e: Exception | type[Exception]):
raise ModuleNotFoundError from e # fine

View File

@@ -29,8 +29,6 @@ completing. The type of `x` at the beginning of the `except` suite in this examp
`x = could_raise_returns_str()` redefinition, but we *also* could have jumped to the `except` suite
*after* that redefinition.
`union_type_inferred.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@@ -52,12 +50,7 @@ reveal_type(x) # revealed: str | Literal[2]
If `x` has the same type at the end of both branches, however, the branches unify and `x` is not
inferred as having a union type following the `try`/`except` block:
`branches_unify_to_non_union_type.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
@@ -137,8 +130,6 @@ the `except` suite:
- At the end of `else`, `x == 3`
- At the end of `except`, `x == 2`
`single_except.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@@ -167,9 +158,6 @@ been executed in its entirety, or the `try` suite and the `else` suite must both
in their entireties:
```py
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
@@ -198,8 +186,6 @@ A `finally` suite is *always* executed. As such, if we reach the `reveal_type` c
this example, we know that `x` *must* have been reassigned to `2` during the `finally` suite. The
type of `x` at the end of the example is therefore `Literal[2]`:
`redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@@ -225,12 +211,7 @@ at this point than there were when we were inside the `finally` block.
(Our current model does *not* correctly infer the types *inside* `finally` suites, however; this is
still a TODO item for us.)
`no_redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
@@ -259,8 +240,6 @@ suites:
exception raised in the `except` suite to cause us to jump to the `finally` suite before the
`except` suite ran to completion
`redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@@ -298,18 +277,7 @@ itself. (In some control-flow possibilities, some exceptions were merely *suspen
`finally` suite; these lead to the scope's termination following the conclusion of the `finally`
suite.)
`no_redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
def could_raise_returns_bytes() -> bytes:
return b"foo"
def could_raise_returns_bool() -> bool:
return True
x = 1
try:
@@ -331,18 +299,7 @@ reveal_type(x) # revealed: str | bool
An example with multiple `except` branches and a `finally` branch:
`multiple_except_branches.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
def could_raise_returns_bytes() -> bytes:
return b"foo"
def could_raise_returns_bool() -> bool:
return True
def could_raise_returns_memoryview() -> memoryview:
return memoryview(b"")
@@ -380,8 +337,6 @@ If the exception handler has an `else` branch, we must also take into account th
control flow could have jumped to the `finally` suite from partway through the `else` suite due to
an exception raised *there*.
`single_except_branch.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@@ -425,24 +380,7 @@ reveal_type(x) # revealed: bool | float
The same again, this time with multiple `except` branches:
`multiple_except_branches.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
def could_raise_returns_bytes() -> bytes:
return b"foo"
def could_raise_returns_bool() -> bool:
return True
def could_raise_returns_memoryview() -> memoryview:
return memoryview(b"")
def could_raise_returns_float() -> float:
return 3.14
def could_raise_returns_range() -> range:
return range(42)

View File

@@ -218,3 +218,21 @@ import package
# error: [unresolved-attribute] "Type `<module 'package'>` has no attribute `foo`"
reveal_type(package.foo.X) # revealed: Unknown
```
## Relative imports at the top of a search path
Relative imports at the top of a search path result in a runtime error:
`ImportError: attempted relative import with no known parent package`. That's why Red Knot should
disallow them.
`parser.py`:
```py
X: int = 42
```
`__main__.py`:
```py
from .parser import X # error: [unresolved-import]
```

View File

@@ -29,8 +29,6 @@ def foo():
However, three attributes on `types.ModuleType` are not present as implicit module globals; these
are excluded:
`unbound_dunders.py`:
```py
# error: [unresolved-reference]
# revealed: Unknown
@@ -72,11 +70,7 @@ Typeshed includes a fake `__getattr__` method in the stub for `types.ModuleType`
dynamic imports; but we ignore that for module-literal types where we know exactly which module
we're dealing with:
`__getattr__.py`:
```py
import typing
# error: [unresolved-attribute]
reveal_type(typing.__getattr__) # revealed: Unknown
```

View File

@@ -5,8 +5,6 @@
Parameter `x` of type `str` is shadowed and reassigned with a new `int` value inside the function.
No diagnostics should be generated.
`a.py`:
```py
def f(x: str):
x: int = int(x)
@@ -14,8 +12,6 @@ def f(x: str):
## Implicit error
`a.py`:
```py
def f(): ...
@@ -24,8 +20,6 @@ f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explici
## Explicit shadowing
`a.py`:
```py
def f(): ...

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
@@ -19,7 +19,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:8
--> /src/mdtest_snippet.py:1:8
|
1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
| ^^^^^^^^^^^^^^ Cannot resolve import `zqzqzqzqzqzqzq`

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | # Topmost component resolvable, submodule not resolvable:
@@ -28,7 +28,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:2:8
--> /src/mdtest_snippet.py:2:8
|
1 | # Topmost component resolvable, submodule not resolvable:
2 | import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
@@ -41,7 +41,7 @@ error: lint:unresolved-import
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:5:8
--> /src/mdtest_snippet.py:5:8
|
4 | # Topmost component unresolvable:
5 | import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`"

View File

@@ -0,0 +1,28 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: unpacking.md - Unpacking - Right hand side not iterable
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
---
# Python source files
## mdtest_snippet.py
```
1 | a, b = 1 # error: [not-iterable]
```
# Diagnostics
```
error: lint:not-iterable
--> /src/mdtest_snippet.py:1:8
|
1 | a, b = 1 # error: [not-iterable]
| ^ Object of type `Literal[1]` is not iterable
|
```

View File

@@ -0,0 +1,28 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: unpacking.md - Unpacking - Too few values to unpack
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
---
# Python source files
## mdtest_snippet.py
```
1 | a, b = (1,) # error: [invalid-assignment]
```
# Diagnostics
```
error: lint:invalid-assignment
--> /src/mdtest_snippet.py:1:1
|
1 | a, b = (1,) # error: [invalid-assignment]
| ^^^^ Not enough values to unpack (expected 2, got 1)
|
```

View File

@@ -0,0 +1,28 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: unpacking.md - Unpacking - Too many values to unpack
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
---
# Python source files
## mdtest_snippet.py
```
1 | a, b = (1, 2, 3) # error: [invalid-assignment]
```
# Diagnostics
```
error: lint:invalid-assignment
--> /src/mdtest_snippet.py:1:1
|
1 | a, b = (1, 2, 3) # error: [invalid-assignment]
| ^^^^ Too many values to unpack (expected 2, got 3)
|
```

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | import does_not_exist # error: [unresolved-import]
@@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:8
--> /src/mdtest_snippet.py:1:8
|
1 | import does_not_exist # error: [unresolved-import]
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`

View File

@@ -16,7 +16,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
2 | does_exist2 = 2
```
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import]
@@ -26,7 +26,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:28
--> /src/mdtest_snippet.py:1:28
|
1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import]
| ^^^^^^^^^^^^^^ Module `a` has no member `does_not_exist`

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | from .does_not_exist import add # error: [unresolved-import]
@@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:7
--> /src/mdtest_snippet.py:1:7
|
1 | from .does_not_exist import add # error: [unresolved-import]
| ^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist`

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | from .does_not_exist.foo.bar import add # error: [unresolved-import]
@@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:7
--> /src/mdtest_snippet.py:1:7
|
1 | from .does_not_exist.foo.bar import add # error: [unresolved-import]
| ^^^^^^^^^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist.foo.bar`

View File

@@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
# Python source files
## mdtest_snippet__1.py
## mdtest_snippet.py
```
1 | from does_not_exist import add # error: [unresolved-import]
@@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso
```
error: lint:unresolved-import
--> /src/mdtest_snippet__1.py:1:6
--> /src/mdtest_snippet.py:1:6
|
1 | from does_not_exist import add # error: [unresolved-import]
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`

View File

@@ -7,43 +7,36 @@ branches whose conditions we can statically determine to be always true or alway
useful for `sys.version_info` branches, which can make new features available based on the Python
version:
`module1.py`:
If we can statically determine that the condition is always true, then we can also understand that
`SomeFeature` is always bound, without raising any errors:
```py
import sys
if sys.version_info >= (3, 9):
SomeFeature: str = "available"
```
class C:
if sys.version_info >= (3, 9):
SomeFeature: str = "available"
If we can statically determine that the condition is always true, then we can also understand that
`SomeFeature` is always bound, without raising any errors:
`test1.py`:
```py
from module1 import SomeFeature
# SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
reveal_type(SomeFeature) # revealed: str
# C.SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
reveal_type(C.SomeFeature) # revealed: str
```
Another scenario where this is useful is for `typing.TYPE_CHECKING` branches, which are often used
for conditional imports:
`module2.py`:
`module.py`:
```py
class SomeType: ...
```
`test2.py`:
`main.py`:
```py
import typing
if typing.TYPE_CHECKING:
from module2 import SomeType
from module import SomeType
# `SomeType` is unconditionally available here for type checkers:
def f(s: SomeType) -> None: ...

View File

@@ -37,8 +37,6 @@ child expression now suppresses errors in the outer expression.
For example, the `type: ignore` comment in this example suppresses the error of adding `2` to
`"test"` and adding `"other"` to the result of the cast.
`nested.py`:
```py
# fmt: off
from typing import cast

View File

@@ -109,8 +109,6 @@ reveal_type(version_info >= (3, 9)) # revealed: bool
The fields of `sys.version_info` can be accessed by name:
`a.py`:
```py
import sys
@@ -122,11 +120,7 @@ reveal_type(sys.version_info.minor >= 10) # revealed: Literal[False]
But the `micro`, `releaselevel` and `serial` fields are inferred as `@Todo` until we support
properties on instance types:
`b.py`:
```py
import sys
reveal_type(sys.version_info.micro) # revealed: @Todo(@property)
reveal_type(sys.version_info.releaselevel) # revealed: @Todo(@property)
reveal_type(sys.version_info.serial) # revealed: @Todo(@property)

View File

@@ -452,6 +452,9 @@ def raise_in_both_branches(cond: bool):
# Exceptions can occur anywhere, so "before" and "raise" are valid possibilities
reveal_type(x) # revealed: Literal["before", "raise1", "raise2"]
else:
# This branch is unreachable, since all control flows in the `try` clause raise exceptions.
# As a result, this binding should never be reachable, since new bindings are visible only
# when they are reachable.
x = "unreachable"
finally:
# Exceptions can occur anywhere, so "before" and "raise" are valid possibilities
@@ -623,9 +626,9 @@ def return_from_nested_if(cond1: bool, cond2: bool):
## Statically known terminal statements
Terminal statements do not yet interact correctly with statically known bounds. In this example, we
should see that the `return` statement is always executed, and therefore that the `"b"` assignment
is not visible to the `reveal_type`.
We model reachability using the same visibility constraints that we use to model statically known
bounds. In this example, we see that the `return` statement is always executed, and therefore that
the `"b"` assignment is not visible to the `reveal_type`.
```py
def _(cond: bool):
@@ -635,6 +638,26 @@ def _(cond: bool):
if True:
return
# TODO: Literal["a"]
reveal_type(x) # revealed: Literal["a", "b"]
reveal_type(x) # revealed: Literal["a"]
```
## Bindings after a terminal statement are unreachable
Any bindings introduced after a terminal statement are unreachable, and are currently considered not
visible. We [anticipate](https://github.com/astral-sh/ruff/issues/15797) that we want to provide a
more useful analysis for code after terminal statements.
```py
def f(cond: bool) -> str:
x = "before"
if cond:
reveal_type(x) # revealed: Literal["before"]
return
x = "after-return"
# TODO: no unresolved-reference error
# error: [unresolved-reference]
reveal_type(x) # revealed: Unknown
else:
x = "else"
reveal_type(x) # revealed: Literal["else"]
```

View File

@@ -84,8 +84,11 @@ def explicit_unknown(x: Unknown, y: tuple[str, Unknown], z: Unknown = 1) -> None
reveal_type(x) # revealed: Unknown
reveal_type(y) # revealed: tuple[str, Unknown]
reveal_type(z) # revealed: Unknown | Literal[1]
```
# Unknown can be subclassed, just like Any
`Unknown` can be subclassed, just like `Any`:
```py
class C(Unknown): ...
# revealed: tuple[Literal[C], Unknown, Literal[object]]
@@ -238,9 +241,12 @@ error_message = "A custom message "
error_message += "constructed from multiple string literals"
# error: "Static assertion error: A custom message constructed from multiple string literals"
static_assert(False, error_message)
```
# There are limitations to what we can still infer as a string literal. In those cases,
# we simply fall back to the default message.
There are limitations to what we can still infer as a string literal. In those cases, we simply fall
back to the default message:
```py
shouted_message = "A custom message".upper()
# error: "Static assertion error: argument evaluates to `False`"
static_assert(False, shouted_message)
@@ -371,8 +377,11 @@ static_assert(is_subtype_of(TypeOf[str], type[str]))
class Base: ...
class Derived(Base): ...
```
# `TypeOf` can be used in annotations:
`TypeOf` can also be used in annotations:
```py
def type_of_annotation() -> None:
t1: TypeOf[Base] = Base
t2: TypeOf[Base] = Derived # error: [invalid-assignment]

View File

@@ -132,6 +132,27 @@ static_assert(not is_disjoint_from(Intersection[X, Z], Y))
static_assert(not is_disjoint_from(Intersection[Y, Z], X))
```
## Negation / complement
The complement of a type `T` is disjoint from `T`. In fact, it is disjoint from every subtype of
`T`:
```py
from knot_extensions import Not, Intersection, is_disjoint_from, static_assert
class T: ...
class S(T): ...
static_assert(is_disjoint_from(Not[T], T))
static_assert(is_disjoint_from(Not[T], S))
static_assert(is_disjoint_from(Intersection[T, Any], Not[T]))
static_assert(is_disjoint_from(Not[T], Intersection[T, Any]))
static_assert(is_disjoint_from(Intersection[S, Any], Not[T]))
static_assert(is_disjoint_from(Not[T], Intersection[S, Any]))
```
## Special types
### `Never`
@@ -244,7 +265,7 @@ static_assert(not is_disjoint_from(TypeOf[f], object))
### `AlwaysTruthy` and `AlwaysFalsy`
```py
from knot_extensions import AlwaysFalsy, AlwaysTruthy, is_disjoint_from, static_assert
from knot_extensions import AlwaysFalsy, AlwaysTruthy, Intersection, Not, is_disjoint_from, static_assert
from typing import Literal
static_assert(is_disjoint_from(None, AlwaysTruthy))
@@ -256,6 +277,14 @@ static_assert(not is_disjoint_from(str, AlwaysTruthy))
static_assert(is_disjoint_from(Literal[1, 2], AlwaysFalsy))
static_assert(not is_disjoint_from(Literal[0, 1], AlwaysTruthy))
type Truthy = Not[AlwaysFalsy]
type Falsy = Not[AlwaysTruthy]
type AmbiguousTruthiness = Intersection[Truthy, Falsy]
static_assert(is_disjoint_from(AlwaysTruthy, AmbiguousTruthiness))
static_assert(is_disjoint_from(AlwaysFalsy, AmbiguousTruthiness))
```
### Instance types versus `type[T]` types

View File

@@ -19,11 +19,17 @@ static_assert(is_equivalent_to(Never, tuple[int, Never]))
static_assert(is_equivalent_to(Never, tuple[int, Never, str]))
static_assert(is_equivalent_to(Never, tuple[int, tuple[str, Never]]))
static_assert(is_equivalent_to(Never, tuple[tuple[str, Never], int]))
```
# The empty tuple is *not* equivalent to Never!
The empty `tuple` is *not* equivalent to `Never`!
```py
static_assert(not is_equivalent_to(Never, tuple[()]))
```
# NoReturn is just a different spelling of Never, so the same is true for NoReturn
`NoReturn` is just a different spelling of `Never`, so the same is true for `NoReturn`:
```py
static_assert(is_equivalent_to(NoReturn, tuple[NoReturn]))
static_assert(is_equivalent_to(NoReturn, tuple[NoReturn, int]))
static_assert(is_equivalent_to(NoReturn, tuple[int, NoReturn]))

View File

@@ -1,6 +1,8 @@
use core::fmt;
use itertools::Itertools;
use ruff_db::diagnostic::{DiagnosticId, LintName, Severity};
use rustc_hash::FxHashMap;
use std::fmt::Formatter;
use std::hash::Hasher;
use thiserror::Error;
@@ -36,13 +38,20 @@ pub struct LintMetadata {
derive(serde::Serialize, serde::Deserialize),
serde(rename_all = "kebab-case")
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Level {
/// # Ignore
///
/// The lint is disabled and should not run.
Ignore,
/// # Warn
///
/// The lint is enabled and diagnostic should have a warning severity.
Warn,
/// # Error
///
/// The lint is enabled and diagnostics have an error severity.
Error,
}
@@ -61,6 +70,16 @@ impl Level {
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Level::Ignore => f.write_str("ignore"),
Level::Warn => f.write_str("warn"),
Level::Error => f.write_str("error"),
}
}
}
impl TryFrom<Level> for Severity {
type Error = ();
@@ -84,9 +103,11 @@ impl LintMetadata {
/// Returns the documentation line by line with one leading space and all trailing whitespace removed.
pub fn documentation_lines(&self) -> impl Iterator<Item = &str> {
self.raw_documentation
.lines()
.map(|line| line.strip_prefix(' ').unwrap_or(line).trim_end())
self.raw_documentation.lines().map(|line| {
line.strip_prefix(char::is_whitespace)
.unwrap_or(line)
.trim_end()
})
}
/// Returns the documentation as a single string.
@@ -180,6 +201,10 @@ impl LintStatus {
pub const fn is_removed(&self) -> bool {
matches!(self, LintStatus::Removed { .. })
}
pub const fn is_deprecated(&self) -> bool {
matches!(self, LintStatus::Deprecated { .. })
}
}
/// Declares a lint rule with the given metadata.
@@ -223,7 +248,7 @@ macro_rules! declare_lint {
$vis static $name: $crate::lint::LintMetadata = $crate::lint::LintMetadata {
name: ruff_db::diagnostic::LintName::of(ruff_macros::kebab_case!($name)),
summary: $summary,
raw_documentation: concat!($($doc,)+ "\n"),
raw_documentation: concat!($($doc, '\n',)+),
status: $status,
file: file!(),
line: line!(),

View File

@@ -11,6 +11,7 @@ pub enum PythonPlatform {
/// Do not make any assumptions about the target platform.
#[default]
All,
/// Assume a specific target platform like `linux`, `darwin` or `win32`.
///
/// We use a string (instead of individual enum variants), as the set of possible platforms
@@ -28,3 +29,77 @@ impl Display for PythonPlatform {
}
}
}
#[cfg(feature = "schemars")]
mod schema {
use crate::PythonPlatform;
use schemars::_serde_json::Value;
use schemars::gen::SchemaGenerator;
use schemars::schema::{Metadata, Schema, SchemaObject, SubschemaValidation};
use schemars::JsonSchema;
impl JsonSchema for PythonPlatform {
fn schema_name() -> String {
"PythonPlatform".to_string()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
// Hard code some well known values, but allow any other string as well.
subschemas: Some(Box::new(SubschemaValidation {
any_of: Some(vec![
Schema::Object(SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
..SchemaObject::default()
}),
// Promote well-known values for better auto-completion.
// Using `const` over `enumValues` as recommended [here](https://github.com/SchemaStore/schemastore/blob/master/CONTRIBUTING.md#documenting-enums).
Schema::Object(SchemaObject {
const_value: Some(Value::String("all".to_string())),
metadata: Some(Box::new(Metadata {
description: Some(
"Do not make any assumptions about the target platform."
.to_string(),
),
..Metadata::default()
})),
..SchemaObject::default()
}),
Schema::Object(SchemaObject {
const_value: Some(Value::String("darwin".to_string())),
metadata: Some(Box::new(Metadata {
description: Some("Darwin".to_string()),
..Metadata::default()
})),
..SchemaObject::default()
}),
Schema::Object(SchemaObject {
const_value: Some(Value::String("linux".to_string())),
metadata: Some(Box::new(Metadata {
description: Some("Linux".to_string()),
..Metadata::default()
})),
..SchemaObject::default()
}),
Schema::Object(SchemaObject {
const_value: Some(Value::String("win32".to_string())),
metadata: Some(Box::new(Metadata {
description: Some("Windows".to_string()),
..Metadata::default()
})),
..SchemaObject::default()
}),
]),
..SubschemaValidation::default()
})),
..SchemaObject::default()
})
}
}
}

View File

@@ -31,6 +31,20 @@ impl PythonVersion {
minor: 13,
};
pub fn iter() -> impl Iterator<Item = PythonVersion> {
[
PythonVersion::PY37,
PythonVersion::PY38,
PythonVersion::PY39,
PythonVersion::PY310,
PythonVersion::PY311,
PythonVersion::PY312,
PythonVersion::PY313,
]
.iter()
.copied()
}
pub fn free_threaded_build_available(self) -> bool {
self >= PythonVersion::PY313
}
@@ -69,40 +83,86 @@ impl fmt::Display for PythonVersion {
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PythonVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let as_str = String::deserialize(deserializer)?;
mod serde {
use crate::PythonVersion;
if let Some((major, minor)) = as_str.split_once('.') {
let major = major
.parse()
.map_err(|err| serde::de::Error::custom(format!("invalid major version: {err}")))?;
let minor = minor
.parse()
.map_err(|err| serde::de::Error::custom(format!("invalid minor version: {err}")))?;
impl<'de> serde::Deserialize<'de> for PythonVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let as_str = String::deserialize(deserializer)?;
Ok((major, minor).into())
} else {
let major = as_str.parse().map_err(|err| {
serde::de::Error::custom(format!(
"invalid python-version: {err}, expected: `major.minor`"
))
})?;
if let Some((major, minor)) = as_str.split_once('.') {
let major = major.parse().map_err(|err| {
serde::de::Error::custom(format!("invalid major version: {err}"))
})?;
let minor = minor.parse().map_err(|err| {
serde::de::Error::custom(format!("invalid minor version: {err}"))
})?;
Ok((major, 0).into())
Ok((major, minor).into())
} else {
let major = as_str.parse().map_err(|err| {
serde::de::Error::custom(format!(
"invalid python-version: {err}, expected: `major.minor`"
))
})?;
Ok((major, 0).into())
}
}
}
impl serde::Serialize for PythonVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for PythonVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
#[cfg(feature = "schemars")]
mod schemars {
use super::PythonVersion;
use schemars::schema::{Metadata, Schema, SchemaObject, SubschemaValidation};
use schemars::JsonSchema;
use schemars::_serde_json::Value;
impl JsonSchema for PythonVersion {
fn schema_name() -> String {
"PythonVersion".to_string()
}
fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> Schema {
let sub_schemas = std::iter::once(Schema::Object(SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some(r"^\d+\.\d+$".to_string()),
..Default::default()
})),
..Default::default()
}))
.chain(Self::iter().map(|v| {
Schema::Object(SchemaObject {
const_value: Some(Value::String(v.to_string())),
metadata: Some(Box::new(Metadata {
description: Some(format!("Python {v}")),
..Metadata::default()
})),
..SchemaObject::default()
})
}));
Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
any_of: Some(sub_schemas.collect()),
..Default::default()
})),
..SchemaObject::default()
})
}
}
}

View File

@@ -1,4 +1,7 @@
use crate::semantic_index::expression::Expression;
use crate::{
semantic_index::{ast_ids::ScopedExpressionId, expression::Expression},
unpack::Unpack,
};
use ruff_python_ast::name::Name;
@@ -14,6 +17,17 @@ pub(crate) enum AttributeAssignment<'db> {
/// An attribute assignment without a type annotation, e.g. `self.x = <value>`.
Unannotated { value: Expression<'db> },
/// An attribute assignment where the right-hand side is an iterable, for example
/// `for self.x in <iterable>`.
Iterable { iterable: Expression<'db> },
/// An attribute assignment where the left-hand side is an unpacking expression,
/// e.g. `self.x, self.y = <value>`.
Unpack {
attribute_expression_id: ScopedExpressionId,
unpack: Unpack<'db>,
},
}
pub(crate) type AttributeAssignments<'db> = FxHashMap<Name, Vec<AttributeAssignment<'db>>>;

View File

@@ -6,9 +6,9 @@ use rustc_hash::{FxHashMap, FxHashSet};
use ruff_db::files::File;
use ruff_db::parsed::ParsedModule;
use ruff_index::IndexVec;
use ruff_python_ast as ast;
use ruff_python_ast::name::Name;
use ruff_python_ast::visitor::{walk_expr, walk_pattern, walk_stmt, Visitor};
use ruff_python_ast::{self as ast, ExprContext};
use crate::ast_node_ref::AstNodeRef;
use crate::module_name::ModuleName;
@@ -793,9 +793,30 @@ where
&mut builder.current_first_parameter_name,
&mut first_parameter_name,
);
builder.visit_body(body);
builder.current_first_parameter_name = first_parameter_name;
// TODO: Fix how we determine the public types of symbols in a
// function-like scope: https://github.com/astral-sh/ruff/issues/15777
//
// In the meantime, visit the function body, but treat the last statement
// specially if it is a return. If it is, this would cause all definitions
// in the function to be marked as non-visible with our current treatment
// of terminal statements. Since we currently model the externally visible
// definitions in a function scope as the set of bindings that are visible
// at the end of the body, we then consider this function to have no
// externally visible definitions. To get around this, we take a flow
// snapshot just before processing the return statement, and use _that_ as
// the "end-of-body" state that we resolve external references against.
if let Some((last_stmt, first_stmts)) = body.split_last() {
builder.visit_body(first_stmts);
let pre_return_state = matches!(last_stmt, ast::Stmt::Return(_))
.then(|| builder.flow_snapshot());
builder.visit_stmt(last_stmt);
if let Some(pre_return_state) = pre_return_state {
builder.flow_restore(pre_return_state);
}
}
builder.current_first_parameter_name = first_parameter_name;
builder.pop_scope()
},
);
@@ -1210,6 +1231,20 @@ where
unpack: None,
first: false,
}),
ast::Expr::Attribute(ast::ExprAttribute {
value: object,
attr,
..
}) => {
self.register_attribute_assignment(
object,
attr,
AttributeAssignment::Iterable {
iterable: iter_expr,
},
);
None
}
_ => None,
};
@@ -1438,7 +1473,7 @@ where
fn visit_expr(&mut self, expr: &'ast ast::Expr) {
self.scopes_by_expression
.insert(expr.into(), self.current_scope());
self.current_ast_ids().record_expression(expr);
let expression_id = self.current_ast_ids().record_expression(expr);
match expr {
ast::Expr::Name(name_node @ ast::ExprName { id, ctx, .. }) => {
@@ -1697,6 +1732,35 @@ where
self.simplify_visibility_constraints(pre_op);
}
ast::Expr::Attribute(ast::ExprAttribute {
value: object,
attr,
ctx: ExprContext::Store,
range: _,
}) => {
if let Some(
CurrentAssignment::Assign {
unpack: Some(unpack),
..
}
| CurrentAssignment::For {
unpack: Some(unpack),
..
},
) = self.current_assignment()
{
self.register_attribute_assignment(
object,
attr,
AttributeAssignment::Unpack {
attribute_expression_id: expression_id,
unpack,
},
);
}
walk_expr(self, expr);
}
_ => {
walk_expr(self, expr);
}

View File

@@ -478,7 +478,6 @@ impl std::iter::FusedIterator for DeclarationsIterator<'_, '_> {}
pub(super) struct FlowSnapshot {
symbol_states: IndexVec<ScopedSymbolId, SymbolState>,
scope_start_visibility: ScopedVisibilityConstraintId,
reachable: bool,
}
#[derive(Debug)]
@@ -506,8 +505,6 @@ pub(super) struct UseDefMapBuilder<'db> {
/// Currently live bindings and declarations for each symbol.
symbol_states: IndexVec<ScopedSymbolId, SymbolState>,
reachable: bool,
}
impl Default for UseDefMapBuilder<'_> {
@@ -520,14 +517,13 @@ impl Default for UseDefMapBuilder<'_> {
bindings_by_use: IndexVec::new(),
definitions_by_definition: FxHashMap::default(),
symbol_states: IndexVec::new(),
reachable: true,
}
}
}
impl<'db> UseDefMapBuilder<'db> {
pub(super) fn mark_unreachable(&mut self) {
self.reachable = false;
self.record_visibility_constraint(ScopedVisibilityConstraintId::ALWAYS_FALSE);
}
pub(super) fn add_symbol(&mut self, symbol: ScopedSymbolId) {
@@ -544,7 +540,7 @@ impl<'db> UseDefMapBuilder<'db> {
binding,
SymbolDefinitions::Declarations(symbol_state.declarations().clone()),
);
symbol_state.record_binding(def_id);
symbol_state.record_binding(def_id, self.scope_start_visibility);
}
pub(super) fn add_constraint(&mut self, constraint: Constraint<'db>) -> ScopedConstraintId {
@@ -596,7 +592,11 @@ impl<'db> UseDefMapBuilder<'db> {
pub(super) fn simplify_visibility_constraints(&mut self, snapshot: FlowSnapshot) {
debug_assert!(self.symbol_states.len() >= snapshot.symbol_states.len());
self.scope_start_visibility = snapshot.scope_start_visibility;
// If there are any control flow paths that have become unreachable between `snapshot` and
// now, then it's not valid to simplify any visibility constraints to `snapshot`.
if self.scope_start_visibility != snapshot.scope_start_visibility {
return;
}
// Note that this loop terminates when we reach a symbol not present in the snapshot.
// This means we keep visibility constraints for all new symbols, which is intended,
@@ -632,7 +632,7 @@ impl<'db> UseDefMapBuilder<'db> {
let def_id = self.all_definitions.push(Some(definition));
let symbol_state = &mut self.symbol_states[symbol];
symbol_state.record_declaration(def_id);
symbol_state.record_binding(def_id);
symbol_state.record_binding(def_id, self.scope_start_visibility);
}
pub(super) fn record_use(&mut self, symbol: ScopedSymbolId, use_id: ScopedUseId) {
@@ -649,7 +649,6 @@ impl<'db> UseDefMapBuilder<'db> {
FlowSnapshot {
symbol_states: self.symbol_states.clone(),
scope_start_visibility: self.scope_start_visibility,
reachable: self.reachable,
}
}
@@ -672,21 +671,23 @@ impl<'db> UseDefMapBuilder<'db> {
num_symbols,
SymbolState::undefined(self.scope_start_visibility),
);
self.reachable = snapshot.reachable;
}
/// Merge the given snapshot into the current state, reflecting that we might have taken either
/// path to get here. The new state for each symbol should include definitions from both the
/// prior state and the snapshot.
pub(super) fn merge(&mut self, snapshot: FlowSnapshot) {
// Unreachable snapshots should not be merged: If the current snapshot is unreachable, it
// should be completely overwritten by the snapshot we're merging in. If the other snapshot
// is unreachable, we should return without merging.
if !snapshot.reachable {
// As an optimization, if we know statically that either of the snapshots is always
// unreachable, we can leave it out of the merged result entirely. Note that we cannot
// perform any type inference at this point, so this is largely limited to unreachability
// via terminal statements. If a flow's reachability depends on an expression in the code,
// we will include the flow in the merged result; the visibility constraints of its
// bindings will include this reachability condition, so that later during type inference,
// we can determine whether any particular binding is non-visible due to unreachability.
if snapshot.scope_start_visibility == ScopedVisibilityConstraintId::ALWAYS_FALSE {
return;
}
if !self.reachable {
if self.scope_start_visibility == ScopedVisibilityConstraintId::ALWAYS_FALSE {
self.restore(snapshot);
return;
}
@@ -712,9 +713,6 @@ impl<'db> UseDefMapBuilder<'db> {
self.scope_start_visibility = self
.visibility_constraints
.add_or_constraint(self.scope_start_visibility, snapshot.scope_start_visibility);
// Both of the snapshots are reachable, so the merged result is too.
self.reachable = true;
}
pub(super) fn finish(mut self) -> UseDefMap<'db> {

View File

@@ -237,7 +237,11 @@ impl SymbolBindings {
}
/// Record a newly-encountered binding for this symbol.
pub(super) fn record_binding(&mut self, binding_id: ScopedDefinitionId) {
pub(super) fn record_binding(
&mut self,
binding_id: ScopedDefinitionId,
visibility_constraint: ScopedVisibilityConstraintId,
) {
// The new binding replaces all previous live bindings in this path, and has no
// constraints.
self.live_bindings = Bindings::with(binding_id.into());
@@ -245,8 +249,7 @@ impl SymbolBindings {
self.constraints.push(Constraints::default());
self.visibility_constraints = VisibilityConstraintPerBinding::with_capacity(1);
self.visibility_constraints
.push(ScopedVisibilityConstraintId::ALWAYS_TRUE);
self.visibility_constraints.push(visibility_constraint);
}
/// Add given constraint to all live bindings.
@@ -349,9 +352,14 @@ impl SymbolState {
}
/// Record a newly-encountered binding for this symbol.
pub(super) fn record_binding(&mut self, binding_id: ScopedDefinitionId) {
pub(super) fn record_binding(
&mut self,
binding_id: ScopedDefinitionId,
visibility_constraint: ScopedVisibilityConstraintId,
) {
debug_assert_ne!(binding_id, ScopedDefinitionId::UNBOUND);
self.bindings.record_binding(binding_id);
self.bindings
.record_binding(binding_id, visibility_constraint);
}
/// Add given constraint to all live bindings.
@@ -557,7 +565,10 @@ mod tests {
#[test]
fn with() {
let mut sym = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym.record_binding(ScopedDefinitionId::from_u32(1));
sym.record_binding(
ScopedDefinitionId::from_u32(1),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
assert_bindings(&sym, &["1<>"]);
}
@@ -565,7 +576,10 @@ mod tests {
#[test]
fn record_constraint() {
let mut sym = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym.record_binding(ScopedDefinitionId::from_u32(1));
sym.record_binding(
ScopedDefinitionId::from_u32(1),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym.record_constraint(ScopedConstraintId::from_u32(0));
assert_bindings(&sym, &["1<0>"]);
@@ -577,11 +591,17 @@ mod tests {
// merging the same definition with the same constraint keeps the constraint
let mut sym1a = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym1a.record_binding(ScopedDefinitionId::from_u32(1));
sym1a.record_binding(
ScopedDefinitionId::from_u32(1),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym1a.record_constraint(ScopedConstraintId::from_u32(0));
let mut sym1b = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym1b.record_binding(ScopedDefinitionId::from_u32(1));
sym1b.record_binding(
ScopedDefinitionId::from_u32(1),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym1b.record_constraint(ScopedConstraintId::from_u32(0));
sym1a.merge(sym1b, &mut visibility_constraints);
@@ -590,11 +610,17 @@ mod tests {
// merging the same definition with differing constraints drops all constraints
let mut sym2a = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym2a.record_binding(ScopedDefinitionId::from_u32(2));
sym2a.record_binding(
ScopedDefinitionId::from_u32(2),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym2a.record_constraint(ScopedConstraintId::from_u32(1));
let mut sym1b = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym1b.record_binding(ScopedDefinitionId::from_u32(2));
sym1b.record_binding(
ScopedDefinitionId::from_u32(2),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym1b.record_constraint(ScopedConstraintId::from_u32(2));
sym2a.merge(sym1b, &mut visibility_constraints);
@@ -603,7 +629,10 @@ mod tests {
// merging a constrained definition with unbound keeps both
let mut sym3a = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);
sym3a.record_binding(ScopedDefinitionId::from_u32(3));
sym3a.record_binding(
ScopedDefinitionId::from_u32(3),
ScopedVisibilityConstraintId::ALWAYS_TRUE,
);
sym3a.record_constraint(ScopedConstraintId::from_u32(3));
let sym2b = SymbolState::undefined(ScopedVisibilityConstraintId::ALWAYS_TRUE);

View File

@@ -40,6 +40,7 @@ use crate::types::call::{
};
use crate::types::class_base::ClassBase;
use crate::types::diagnostic::INVALID_TYPE_FORM;
use crate::types::infer::infer_unpack_types;
use crate::types::mro::{Mro, MroError, MroIterator};
use crate::types::narrow::narrowing_constraint;
use crate::{Db, FxOrderSet, Module, Program, PythonVersion};
@@ -1260,19 +1261,42 @@ impl<'db> Type<'db> {
.iter()
.all(|e| e.is_disjoint_from(db, other)),
(Type::Intersection(intersection), other)
| (other, Type::Intersection(intersection)) => {
if intersection
(Type::Intersection(inter_left), Type::Intersection(inter_right)) => {
// We explicitly make this case a symmetric version of the case below, as there
// are some type pairs like `Any & T` and `~T` that would otherwise lead to non-
// symmetric results.
inter_left
.positive(db)
.iter()
.any(|p| p.is_disjoint_from(db, other))
{
true
} else {
// TODO we can do better here. For example:
// X & ~Literal[1] is disjoint from Literal[1]
false
}
|| inter_right
.positive(db)
.iter()
.any(|p| p.is_disjoint_from(db, self))
|| inter_left.negative(db).iter().any(|n| {
other.is_subtype_of(db, *n)
&& self.is_fully_static(db)
&& other.is_fully_static(db)
})
|| inter_right.negative(db).iter().any(|n| {
self.is_subtype_of(db, *n)
&& self.is_fully_static(db)
&& other.is_fully_static(db)
})
}
(Type::Intersection(intersection), t) | (t, Type::Intersection(intersection)) => {
// TODO: There are certainly more cases that could be handled here. For example,
// it is possible that both A and B overlap with C, but the intersection A & B
// does not overlap with C.
intersection
.positive(db)
.iter()
.any(|p| p.is_disjoint_from(db, t))
|| intersection.negative(db).iter().any(|n| {
t.is_subtype_of(db, *n)
&& self.is_fully_static(db)
&& other.is_fully_static(db)
})
}
// any single-valued type is disjoint from another single-valued type
@@ -4231,6 +4255,32 @@ impl<'db> Class<'db> {
union_of_inferred_types = union_of_inferred_types.add(inferred_ty);
}
AttributeAssignment::Iterable { iterable } => {
// We found an attribute assignment like:
//
// for self.name in <iterable>:
// TODO: Potential diagnostics resulting from the iterable are currently not reported.
let iterable_ty = infer_expression_type(db, *iterable);
let inferred_ty = iterable_ty.iterate(db).unwrap_without_diagnostic();
union_of_inferred_types = union_of_inferred_types.add(inferred_ty);
}
AttributeAssignment::Unpack {
attribute_expression_id,
unpack,
} => {
// We found an unpacking assignment like:
//
// .., self.name, .. = <value>
// (.., self.name, ..) = <value>
// [.., self.name, ..] = <value>
let inferred_ty =
infer_unpack_types(db, *unpack).expression_type(*attribute_expression_id);
union_of_inferred_types = union_of_inferred_types.add(inferred_ty);
}
}
}

View File

@@ -200,7 +200,7 @@ pub(crate) fn infer_expression_types<'db>(
/// type of the variables involved in this unpacking along with any violations that are detected
/// during this unpacking.
#[salsa::tracked(return_ref)]
fn infer_unpack_types<'db>(db: &'db dyn Db, unpack: Unpack<'db>) -> UnpackResult<'db> {
pub(super) fn infer_unpack_types<'db>(db: &'db dyn Db, unpack: Unpack<'db>) -> UnpackResult<'db> {
let file = unpack.file(db);
let _span =
tracing::trace_span!("infer_unpack_types", range=?unpack.range(db), file=%file.path(db))
@@ -2085,7 +2085,7 @@ impl<'db> TypeInferenceBuilder<'db> {
}
let name_ast_id = name.scoped_expression_id(self.db(), self.scope());
unpacked.get(name_ast_id).unwrap_or(Type::unknown())
unpacked.expression_type(name_ast_id)
}
TargetKind::Name => {
if self.in_stub() && value.is_ellipsis_literal_expr() {
@@ -2356,7 +2356,7 @@ impl<'db> TypeInferenceBuilder<'db> {
self.context.extend(unpacked);
}
let name_ast_id = name.scoped_expression_id(self.db(), self.scope());
unpacked.get(name_ast_id).unwrap_or(Type::unknown())
unpacked.expression_type(name_ast_id)
}
TargetKind::Name => iterable_ty
.iterate(self.db())
@@ -2512,19 +2512,22 @@ impl<'db> TypeInferenceBuilder<'db> {
let module = file_to_module(self.db(), self.file())
.ok_or(ModuleNameResolutionError::UnknownCurrentModule)?;
let mut level = level.get();
if module.kind().is_package() {
level -= 1;
}
let mut module_name = module.name().clone();
for _ in 0..level {
module_name = module_name
.parent()
.ok_or(ModuleNameResolutionError::TooManyDots)?;
level = level.saturating_sub(1);
}
let mut module_name = module
.name()
.ancestors()
.nth(level as usize)
.ok_or(ModuleNameResolutionError::TooManyDots)?;
if let Some(tail) = tail {
let tail = ModuleName::new(tail).ok_or(ModuleNameResolutionError::InvalidSyntax)?;
module_name.extend(&tail);
}
Ok(module_name)
}

View File

@@ -62,19 +62,22 @@ impl<'db> Unpacker<'db> {
.unwrap_with_diagnostic(&self.context, value.as_any_node_ref(self.db()));
}
self.unpack_inner(target, value_ty);
self.unpack_inner(target, value.as_any_node_ref(self.db()), value_ty);
}
fn unpack_inner(&mut self, target: &ast::Expr, value_ty: Type<'db>) {
fn unpack_inner(
&mut self,
target: &ast::Expr,
value_expr: AnyNodeRef<'db>,
value_ty: Type<'db>,
) {
match target {
ast::Expr::Name(target_name) => {
self.targets.insert(
target_name.scoped_expression_id(self.db(), self.scope),
value_ty,
);
ast::Expr::Name(_) | ast::Expr::Attribute(_) => {
self.targets
.insert(target.scoped_expression_id(self.db(), self.scope), value_ty);
}
ast::Expr::Starred(ast::ExprStarred { value, .. }) => {
self.unpack_inner(value, value_ty);
self.unpack_inner(value, value_expr, value_ty);
}
ast::Expr::List(ast::ExprList { elts, .. })
| ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
@@ -153,7 +156,7 @@ impl<'db> Unpacker<'db> {
Type::LiteralString
} else {
ty.iterate(self.db())
.unwrap_with_diagnostic(&self.context, AnyNodeRef::from(target))
.unwrap_with_diagnostic(&self.context, value_expr)
};
for target_type in &mut target_types {
target_type.push(ty);
@@ -167,7 +170,7 @@ impl<'db> Unpacker<'db> {
[] => Type::unknown(),
types => UnionType::from_elements(self.db(), types),
};
self.unpack_inner(element, element_ty);
self.unpack_inner(element, value_expr, element_ty);
}
}
_ => {}
@@ -265,8 +268,14 @@ pub(crate) struct UnpackResult<'db> {
}
impl<'db> UnpackResult<'db> {
pub(crate) fn get(&self, expr_id: ScopedExpressionId) -> Option<Type<'db>> {
self.targets.get(&expr_id).copied()
/// Returns the inferred type for a given sub-expression of the left-hand side target
/// of an unpacking assignment.
///
/// Panics if a scoped expression ID is passed in that does not correspond to a sub-
/// expression of the target.
#[track_caller]
pub(crate) fn expression_type(&self, expr_id: ScopedExpressionId) -> Type<'db> {
self.targets[&expr_id]
}
}

View File

@@ -18,6 +18,7 @@ ruff_index = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
ruff_python_ast = { workspace = true }
anyhow = { workspace = true }
camino = { workspace = true }

View File

@@ -20,7 +20,7 @@ reveal_type(1) # revealed: Literal[1]
````
When running this test, the mdtest framework will write a file with these contents to the default
file path (`/src/mdtest_snippet__1.py`) in its in-memory file system, run a type check on that file,
file path (`/src/mdtest_snippet.py`) in its in-memory file system, run a type check on that file,
and then match the resulting diagnostics with the assertions in the test. Assertions are in the form
of Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise,
it fails.
@@ -34,7 +34,8 @@ syntax, it's just how this README embeds an example mdtest Markdown document.)
See actual example mdtest suites in
[`crates/red_knot_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/resources/mdtest).
> Note: If you use `dir-test`, `rstest` or similar to generate a separate test for all Markdown files in a certain directory,
> [!NOTE]
> If you use `dir-test`, `rstest` or similar to generate a separate test for all Markdown files in a certain directory,
> as with the example in `crates/red_knot_python_semantic/tests/mdtest.rs`,
> you will likely want to also make sure that the crate the tests are in is rebuilt every time a
> Markdown file is added or removed from the directory. See
@@ -126,6 +127,31 @@ Intervening empty lines or non-assertion comments are not allowed; an assertion
assertion per line, immediately following each other, with the line immediately following the last
assertion as the line of source code on which the matched diagnostics are emitted.
## Literate style
If multiple code blocks (without an explicit path, see below) are present in a single test, they will
be merged into a single file in the order they appear in the Markdown file. This allows for tests that
interleave code and explanations:
````markdown
# My literate test
This first snippet here:
```py
from typing import Literal
def f(x: Literal[1]):
pass
```
will be merged with this second snippet here, i.e. `f` is defined here:
```py
f(2) # error: [invalid-argument-type]
```
````
## Diagnostic Snapshotting
In addition to inline assertions, one can also snapshot the full diagnostic
@@ -156,13 +182,8 @@ snapshotting of specific diagnostics.
## Multi-file tests
Some tests require multiple files, with imports from one file into another. Multiple fenced code
blocks represent multiple embedded files. If there are multiple unnamed files, mdtest will name them
according to the numbered scheme `/src/mdtest_snippet__1.py`, `/src/mdtest_snippet__2.py`, etc. (If
they are `pyi` files, they will be named with a `pyi` extension instead.)
Tests should not rely on these default names. If a test must import from a file, then it should
explicitly specify the file name:
Some tests require multiple files, with imports from one file into another. For this purpose,
tests can specify explicit file paths in a separate line before the code block (`b.py` below):
````markdown
```py
@@ -183,8 +204,8 @@ is, the equivalent of a runtime entry on `sys.path`).
The default workspace root is `/src/`. Currently it is not possible to customize this in a test, but
this is a feature we will want to add in the future.
So the above test creates two files, `/src/mdtest_snippet__1.py` and `/src/b.py`, and sets the
workspace root to `/src/`, allowing imports from `b.py` using the module name `b`.
So the above test creates two files, `/src/mdtest_snippet.py` and `/src/b.py`, and sets the workspace
root to `/src/`, allowing imports from `b.py` using the module name `b`.
## Multi-test suites
@@ -398,7 +419,7 @@ This is just an example, not a proposal that red-knot would ever actually output
precisely this format:
```output
mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]'
mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]'
```
````
@@ -406,7 +427,7 @@ We will want to build tooling to automatically capture and update these “full
blocks, when tests are run in an update-output mode (probably specified by an environment variable.)
By default, an `output` block will specify diagnostic output for the file
`<workspace-root>/mdtest_snippet__1.py`. An `output` block can be prefixed by a
`<workspace-root>/mdtest_snippet.py`. An `output` block can be prefixed by a
<code>`&lt;path>`:</code> label as usual, to explicitly specify the Python file for which it asserts
diagnostic output.
@@ -442,7 +463,7 @@ x = 1
Initial expected output for the unnamed file:
```output
/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]'
/src/mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]'
```
Now in our first incremental stage, modify the contents of `b.py`:
@@ -457,12 +478,12 @@ x = 2
And this is our updated expected output for the unnamed file at stage 1:
```output stage=1
/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[2]'
/src/mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[2]'
```
(One reason to use full-diagnostic-output blocks in this test is that updating inline-comment
diagnostic assertions for `mdtest_snippet__1.py` would require specifying new contents for
`mdtest_snippet__1.py` in stage 1, which we don't want to do in this test.)
diagnostic assertions for `mdtest_snippet.py` would require specifying new contents for
`mdtest_snippet.py` in stage 1, which we don't want to do in this test.)
````
It will be possible to provide any number of stages in an incremental test. If a stage re-specifies

View File

@@ -1,4 +1,5 @@
use crate::config::Log;
use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap};
use camino::Utf8Path;
use colored::Colorize;
use parser as test_parser;
@@ -11,7 +12,6 @@ use ruff_db::parsed::parsed_module;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
use ruff_source_file::{LineIndex, OneIndexed};
use ruff_text_size::TextSize;
use std::fmt::Write;
mod assertion;
@@ -67,12 +67,14 @@ pub fn run(
let md_index = LineIndex::from_source_text(&source);
for test_failures in failures {
let backtick_line = md_index.line_index(test_failures.backtick_offset);
let source_map =
EmbeddedFileSourceMap::new(&md_index, test_failures.backtick_offsets);
for (relative_line_number, failures) in test_failures.by_line.iter() {
let absolute_line_number =
source_map.to_absolute_line_number(relative_line_number);
for failure in failures {
let absolute_line_number =
backtick_line.checked_add(relative_line_number).unwrap();
let line_info =
format!("{relative_fixture_path}:{absolute_line_number}").cyan();
println!(" {line_info} {failure}");
@@ -120,11 +122,7 @@ fn run_test(
"Supported file types are: py, pyi, text"
);
let full_path = if embedded.path.starts_with('/') {
SystemPathBuf::from(embedded.path.clone())
} else {
project_root.join(&embedded.path)
};
let full_path = embedded.full_path(&project_root);
if let Some(ref typeshed_path) = custom_typeshed_path {
if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) {
@@ -136,7 +134,7 @@ fn run_test(
}
}
db.write_file(&full_path, embedded.code).unwrap();
db.write_file(&full_path, &embedded.code).unwrap();
if !full_path.starts_with(&src_path) || embedded.lang == "text" {
// These files need to be written to the file system (above), but we don't run any checks on them.
@@ -147,7 +145,7 @@ fn run_test(
Some(TestFile {
file,
backtick_offset: embedded.backtick_offset,
backtick_offsets: embedded.backtick_offsets.clone(),
})
})
.collect();
@@ -230,7 +228,7 @@ fn run_test(
}
by_line.push(OneIndexed::from_zero_indexed(0), messages);
return Some(FileFailures {
backtick_offset: test_file.backtick_offset,
backtick_offsets: test_file.backtick_offsets,
by_line,
});
}
@@ -244,7 +242,7 @@ fn run_test(
match matcher::match_file(db, test_file.file, diagnostics.iter().map(|d| &**d)) {
Ok(()) => None,
Err(line_failures) => Some(FileFailures {
backtick_offset: test_file.backtick_offset,
backtick_offsets: test_file.backtick_offsets,
by_line: line_failures,
}),
};
@@ -280,11 +278,11 @@ fn run_test(
type Failures = Vec<FileFailures>;
/// The failures for a single file in a test by line number.
#[derive(Debug)]
struct FileFailures {
/// The offset of the backticks that starts the code block in the Markdown file
backtick_offset: TextSize,
/// The failures by lines in the code block.
/// Positional information about the code block(s) to reconstruct absolute line numbers.
backtick_offsets: Vec<BacktickOffsets>,
/// The failures by lines in the file.
by_line: matcher::FailuresByLine,
}
@@ -292,8 +290,8 @@ struct FileFailures {
struct TestFile {
file: File,
// Offset of the backticks that starts the code block in the Markdown file
backtick_offset: TextSize,
/// Positional information about the code block(s) to reconstruct absolute line numbers.
backtick_offsets: Vec<BacktickOffsets>,
}
fn create_diagnostic_snapshot<D: Diagnostic>(
@@ -317,7 +315,7 @@ fn create_diagnostic_snapshot<D: Diagnostic>(
writeln!(snapshot, "# Python source files").unwrap();
writeln!(snapshot).unwrap();
for file in test.files() {
writeln!(snapshot, "## {}", file.path).unwrap();
writeln!(snapshot, "## {}", file.relative_path()).unwrap();
writeln!(snapshot).unwrap();
// Note that we don't use ```py here because the line numbering
// we add makes it invalid Python. This sacrifices syntax

View File

@@ -1,9 +1,13 @@
use std::{borrow::Cow, collections::hash_map::Entry};
use anyhow::bail;
use rustc_hash::{FxHashMap, FxHashSet};
use ruff_db::system::{SystemPath, SystemPathBuf};
use rustc_hash::FxHashMap;
use ruff_index::{newtype_index, IndexVec};
use ruff_python_ast::PySourceType;
use ruff_python_trivia::Cursor;
use ruff_source_file::LineRanges;
use ruff_source_file::{LineIndex, LineRanges, OneIndexed};
use ruff_text_size::{TextLen, TextRange, TextSize};
use crate::config::MarkdownTestConfig;
@@ -132,6 +136,112 @@ struct Section<'s> {
#[newtype_index]
struct EmbeddedFileId;
/// Holds information about the start and the end of a code block in a Markdown file.
///
/// The start is the offset of the first triple-backtick in the code block, and the end is the
/// offset of the (start of the) closing triple-backtick.
#[derive(Debug, Clone)]
pub(crate) struct BacktickOffsets(TextSize, TextSize);
/// Holds information about the position and length of all code blocks that are part of
/// a single embedded file in a Markdown file. This is used to reconstruct absolute line
/// numbers (in the Markdown file) from relative line numbers (in the embedded file).
///
/// If we have a Markdown section with multiple code blocks like this:
///
/// 01 # Test
/// 02
/// 03 Part 1:
/// 04
/// 05 ```py
/// 06 a = 1 # Relative line number: 1
/// 07 b = 2 # Relative line number: 2
/// 08 ```
/// 09
/// 10 Part 2:
/// 11
/// 12 ```py
/// 13 c = 3 # Relative line number: 3
/// 14 ```
///
/// We want to reconstruct the absolute line number (left) from relative
/// line numbers. The information we have is the start line and the line
/// count of each code block:
///
/// - Block 1: (start = 5, count = 2)
/// - Block 2: (start = 12, count = 1)
///
/// For example, if we see a relative line number of 3, we see that it is
/// larger than the line count of the first block, so we subtract the line
/// count of the first block, and then add the new relative line number (1)
/// to the absolute start line of the second block (12), resulting in an
/// absolute line number of 13.
pub(crate) struct EmbeddedFileSourceMap {
start_line_and_line_count: Vec<(usize, usize)>,
}
impl EmbeddedFileSourceMap {
pub(crate) fn new(
md_index: &LineIndex,
dimensions: impl IntoIterator<Item = BacktickOffsets>,
) -> EmbeddedFileSourceMap {
EmbeddedFileSourceMap {
start_line_and_line_count: dimensions
.into_iter()
.map(|d| {
let start_line = md_index.line_index(d.0).get();
let end_line = md_index.line_index(d.1).get();
let code_line_count = (end_line - start_line) - 1;
(start_line, code_line_count)
})
.collect(),
}
}
pub(crate) fn to_absolute_line_number(&self, relative_line_number: OneIndexed) -> OneIndexed {
let mut absolute_line_number = 0;
let mut relative_line_number = relative_line_number.get();
for (start_line, line_count) in &self.start_line_and_line_count {
if relative_line_number > *line_count {
relative_line_number -= *line_count;
} else {
absolute_line_number = start_line + relative_line_number;
break;
}
}
OneIndexed::new(absolute_line_number).expect("Relative line number out of bounds")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum EmbeddedFilePath<'s> {
Autogenerated(PySourceType),
Explicit(&'s str),
}
impl EmbeddedFilePath<'_> {
pub(crate) fn as_str(&self) -> &str {
match self {
EmbeddedFilePath::Autogenerated(PySourceType::Python) => "mdtest_snippet.py",
EmbeddedFilePath::Autogenerated(PySourceType::Stub) => "mdtest_snippet.pyi",
EmbeddedFilePath::Autogenerated(PySourceType::Ipynb) => "mdtest_snippet.ipynb",
EmbeddedFilePath::Explicit(path) => path,
}
}
fn is_explicit(&self) -> bool {
matches!(self, EmbeddedFilePath::Explicit(_))
}
fn is_allowed_explicit_path(path: &str) -> bool {
[PySourceType::Python, PySourceType::Stub]
.iter()
.all(|source_type| path != EmbeddedFilePath::Autogenerated(*source_type).as_str())
}
}
/// A single file embedded in a [`Section`] as a fenced code block.
///
/// Currently must be a Python file (`py` language), a type stub (`pyi`) or a [typeshed `VERSIONS`]
@@ -148,12 +258,39 @@ struct EmbeddedFileId;
#[derive(Debug)]
pub(crate) struct EmbeddedFile<'s> {
section: SectionId,
pub(crate) path: String,
path: EmbeddedFilePath<'s>,
pub(crate) lang: &'s str,
pub(crate) code: &'s str,
pub(crate) code: Cow<'s, str>,
pub(crate) backtick_offsets: Vec<BacktickOffsets>,
}
/// The offset of the backticks beginning the code block within the markdown file
pub(crate) backtick_offset: TextSize,
impl EmbeddedFile<'_> {
fn append_code(&mut self, backtick_offsets: BacktickOffsets, new_code: &str) {
// Treat empty code blocks as non-existent, instead of creating
// an additional empty line:
if new_code.is_empty() {
return;
}
self.backtick_offsets.push(backtick_offsets);
let existing_code = self.code.to_mut();
existing_code.push('\n');
existing_code.push_str(new_code);
}
pub(crate) fn relative_path(&self) -> &str {
self.path.as_str()
}
pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
let relative_path = self.relative_path();
if relative_path.starts_with('/') {
SystemPathBuf::from(relative_path)
} else {
project_root.join(relative_path)
}
}
}
#[derive(Debug)]
@@ -195,12 +332,6 @@ struct Parser<'s> {
/// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`].
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
/// The counts are done by section. This gives each code block a
/// somewhat locally derived name. That is, adding new sections
/// won't change the names of files in other sections. This is
/// important for avoiding snapshot churn.
unnamed_file_count: FxHashMap<SectionId, usize>,
/// The unparsed remainder of the Markdown source.
cursor: Cursor<'s>,
@@ -217,7 +348,7 @@ struct Parser<'s> {
stack: SectionStack,
/// Names of embedded files in current active section.
current_section_files: Option<FxHashSet<String>>,
current_section_files: FxHashMap<EmbeddedFilePath<'s>, EmbeddedFileId>,
/// Whether or not the current section has a config block.
current_section_has_config: bool,
@@ -237,13 +368,12 @@ impl<'s> Parser<'s> {
sections,
source,
files: IndexVec::default(),
unnamed_file_count: FxHashMap::default(),
cursor: Cursor::new(source),
preceding_blank_lines: 0,
explicit_path: None,
source_len: source.text_len(),
stack: SectionStack::new(root_section_id),
current_section_files: None,
current_section_files: FxHashMap::default(),
current_section_has_config: false,
}
}
@@ -334,7 +464,7 @@ impl<'s> Parser<'s> {
if self.cursor.eat_char2('`', '`') {
// We saw the triple-backtick beginning of a code block.
let backtick_offset = self.offset() - "```".text_len();
let backtick_offset_start = self.offset() - "```".text_len();
if self.preceding_blank_lines < 1 && self.explicit_path.is_none() {
bail!("Code blocks must start on a new line and be preceded by at least one blank line.");
@@ -363,7 +493,13 @@ impl<'s> Parser<'s> {
code = &code[..code.len() - '\n'.len_utf8()];
}
self.process_code_block(lang, code, backtick_offset)?;
let backtick_offset_end = self.offset() - "```".text_len();
self.process_code_block(
lang,
code,
BacktickOffsets(backtick_offset_start, backtick_offset_end),
)?;
} else {
let code_block_start = self.cursor.token_len();
let line = self.source.count_lines(TextRange::up_to(code_block_start));
@@ -428,7 +564,7 @@ impl<'s> Parser<'s> {
snapshot_diagnostics: self.sections[parent].snapshot_diagnostics,
};
if self.current_section_files.is_some() {
if !self.current_section_files.is_empty() {
bail!(
"Header '{}' not valid inside a test case; parent '{}' has code files.",
section.title,
@@ -439,7 +575,7 @@ impl<'s> Parser<'s> {
let section_id = self.sections.push(section);
self.stack.push(section_id);
self.current_section_files = None;
self.current_section_files.clear();
self.current_section_has_config = false;
Ok(())
@@ -449,10 +585,11 @@ impl<'s> Parser<'s> {
&mut self,
lang: &'s str,
code: &'s str,
backtick_offset: TextSize,
backtick_offsets: BacktickOffsets,
) -> anyhow::Result<()> {
// We never pop the implicit root section.
let section = self.stack.top();
let test_name = self.sections[section].title;
if lang == "toml" {
return self.process_config_block(code);
@@ -465,52 +602,86 @@ impl<'s> Parser<'s> {
&& !explicit_path.ends_with(&format!(".{lang}"))
{
bail!(
"File ending of test file path `{explicit_path}` does not match `lang={lang}` of code block"
"File extension of test file path `{explicit_path}` in test `{test_name}` does not match language specified `{lang}` of code block"
);
}
}
let path = match self.explicit_path {
Some(path) => path.to_string(),
None => {
let unnamed_file_count = self.unnamed_file_count.entry(section).or_default();
*unnamed_file_count += 1;
match lang {
"py" | "pyi" => format!("mdtest_snippet__{unnamed_file_count}.{lang}"),
"" => format!("mdtest_snippet__{unnamed_file_count}.py"),
_ => {
bail!(
"Cannot generate name for `lang={}`: Unsupported extension",
lang
);
}
Some(path) => {
if !EmbeddedFilePath::is_allowed_explicit_path(path) {
bail!(
"The file name `{path}` in test `{test_name}` must not be used explicitly.",
);
}
EmbeddedFilePath::Explicit(path)
}
None => match lang {
"py" => EmbeddedFilePath::Autogenerated(PySourceType::Python),
"pyi" => EmbeddedFilePath::Autogenerated(PySourceType::Stub),
"" => {
bail!("Cannot auto-generate file name for code block with empty language specifier in test `{test_name}`");
}
_ => {
bail!(
"Cannot auto-generate file name for code block with language `{}` in test `{test_name}`",
lang
);
}
},
};
self.files.push(EmbeddedFile {
path: path.clone(),
section,
lang,
code,
backtick_offset,
});
let has_merged_snippets = self.current_section_has_merged_snippets();
let has_explicit_file_paths = self.current_section_has_explicit_file_paths();
if let Some(current_files) = &mut self.current_section_files {
if !current_files.insert(path.clone()) {
bail!(
"Test `{}` has duplicate files named `{path}`.",
self.sections[section].title
);
};
} else {
self.current_section_files = Some(FxHashSet::from_iter([path]));
match self.current_section_files.entry(path.clone()) {
Entry::Vacant(entry) => {
if has_merged_snippets {
bail!("Merged snippets in test `{test_name}` are not allowed in the presence of other files.");
}
let index = self.files.push(EmbeddedFile {
path: path.clone(),
section,
lang,
code: Cow::Borrowed(code),
backtick_offsets: vec![backtick_offsets],
});
entry.insert(index);
}
Entry::Occupied(entry) => {
if path.is_explicit() {
bail!(
"Test `{test_name}` has duplicate files named `{}`.",
path.as_str(),
);
};
if has_explicit_file_paths {
bail!("Merged snippets in test `{test_name}` are not allowed in the presence of other files.");
}
let index = *entry.get();
self.files[index].append_code(backtick_offsets, code);
}
}
Ok(())
}
fn current_section_has_explicit_file_paths(&self) -> bool {
self.current_section_files
.iter()
.any(|(path, _)| path.is_explicit())
}
fn current_section_has_merged_snippets(&self) -> bool {
self.current_section_files
.values()
.any(|id| self.files[*id].backtick_offsets.len() > 1)
}
fn process_config_block(&mut self, code: &str) -> anyhow::Result<()> {
if self.current_section_has_config {
bail!("Multiple TOML configuration blocks in the same section are not allowed.");
@@ -531,7 +702,7 @@ impl<'s> Parser<'s> {
everything else (including TOML configuration blocks).",
);
}
if self.current_section_files.is_some() {
if !self.current_section_files.is_empty() {
bail!(
"Section config to enable snapshotting diagnostics must come before \
everything else (including embedded files).",
@@ -555,7 +726,7 @@ impl<'s> Parser<'s> {
self.stack.pop();
// We would have errored before pushing a child section if there were files, so we know
// no parent section can have files.
self.current_section_files = None;
self.current_section_files.clear();
}
}
@@ -567,8 +738,11 @@ impl<'s> Parser<'s> {
#[cfg(test)]
mod tests {
use ruff_python_ast::PySourceType;
use ruff_python_trivia::textwrap::dedent;
use crate::parser::EmbeddedFilePath;
#[test]
fn empty() {
let mf = super::parse("file.md", "").unwrap();
@@ -597,7 +771,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
@@ -622,7 +799,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
@@ -645,10 +825,14 @@ mod tests {
# Three
`mod_a.pyi`:
```pyi
a: int
```
`mod_b.pyi`:
```pyi
b: str
```
@@ -668,7 +852,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
@@ -676,7 +863,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "y = 2");
@@ -684,11 +874,11 @@ mod tests {
panic!("expected two files");
};
assert_eq!(file_1.path, "mdtest_snippet__1.pyi");
assert_eq!(file_1.relative_path(), "mod_a.pyi");
assert_eq!(file_1.lang, "pyi");
assert_eq!(file_1.code, "a: int");
assert_eq!(file_2.path, "mdtest_snippet__2.pyi");
assert_eq!(file_2.relative_path(), "mod_b.pyi");
assert_eq!(file_2.lang, "pyi");
assert_eq!(file_2.code, "b: str");
}
@@ -731,11 +921,11 @@ mod tests {
panic!("expected two files");
};
assert_eq!(main.path, "main.py");
assert_eq!(main.relative_path(), "main.py");
assert_eq!(main.lang, "py");
assert_eq!(main.code, "from foo import y");
assert_eq!(foo.path, "foo.py");
assert_eq!(foo.relative_path(), "foo.py");
assert_eq!(foo.lang, "py");
assert_eq!(foo.code, "y = 2");
@@ -743,11 +933,157 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "y = 2");
}
#[test]
fn merged_snippets() {
let source = dedent(
"
# One
This is the first part of the embedded file:
```py
x = 1
```
And this is the second part:
```py
y = 2
```
And this is the third part:
```py
z = 3
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1\ny = 2\nz = 3");
}
#[test]
fn no_merged_snippets_for_explicit_paths() {
let source = dedent(
"
# One
`foo.py`:
```py
x = 1
```
`foo.py`:
```py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `One` has duplicate files named `foo.py`."
);
}
#[test]
fn disallow_merged_snippets_in_presence_of_explicit_paths() {
for source in [
// Merged snippets first
"
# One
```py
x = 1
```
```py
y = 2
```
`foo.py`:
```py
print('hello')
```
",
// Explicit path first
"
# One
`foo.py`:
```py
print('hello')
```
```py
x = 1
```
```py
y = 2
```
",
] {
let err = super::parse("file.md", &dedent(source)).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Merged snippets in test `One` are not allowed in the presence of other files."
);
}
}
#[test]
fn disallow_pyi_snippets_in_presence_of_merged_py_snippets() {
let source = dedent(
"
# One
```py
x = 1
```
```py
y = 2
```
```pyi
x: int
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Merged snippets in test `One` are not allowed in the presence of other files."
);
}
#[test]
fn custom_file_path() {
let source = dedent(
@@ -768,7 +1104,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.relative_path(), "foo.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
@@ -820,28 +1156,27 @@ mod tests {
fn no_lang() {
let source = dedent(
"
# No language specifier
```
x = 10
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.code, "x = 10");
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Cannot auto-generate file name for code block with empty language specifier in test `No language specifier`"
);
}
#[test]
fn cannot_generate_name_for_lang() {
let source = dedent(
"
# JSON test?
```json
{}
```
@@ -850,7 +1185,7 @@ mod tests {
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Cannot generate name for `lang=json`: Unsupported extension"
"Cannot auto-generate file name for code block with language `json` in test `JSON test?`"
);
}
@@ -858,6 +1193,8 @@ mod tests {
fn mismatching_lang() {
let source = dedent(
"
# Accidental stub
`a.py`:
```pyi
@@ -868,7 +1205,7 @@ mod tests {
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"File ending of test file path `a.py` does not match `lang=pyi` of code block"
"File extension of test file path `a.py` in test `Accidental stub` does not match language specified `pyi` of code block"
);
}
@@ -893,7 +1230,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "lorem");
assert_eq!(file.relative_path(), "lorem");
assert_eq!(file.code, "x = 1");
}
@@ -918,7 +1255,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "lorem.yaml");
assert_eq!(file.relative_path(), "lorem.yaml");
assert_eq!(file.code, "x = 1");
}
@@ -940,13 +1277,13 @@ mod tests {
"
## A well-fenced block
```
```py
y = 2
```
## A not-so-well-fenced block
```
```py
x = 1
",
);
@@ -987,7 +1324,8 @@ mod tests {
```
",
);
super::parse("file.md", &source).expect_err("Indented code blocks are not supported.");
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Indented code blocks are not supported.");
}
#[test]
@@ -1033,7 +1371,10 @@ mod tests {
};
assert_eq!(test.section.title, "file.md");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}
@@ -1062,7 +1403,10 @@ mod tests {
};
assert_eq!(test.section.title, "Foo");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}
@@ -1091,10 +1435,12 @@ mod tests {
}
#[test]
fn no_duplicate_name_files_in_test_2() {
fn no_usage_of_autogenerated_name() {
let source = dedent(
"
`mdtest_snippet__1.py`:
# Name clash
`mdtest_snippet.py`:
```py
x = 1
@@ -1108,7 +1454,7 @@ mod tests {
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `mdtest_snippet__1.py`."
"The file name `mdtest_snippet.py` in test `Name clash` must not be used explicitly."
);
}
@@ -1133,7 +1479,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.relative_path(), "foo.py");
assert_eq!(file.code, "x = 1");
}
@@ -1158,7 +1504,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.relative_path(), "foo.py");
assert_eq!(file.code, "x = 1");
}
@@ -1182,7 +1528,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.relative_path(), "foo.py");
assert_eq!(file.code, "x = 1");
}
@@ -1207,7 +1553,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "foo bar.py");
assert_eq!(file.relative_path(), "foo bar.py");
assert_eq!(file.code, "x = 1");
}
@@ -1233,7 +1579,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}
@@ -1258,7 +1607,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}
@@ -1284,7 +1636,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}
@@ -1310,7 +1665,10 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(
file.path,
EmbeddedFilePath::Autogenerated(PySourceType::Python)
);
assert_eq!(file.code, "x = 1");
}

View File

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

View File

@@ -3,9 +3,9 @@
#![cfg(not(target_family = "wasm"))]
use regex::escape;
use std::fs;
use std::process::Command;
use std::str;
use std::{fs, path::Path};
use anyhow::Result;
use assert_fs::fixture::{ChildPath, FileTouch, PathChild};
@@ -2218,13 +2218,11 @@ def func(t: _T) -> _T:
return x
"#
),
@r#"
success: false
exit_code: 1
@r"
success: true
exit_code: 0
----- stdout -----
from typing import TypeVar
_T = TypeVar("_T")
class OldStyle[T]:
var: T
@@ -2234,8 +2232,153 @@ def func(t: _T) -> _T:
return x
----- stderr -----
test.py:3:1: PYI018 Private TypeVar `_T` is never used
Found 6 errors (5 fixed, 1 remaining).
"#
Found 7 errors (7 fixed, 0 remaining).
"
);
}
/// Test that we do not rename two different type parameters to the same name
/// in one execution of Ruff (autofixing this to `class Foo[T, T]: ...` would
/// introduce invalid syntax)
#[test]
fn type_parameter_rename_isolation() {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--select", "UP049"])
.args(["--stdin-filename", "test.py"])
.arg("--unsafe-fixes")
.arg("--fix")
.arg("--preview")
.arg("--target-version=py312")
.arg("-")
.pass_stdin(
r#"
class Foo[_T, __T]:
pass
"#
),
@r"
success: false
exit_code: 1
----- stdout -----
class Foo[T, __T]:
pass
----- stderr -----
test.py:2:14: UP049 Generic class uses private type parameters
Found 2 errors (1 fixed, 1 remaining).
"
);
}
#[test]
fn a005_module_shadowing_strict() -> Result<()> {
fn create_module(path: &Path) -> Result<()> {
fs::create_dir(path)?;
fs::File::create(path.join("__init__.py"))?;
Ok(())
}
// construct a directory tree with this structure:
// .
// ├── abc
// │   └── __init__.py
// ├── collections
// │   ├── __init__.py
// │   ├── abc
// │   │   └── __init__.py
// │   └── foobar
// │   └── __init__.py
// ├── foobar
// │   ├── __init__.py
// │   ├── abc
// │   │   └── __init__.py
// │   └── collections
// │   ├── __init__.py
// │   ├── abc
// │   │   └── __init__.py
// │   └── foobar
// │   └── __init__.py
// ├── ruff.toml
// └── urlparse
// └── __init__.py
let tempdir = TempDir::new()?;
let foobar = tempdir.path().join("foobar");
create_module(&foobar)?;
for base in [&tempdir.path().into(), &foobar] {
for dir in ["abc", "collections"] {
create_module(&base.join(dir))?;
}
create_module(&base.join("collections").join("abc"))?;
create_module(&base.join("collections").join("foobar"))?;
}
create_module(&tempdir.path().join("urlparse"))?;
// also create a ruff.toml to mark the project root
fs::File::create(tempdir.path().join("ruff.toml"))?;
insta::with_settings!({
filters => vec![(r"\\", "/")]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--select", "A005"])
.current_dir(tempdir.path()),
@r"
success: false
exit_code: 1
----- stdout -----
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
Found 6 errors.
----- stderr -----
");
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--select", "A005"])
.current_dir(tempdir.path()),
@r"
success: false
exit_code: 1
----- stdout -----
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
Found 6 errors.
----- stderr -----
");
// TODO(brent) Default should currently match the strict version, but after the next minor
// release it will match the non-strict version directly above
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--select", "A005"])
.current_dir(tempdir.path()),
@r"
success: false
exit_code: 1
----- stdout -----
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
Found 6 errors.
----- stderr -----
");
});
Ok(())
}

View File

@@ -30,6 +30,7 @@ glob = { workspace = true }
ignore = { workspace = true, optional = true }
matchit = { workspace = true }
salsa = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
path-slash = { workspace = true }
thiserror = { workspace = true }

View File

@@ -471,7 +471,13 @@ impl ToOwned for SystemPath {
/// The path is guaranteed to be valid UTF-8.
#[repr(transparent)]
#[derive(Eq, PartialEq, Clone, Hash, PartialOrd, Ord)]
pub struct SystemPathBuf(Utf8PathBuf);
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(transparent)
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SystemPathBuf(#[cfg_attr(feature = "schemars", schemars(with = "String"))] Utf8PathBuf);
impl SystemPathBuf {
pub fn new() -> Self {
@@ -658,27 +664,6 @@ impl ruff_cache::CacheKey for SystemPathBuf {
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for SystemPath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for SystemPathBuf {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for SystemPathBuf {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Utf8PathBuf::deserialize(deserializer).map(SystemPathBuf)
}
}
/// A slice of a virtual path on [`System`](super::System) (akin to [`str`]).
#[repr(transparent)]
pub struct SystemVirtualPath(str);

View File

@@ -11,6 +11,7 @@ repository = { workspace = true }
license = { workspace = true }
[dependencies]
red_knot_project = { workspace = true, features = ["schemars"] }
ruff = { workspace = true }
ruff_diagnostics = { workspace = true }
ruff_formatter = { workspace = true }

View File

@@ -2,7 +2,7 @@
use anyhow::Result;
use crate::{generate_cli_help, generate_docs, generate_json_schema};
use crate::{generate_cli_help, generate_docs, generate_json_schema, generate_knot_schema};
pub(crate) const REGENERATE_ALL_COMMAND: &str = "cargo dev generate-all";
@@ -33,6 +33,7 @@ impl Mode {
pub(crate) fn main(args: &Args) -> Result<()> {
generate_json_schema::main(&generate_json_schema::Args { mode: args.mode })?;
generate_knot_schema::main(&generate_knot_schema::Args { mode: args.mode })?;
generate_cli_help::main(&generate_cli_help::Args { mode: args.mode })?;
generate_docs::main(&generate_docs::Args {
dry_run: args.mode.is_dry_run(),

View File

@@ -0,0 +1,72 @@
#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::fs;
use std::path::PathBuf;
use anyhow::{bail, Result};
use pretty_assertions::StrComparison;
use schemars::schema_for;
use crate::generate_all::{Mode, REGENERATE_ALL_COMMAND};
use crate::ROOT_DIR;
use red_knot_project::metadata::options::Options;
#[derive(clap::Args)]
pub(crate) struct Args {
/// Write the generated table to stdout (rather than to `knot.schema.json`).
#[arg(long, default_value_t, value_enum)]
pub(crate) mode: Mode,
}
pub(crate) fn main(args: &Args) -> Result<()> {
let schema = schema_for!(Options);
let schema_string = serde_json::to_string_pretty(&schema).unwrap();
let filename = "knot.schema.json";
let schema_path = PathBuf::from(ROOT_DIR).join(filename);
match args.mode {
Mode::DryRun => {
println!("{schema_string}");
}
Mode::Check => {
let current = fs::read_to_string(schema_path)?;
if current == schema_string {
println!("Up-to-date: {filename}");
} else {
let comparison = StrComparison::new(&current, &schema_string);
bail!("{filename} changed, please run `{REGENERATE_ALL_COMMAND}`:\n{comparison}");
}
}
Mode::Write => {
let current = fs::read_to_string(&schema_path)?;
if current == schema_string {
println!("Up-to-date: {filename}");
} else {
println!("Updating: {filename}");
fs::write(schema_path, schema_string.as_bytes())?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use std::env;
use crate::generate_all::Mode;
use super::{main, Args};
#[test]
fn test_generate_json_schema() -> Result<()> {
let mode = if env::var("KNOT_UPDATE_SCHEMA").as_deref() == Ok("1") {
Mode::Write
} else {
Mode::Check
};
main(&Args { mode })
}
}

View File

@@ -13,6 +13,7 @@ mod generate_all;
mod generate_cli_help;
mod generate_docs;
mod generate_json_schema;
mod generate_knot_schema;
mod generate_options;
mod generate_rules_table;
mod print_ast;
@@ -39,6 +40,8 @@ enum Command {
GenerateAll(generate_all::Args),
/// Generate JSON schema for the TOML configuration file.
GenerateJSONSchema(generate_json_schema::Args),
/// Generate JSON schema for the Red Knot TOML configuration file.
GenerateKnotSchema(generate_knot_schema::Args),
/// Generate a Markdown-compatible table of supported lint rules.
GenerateRulesTable,
/// Generate a Markdown-compatible listing of configuration options.
@@ -83,6 +86,7 @@ fn main() -> Result<ExitCode> {
match command {
Command::GenerateAll(args) => generate_all::main(&args)?,
Command::GenerateJSONSchema(args) => generate_json_schema::main(&args)?,
Command::GenerateKnotSchema(args) => generate_knot_schema::main(&args)?,
Command::GenerateRulesTable => println!("{}", generate_rules_table::generate()),
Command::GenerateOptions => println!("{}", generate_options::generate()),
Command::GenerateCliHelp(args) => generate_cli_help::main(&args)?,

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff_linter"
version = "0.9.4"
version = "0.9.5"
publish = false
authors = { workspace = true }
edition = { workspace = true }

View File

@@ -75,15 +75,10 @@ from airflow.secrets.local_filesystem import LocalFilesystemBackend, load_connec
from airflow.security.permissions import RESOURCE_DATASET
from airflow.sensors.base_sensor_operator import BaseSensorOperator
from airflow.sensors.date_time_sensor import DateTimeSensor
from airflow.sensors.external_task import (
ExternalTaskSensorLink as ExternalTaskSensorLinkFromExternalTask,
)
from airflow.sensors.external_task_sensor import (
ExternalTaskMarker,
ExternalTaskSensor,
)
from airflow.sensors.external_task_sensor import (
ExternalTaskSensorLink as ExternalTaskSensorLinkFromExternalTaskSensor,
ExternalTaskSensorLink,
)
from airflow.sensors.time_delta_sensor import TimeDeltaSensor
from airflow.timetables.datasets import DatasetOrTimeSchedule
@@ -249,11 +244,13 @@ BaseSensorOperator()
DateTimeSensor()
# airflow.sensors.external_task
ExternalTaskSensorLinkFromExternalTask()
# airflow.sensors.external_task_sensor
ExternalTaskSensorLink()
ExternalTaskMarker()
ExternalTaskSensor()
# airflow.sensors.external_task_sensor
ExternalTaskMarkerFromExternalTaskSensor()
ExternalTaskSensorFromExternalTaskSensor()
ExternalTaskSensorLinkFromExternalTaskSensor()
# airflow.sensors.time_delta_sensor

View File

@@ -0,0 +1,28 @@
##### https://github.com/astral-sh/ruff/issues/15809
### Errors
def overshadowed_list():
list = ...
list(map(lambda x: x, []))
### No errors
dict(map(lambda k: (k,), a))
dict(map(lambda k: (k, v, 0), a))
dict(map(lambda k: [k], a))
dict(map(lambda k: [k, v, 0], a))
dict(map(lambda k: {k, v}, a))
dict(map(lambda k: {k: 0, v: 1}, a))
a = [(1, 2), (3, 4)]
map(lambda x: [*x, 10], *a)
map(lambda x: [*x, 10], *a, *b)
map(lambda x: [*x, 10], a, *b)
map(lambda x: x + 10, (a := []))
list(map(lambda x: x + 10, (a := [])))
set(map(lambda x: x + 10, (a := [])))
dict(map(lambda x: (x, 10), (a := [])))

View File

@@ -70,6 +70,32 @@ foo({**foo, **{"bar": True}}) # PIE800
,
})
{
"data": [],
** # Foo
( # Comment
{ "a": b,
# Comment
}
) ,
c: 9,
}
# https://github.com/astral-sh/ruff/issues/15997
{"a": [], **{},}
{"a": [], **({}),}
{"a": [], **{}, 6: 3}
{"a": [], **({}), 6: 3}
{"a": [], **{
# Comment
}, 6: 3}
{"a": [], **({
# Comment
}), 6: 3}
{**foo, "bar": True } # OK

View File

@@ -2,6 +2,8 @@
# Positive cases
###
a_dict = {}
# SIM401 (pattern-1)
if key in a_dict:
var = a_dict[key]
@@ -26,6 +28,8 @@ if keys[idx] in a_dict:
else:
var = "default"
dicts = {"key": a_dict}
# SIM401 (complex expression in dict)
if key in dicts[idx]:
var = dicts[idx][key]
@@ -115,6 +119,28 @@ elif key in a_dict:
else:
vars[idx] = "default"
class NotADictionary:
def __init__(self):
self._dict = {}
def __getitem__(self, key):
return self._dict[key]
def __setitem__(self, key, value):
self._dict[key] = value
def __iter__(self):
return self._dict.__iter__()
not_dict = NotADictionary()
not_dict["key"] = "value"
# OK (type `NotADictionary` is not a known dictionary type)
if "key" in not_dict:
value = not_dict["key"]
else:
value = None
###
# Positive cases (preview)
###

View File

@@ -6,6 +6,10 @@ class _bad:
pass
class __bad:
pass
class bad_class:
pass
@@ -13,6 +17,8 @@ class bad_class:
class Bad_Class:
pass
class Bad__Class:
pass
class BAD_CLASS:
pass
@@ -32,3 +38,6 @@ class GoodClass:
class GOOD:
pass
class __GoodClass:
pass

View File

@@ -0,0 +1,7 @@
import os
import sys
sys.path += [os.path.dirname(__file__)]
sys.path += ["../"]
from package import module

View File

@@ -64,10 +64,42 @@ u''.strip('http://')
u''.lstrip('http://')
# PLE1310
b''.rstrip('http://')
b''.rstrip(b'http://')
# OK
''.strip('Hi')
# OK
''.strip()
### https://github.com/astral-sh/ruff/issues/15968
# Errors: Multiple backslashes
''.strip('\\b\\x09')
''.strip(r'\b\x09')
''.strip('\\\x5C')
# OK: Different types
b"".strip("//")
"".strip(b"//")
# OK: Escapes
'\\test'.strip('\\')
# OK: Extra/missing arguments
"".strip("//", foo)
b"".lstrip(b"//", foo = "bar")
"".rstrip()
# OK: Not literals
foo: str = ""; bar: bytes = b""
"".strip(foo)
b"".strip(bar)
# False negative
foo.rstrip("//")
bar.lstrip(b"//")
# OK: Not `.[lr]?strip`
"".mobius_strip("")

View File

@@ -113,3 +113,18 @@ PositiveList = TypeAliasType(
Annotated[T, Gt(0)], # preserved comment
], type_params=(T,)
)
T: TypeAlias = (
int
| str
)
T: TypeAlias = ( # comment0
# comment1
int # comment2
# comment3
| # comment4
# comment5
str # comment6
# comment7
) # comment8

View File

@@ -12,3 +12,13 @@ x: TypeAlias = tuple[
int, # preserved
float,
]
T: TypeAlias = ( # comment0
# comment1
int # comment2
# comment3
| # comment4
# comment5
str # comment6
# comment7
) # comment8

View File

@@ -54,3 +54,52 @@ def f[_](x: _) -> _: ...
def g[__](x: __) -> __: ...
def h[_T_](x: _T_) -> _T_: ...
def i[__T__](x: __T__) -> __T__: ...
# https://github.com/astral-sh/ruff/issues/16024
from typing import cast, Literal
class C[_0]: ...
class C[T, _T]: ...
class C[_T, T]: ...
class C[_T]:
v1 = cast(_T, ...)
v2 = cast('_T', ...)
v3 = cast("\u005fT", ...)
def _(self):
v1 = cast(_T, ...)
v2 = cast('_T', ...)
v3 = cast("\u005fT", ...)
class C[_T]:
v = cast('Literal[\'foo\'] | _T', ...)
## Name collision
class C[T]:
def f[_T](self): # No fix, collides with `T` from outer scope
v1 = cast(_T, ...)
v2 = cast('_T', ...)
# Unfixable as the new name collides with a variable visible from one of the inner scopes
class C[_T]:
T = 42
v1 = cast(_T, ...)
v2 = cast('_T', ...)
# Unfixable as the new name collides with a variable visible from one of the inner scopes
class C[_T]:
def f[T](self):
v1 = cast(_T, ...)
v2 = cast('_T', ...)

View File

@@ -176,3 +176,22 @@ class Node:
_seen.add(self)
for other in self.connected:
other.recurse(_seen=_seen)
def foo():
_dummy_var = 42
def bar():
dummy_var = 43
print(_dummy_var)
def foo():
# Unfixable because both possible candidates for the new name are shadowed
# in the scope of one of the references to the variable
_dummy_var = 42
def bar():
dummy_var = 43
dummy_var_ = 44
print(_dummy_var)

View File

@@ -0,0 +1,109 @@
from typing import Generic, ParamSpec, TypeVar, TypeVarTuple, Unpack
_A = TypeVar('_A')
_B = TypeVar('_B', bound=int)
_C = TypeVar('_C', str, bytes)
_D = TypeVar('_D', default=int)
_E = TypeVar('_E', bound=int, default=int)
_F = TypeVar('_F', str, bytes, default=str)
_G = TypeVar('_G', str, a := int)
_As = TypeVarTuple('_As')
_Bs = TypeVarTuple('_Bs', bound=tuple[int, str])
_Cs = TypeVarTuple('_Cs', default=tuple[int, str])
_P1 = ParamSpec('_P1')
_P2 = ParamSpec('_P2', bound=[bytes, bool])
_P3 = ParamSpec('_P3', default=[int, str])
### Errors
class C[T](Generic[_A]): ...
class C[T](Generic[_B], str): ...
class C[T](int, Generic[_C]): ...
class C[T](bytes, Generic[_D], bool): ... # TODO: Type parameter defaults
class C[T](Generic[_E], list[_E]): ... # TODO: Type parameter defaults
class C[T](list[_F], Generic[_F]): ... # TODO: Type parameter defaults
class C[*Ts](Generic[*_As]): ...
class C[*Ts](Generic[Unpack[_As]]): ...
class C[*Ts](Generic[Unpack[_Bs]], tuple[*Bs]): ...
class C[*Ts](Callable[[*_Cs], tuple[*Ts]], Generic[_Cs]): ... # TODO: Type parameter defaults
class C[**P](Generic[_P1]): ...
class C[**P](Generic[_P2]): ...
class C[**P](Generic[_P3]): ... # TODO: Type parameter defaults
class C[T](Generic[T, _A]): ...
# See `is_existing_param_of_same_class`.
# `expr_name_to_type_var` doesn't handle named expressions,
# only simple assignments, so there is no fix.
class C[T: (_Z := TypeVar('_Z'))](Generic[_Z]): ...
class C(Generic[_B]):
class D[T](Generic[_B, T]): ...
class C[T]:
class D[U](Generic[T, U]): ...
# In a single run, only the first is reported.
# Others will be reported/fixed in following iterations.
class C[T](Generic[_C], Generic[_D]): ...
class C[T, _C: (str, bytes)](Generic[_D]): ... # TODO: Type parameter defaults
class C[
T # Comment
](Generic[_E]): ... # TODO: Type parameter defaults
class C[T](Generic[Generic[_F]]): ...
class C[T](Generic[Unpack[_A]]): ...
class C[T](Generic[Unpack[_P1]]): ...
class C[T](Generic[Unpack[Unpack[_P2]]]): ...
class C[T](Generic[Unpack[*_As]]): ...
class C[T](Generic[Unpack[_As, _Bs]]): ...
class C[T](Generic[_A, _A]): ...
class C[T](Generic[_A, Unpack[_As]]): ...
class C[T](Generic[*_As, _A]): ...
from somewhere import APublicTypeVar
class C[T](Generic[APublicTypeVar]): ...
class C[T](Generic[APublicTypeVar, _A]): ...
# `_G` has two constraints: `str` and `a := int`.
# The latter cannot be used as a PEP 695 constraint,
# as named expressions are forbidden within type parameter lists.
# See also the `_Z` example above.
class C[T](Generic[_G]): ... # Should be moved down below eventually
# Single-element constraints should not be converted to a bound.
class C[T: (str,)](Generic[_A]): ...
class C[T: [a]](Generic[_A]): ...
# Existing bounds should not be deparenthesized.
# class C[T: (_Y := int)](Generic[_A]): ... # TODO: Uncomment this
# class C[T: (*a,)](Generic[_A]): ... # TODO: Uncomment this
### No errors
class C(Generic[_A]): ...
class C[_A]: ...
class C[_A](list[_A]): ...
class C[_A](list[Generic[_A]]): ...

View File

@@ -9,7 +9,7 @@ use crate::rules::{
};
/// Run lint rules over the [`Binding`]s.
pub(crate) fn bindings(checker: &mut Checker) {
pub(crate) fn bindings(checker: &Checker) {
if !checker.any_enabled(&[
Rule::AssignmentInAssert,
Rule::InvalidAllFormat,
@@ -48,22 +48,22 @@ pub(crate) fn bindings(checker: &mut Checker) {
pyflakes::fixes::remove_exception_handler_assignment(binding, checker.locator)
.map(Fix::safe_edit)
});
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::InvalidAllFormat) {
if let Some(diagnostic) = pylint::rules::invalid_all_format(binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::InvalidAllObject) {
if let Some(diagnostic) = pylint::rules::invalid_all_object(binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::NonAsciiName) {
if let Some(diagnostic) = pylint::rules::non_ascii_name(binding, checker.locator) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::UnconventionalImportAlias) {
@@ -72,61 +72,61 @@ pub(crate) fn bindings(checker: &mut Checker) {
binding,
&checker.settings.flake8_import_conventions.aliases,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::UnaliasedCollectionsAbcSetImport) {
if let Some(diagnostic) =
flake8_pyi::rules::unaliased_collections_abc_set_import(checker, binding)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if !checker.source_type.is_stub() && checker.enabled(Rule::UnquotedTypeAlias) {
if let Some(diagnostics) =
flake8_type_checking::rules::unquoted_type_alias(checker, binding)
{
checker.diagnostics.extend(diagnostics);
checker.report_diagnostics(diagnostics);
}
}
if checker.enabled(Rule::UnsortedDunderSlots) {
if let Some(diagnostic) = ruff::rules::sort_dunder_slots(checker, binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::UsedDummyVariable) {
if let Some(diagnostic) = ruff::rules::used_dummy_variable(checker, binding, binding_id)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::AssignmentInAssert) {
if let Some(diagnostic) = ruff::rules::assignment_in_assert(checker, binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::PytestUnittestRaisesAssertion) {
if let Some(diagnostic) =
flake8_pytest_style::rules::unittest_raises_assertion_binding(checker, binding)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::ForLoopWrites) {
if let Some(diagnostic) = refurb::rules::for_loop_writes_binding(checker, binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CustomTypeVarForSelf) {
if let Some(diagnostic) =
flake8_pyi::rules::custom_type_var_instead_of_self(checker, binding)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::PrivateTypeParameter) {
if let Some(diagnostic) = pyupgrade::rules::private_type_parameter(checker, binding) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}

View File

@@ -5,7 +5,7 @@ use crate::codes::Rule;
use crate::rules::{flake8_simplify, pylint, refurb};
/// Run lint rules over a [`Comprehension`] syntax nodes.
pub(crate) fn comprehension(comprehension: &Comprehension, checker: &mut Checker) {
pub(crate) fn comprehension(comprehension: &Comprehension, checker: &Checker) {
if checker.enabled(Rule::InDictKeys) {
flake8_simplify::rules::key_in_dict_comprehension(checker, comprehension);
}

View File

@@ -13,7 +13,7 @@ use crate::rules::{
};
/// Run lint rules over all deferred scopes in the [`SemanticModel`].
pub(crate) fn deferred_scopes(checker: &mut Checker) {
pub(crate) fn deferred_scopes(checker: &Checker) {
if !checker.any_enabled(&[
Rule::AsyncioDanglingTask,
Rule::BadStaticmethodArgument,
@@ -85,12 +85,11 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
vec![]
};
let mut diagnostics: Vec<Diagnostic> = vec![];
for scope_id in checker.analyze.scopes.iter().rev().copied() {
let scope = &checker.semantic.scopes[scope_id];
if checker.enabled(Rule::UndefinedLocal) {
pyflakes::rules::undefined_local(checker, scope_id, scope, &mut diagnostics);
pyflakes::rules::undefined_local(checker, scope_id, scope);
}
if checker.enabled(Rule::GlobalVariableNotAssigned) {
@@ -112,7 +111,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
.map(|id| checker.semantic.reference(*id))
.all(ResolvedReference::is_load)
{
diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pylint::rules::GlobalVariableNotAssigned {
name: (*name).to_string(),
},
@@ -146,7 +145,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
if scope.kind.is_generator() {
continue;
}
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pylint::rules::RedefinedArgumentFromLocal {
name: name.to_string(),
},
@@ -186,7 +185,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
continue;
}
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::ImportShadowedByLoopVar {
name: name.to_string(),
row: checker.compute_source_row(shadowed.start()),
@@ -347,7 +346,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
diagnostic.set_fix(fix.clone());
}
diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -356,55 +355,47 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
|| matches!(scope.kind, ScopeKind::Module | ScopeKind::Function(_))
{
if checker.enabled(Rule::UnusedPrivateTypeVar) {
flake8_pyi::rules::unused_private_type_var(checker, scope, &mut diagnostics);
flake8_pyi::rules::unused_private_type_var(checker, scope);
}
if checker.enabled(Rule::UnusedPrivateProtocol) {
flake8_pyi::rules::unused_private_protocol(checker, scope, &mut diagnostics);
flake8_pyi::rules::unused_private_protocol(checker, scope);
}
if checker.enabled(Rule::UnusedPrivateTypeAlias) {
flake8_pyi::rules::unused_private_type_alias(checker, scope, &mut diagnostics);
flake8_pyi::rules::unused_private_type_alias(checker, scope);
}
if checker.enabled(Rule::UnusedPrivateTypedDict) {
flake8_pyi::rules::unused_private_typed_dict(checker, scope, &mut diagnostics);
flake8_pyi::rules::unused_private_typed_dict(checker, scope);
}
}
if checker.enabled(Rule::AsyncioDanglingTask) {
ruff::rules::asyncio_dangling_binding(scope, &checker.semantic, &mut diagnostics);
ruff::rules::asyncio_dangling_binding(scope, checker);
}
if let Some(class_def) = scope.kind.as_class() {
if checker.enabled(Rule::BuiltinAttributeShadowing) {
flake8_builtins::rules::builtin_attribute_shadowing(
checker,
scope_id,
scope,
class_def,
&mut diagnostics,
checker, scope_id, scope, class_def,
);
}
if checker.enabled(Rule::FunctionCallInDataclassDefaultArgument) {
ruff::rules::function_call_in_dataclass_default(
checker,
class_def,
&mut diagnostics,
);
ruff::rules::function_call_in_dataclass_default(checker, class_def);
}
if checker.enabled(Rule::MutableClassDefault) {
ruff::rules::mutable_class_default(checker, class_def, &mut diagnostics);
ruff::rules::mutable_class_default(checker, class_def);
}
if checker.enabled(Rule::MutableDataclassDefault) {
ruff::rules::mutable_dataclass_default(checker, class_def, &mut diagnostics);
ruff::rules::mutable_dataclass_default(checker, class_def);
}
}
if matches!(scope.kind, ScopeKind::Function(_) | ScopeKind::Lambda(_)) {
if checker.enabled(Rule::UnusedVariable) {
pyflakes::rules::unused_variable(checker, scope, &mut diagnostics);
pyflakes::rules::unused_variable(checker, scope);
}
if checker.enabled(Rule::UnusedAnnotation) {
pyflakes::rules::unused_annotation(checker, scope, &mut diagnostics);
pyflakes::rules::unused_annotation(checker, scope);
}
if !checker.source_type.is_stub() {
@@ -415,11 +406,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
Rule::UnusedMethodArgument,
Rule::UnusedStaticMethodArgument,
]) {
flake8_unused_arguments::rules::unused_arguments(
checker,
scope,
&mut diagnostics,
);
flake8_unused_arguments::rules::unused_arguments(checker, scope);
}
}
}
@@ -428,11 +415,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
if !checker.source_type.is_stub()
&& checker.enabled(Rule::RuntimeImportInTypeCheckingBlock)
{
flake8_type_checking::rules::runtime_import_in_type_checking_block(
checker,
scope,
&mut diagnostics,
);
flake8_type_checking::rules::runtime_import_in_type_checking_block(checker, scope);
}
if enforce_typing_only_imports {
let runtime_imports: Vec<&Binding> = checker
@@ -447,47 +430,45 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
checker,
scope,
&runtime_imports,
&mut diagnostics,
);
}
if checker.enabled(Rule::UnusedImport) {
pyflakes::rules::unused_import(checker, scope, &mut diagnostics);
pyflakes::rules::unused_import(checker, scope);
}
if checker.enabled(Rule::ImportPrivateName) {
pylint::rules::import_private_name(checker, scope, &mut diagnostics);
pylint::rules::import_private_name(checker, scope);
}
}
if scope.kind.is_function() {
if checker.enabled(Rule::NoSelfUse) {
pylint::rules::no_self_use(checker, scope_id, scope, &mut diagnostics);
pylint::rules::no_self_use(checker, scope_id, scope);
}
if checker.enabled(Rule::TooManyLocals) {
pylint::rules::too_many_locals(checker, scope, &mut diagnostics);
pylint::rules::too_many_locals(checker, scope);
}
if checker.enabled(Rule::SingledispatchMethod) {
pylint::rules::singledispatch_method(checker, scope, &mut diagnostics);
pylint::rules::singledispatch_method(checker, scope);
}
if checker.enabled(Rule::SingledispatchmethodFunction) {
pylint::rules::singledispatchmethod_function(checker, scope, &mut diagnostics);
pylint::rules::singledispatchmethod_function(checker, scope);
}
if checker.enabled(Rule::BadStaticmethodArgument) {
pylint::rules::bad_staticmethod_argument(checker, scope, &mut diagnostics);
pylint::rules::bad_staticmethod_argument(checker, scope);
}
if checker.any_enabled(&[
Rule::InvalidFirstArgumentNameForClassMethod,
Rule::InvalidFirstArgumentNameForMethod,
]) {
pep8_naming::rules::invalid_first_argument_name(checker, scope, &mut diagnostics);
pep8_naming::rules::invalid_first_argument_name(checker, scope);
}
}
}
checker.diagnostics.extend(diagnostics);
}

View File

@@ -139,13 +139,11 @@ pub(crate) fn definitions(checker: &mut Checker) {
&checker.semantic,
)
}) {
checker
.diagnostics
.extend(flake8_annotations::rules::definition(
checker,
definition,
*visibility,
));
checker.report_diagnostics(flake8_annotations::rules::definition(
checker,
definition,
*visibility,
));
}
overloaded_name =
flake8_annotations::helpers::overloaded_name(definition, &checker.semantic);

View File

@@ -8,7 +8,7 @@ use crate::rules::{
};
/// Run lint rules over an [`ExceptHandler`] syntax node.
pub(crate) fn except_handler(except_handler: &ExceptHandler, checker: &mut Checker) {
pub(crate) fn except_handler(except_handler: &ExceptHandler, checker: &Checker) {
match except_handler {
ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler {
type_,
@@ -23,7 +23,7 @@ pub(crate) fn except_handler(except_handler: &ExceptHandler, checker: &mut Check
except_handler,
checker.locator,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::RaiseWithoutFromInsideExcept) {

View File

@@ -21,7 +21,7 @@ use crate::rules::{
use crate::settings::types::PythonVersion;
/// Run lint rules over an [`Expr`] syntax node.
pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
pub(crate) fn expression(expr: &Expr, checker: &Checker) {
match expr {
Expr::Subscript(subscript @ ast::ExprSubscript { value, slice, .. }) => {
// Ex) Optional[...], Union[...]
@@ -201,7 +201,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
check_two_starred_expressions,
expr.range(),
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -515,7 +515,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
match pyflakes::format::FormatSummary::try_from(string_value.to_str()) {
Err(e) => {
if checker.enabled(Rule::StringDotFormatInvalidFormat) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::StringDotFormatInvalidFormat {
message: pyflakes::format::error_to_string(&e),
},
@@ -842,13 +842,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
flake8_comprehensions::rules::unnecessary_subscript_reversal(checker, call);
}
if checker.enabled(Rule::UnnecessaryMap) {
flake8_comprehensions::rules::unnecessary_map(
checker,
expr,
checker.semantic.current_expression_parent(),
func,
args,
);
flake8_comprehensions::rules::unnecessary_map(checker, call);
}
if checker.enabled(Rule::UnnecessaryComprehensionInCall) {
flake8_comprehensions::rules::unnecessary_comprehension_in_call(
@@ -912,7 +906,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
pylint::rules::bad_open_mode(checker, call);
}
if checker.enabled(Rule::BadStrStripCall) {
pylint::rules::bad_str_strip_call(checker, func, args);
pylint::rules::bad_str_strip_call(checker, call);
}
if checker.enabled(Rule::ShallowCopyEnviron) {
pylint::rules::shallow_copy_environ(checker, call);
@@ -931,7 +925,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
}
if checker.enabled(Rule::PytestPatchWithLambda) {
if let Some(diagnostic) = flake8_pytest_style::rules::patch_with_lambda(call) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.any_enabled(&[
@@ -1287,7 +1281,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
..
}) => {
if checker.enabled(Rule::PercentFormatUnsupportedFormatCharacter) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::PercentFormatUnsupportedFormatCharacter {
char: c,
},
@@ -1297,7 +1291,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
}
Err(e) => {
if checker.enabled(Rule::PercentFormatInvalidFormat) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::PercentFormatInvalidFormat {
message: e.to_string(),
},
@@ -1371,7 +1365,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
checker.locator,
checker.settings,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CollectionLiteralConcatenation) {

View File

@@ -5,7 +5,7 @@ use crate::codes::Rule;
use crate::rules::{flake8_bugbear, ruff};
/// Run lint rules over a module.
pub(crate) fn module(suite: &Suite, checker: &mut Checker) {
pub(crate) fn module(suite: &Suite, checker: &Checker) {
if checker.enabled(Rule::FStringDocstring) {
flake8_bugbear::rules::f_string_docstring(checker, suite);
}

View File

@@ -6,7 +6,7 @@ use crate::codes::Rule;
use crate::rules::{flake8_builtins, pycodestyle};
/// Run lint rules over a [`Parameter`] syntax node.
pub(crate) fn parameter(parameter: &Parameter, checker: &mut Checker) {
pub(crate) fn parameter(parameter: &Parameter, checker: &Checker) {
if checker.enabled(Rule::AmbiguousVariableName) {
pycodestyle::rules::ambiguous_variable_name(
checker,

View File

@@ -5,7 +5,7 @@ use crate::codes::Rule;
use crate::rules::{flake8_bugbear, flake8_pyi, ruff};
/// Run lint rules over a [`Parameters`] syntax node.
pub(crate) fn parameters(parameters: &Parameters, checker: &mut Checker) {
pub(crate) fn parameters(parameters: &Parameters, checker: &Checker) {
if checker.enabled(Rule::FunctionCallInDefaultArgument) {
flake8_bugbear::rules::function_call_in_argument_default(checker, parameters);
}

View File

@@ -39,7 +39,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if !checker.semantic.scope_id.is_global() {
for name in names {
if checker.semantic.nonlocal(name).is_none() {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pylint::rules::NonlocalWithoutBinding {
name: name.to_string(),
},
@@ -59,7 +59,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
stmt,
&mut checker.semantic.current_statements().skip(1),
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -69,7 +69,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
stmt,
&mut checker.semantic.current_statements().skip(1),
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -99,7 +99,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
if checker.enabled(Rule::AmbiguousFunctionName) {
if let Some(diagnostic) = pycodestyle::rules::ambiguous_function_name(name) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::InvalidBoolReturnType) {
@@ -128,7 +128,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
&checker.semantic,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.source_type.is_stub() {
@@ -187,7 +187,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
name,
&checker.settings.pep8_naming.ignore_names,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::GlobalStatement) {
@@ -239,7 +239,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
body,
checker.settings.mccabe.max_complexity,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::HardcodedPasswordDefault) {
@@ -265,7 +265,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
body,
checker.settings.pylint.max_returns,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::TooManyBranches) {
@@ -274,7 +274,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
body,
checker.settings.pylint.max_branches,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::TooManyStatements) {
@@ -283,7 +283,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
body,
checker.settings.pylint.max_statements,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.any_enabled(&[
@@ -351,9 +351,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
#[cfg(any(feature = "test-rules", test))]
if checker.enabled(Rule::UnreachableCode) {
checker
.diagnostics
.extend(pylint::rules::in_function(name, body));
checker.report_diagnostics(pylint::rules::in_function(name, body));
}
if checker.enabled(Rule::ReimplementedOperator) {
refurb::rules::reimplemented_operator(checker, &function_def.into());
@@ -456,7 +454,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
if checker.enabled(Rule::AmbiguousClassName) {
if let Some(diagnostic) = pycodestyle::rules::ambiguous_class_name(name) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::InvalidClassName) {
@@ -465,7 +463,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
name,
&checker.settings.pep8_naming.ignore_names,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::ErrorSuffixOnExceptionName) {
@@ -475,7 +473,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
name,
&checker.settings.pep8_naming.ignore_names,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if !checker.source_type.is_stub() {
@@ -554,6 +552,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.enabled(Rule::NonPEP695GenericClass) {
pyupgrade::rules::non_pep695_generic_class(checker, class_def);
}
if checker.enabled(Rule::ClassWithMixedTypeVars) {
ruff::rules::class_with_mixed_type_vars(checker, class_def);
}
}
Stmt::Import(ast::StmtImport { names, range: _ }) => {
if checker.enabled(Rule::MultipleImportsOnOneLine) {
@@ -612,7 +613,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if let Some(diagnostic) =
flake8_debugger::rules::debugger_import(stmt, None, &alias.name)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::BannedApi) {
@@ -639,7 +640,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if let Some(diagnostic) =
pylint::rules::import_self(alias, checker.module.qualified_name())
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if let Some(asname) = &alias.asname {
@@ -654,7 +655,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::LowercaseImportedAsNonLowercase) {
@@ -667,7 +668,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsLowercase) {
@@ -680,7 +681,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsConstant) {
@@ -691,14 +692,14 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
stmt,
&checker.settings.pep8_naming.ignore_names,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsAcronym) {
if let Some(diagnostic) = pep8_naming::rules::camelcase_imported_as_acronym(
name, asname, alias, stmt, checker,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -712,7 +713,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.flake8_import_conventions.banned_aliases,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -722,7 +723,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&alias.name,
alias.asname.as_deref(),
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::BuiltinImportShadowing) {
@@ -838,7 +839,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if let Some(diagnostic) =
flake8_pytest_style::rules::import_from(stmt, module, level)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.source_type.is_stub() {
@@ -853,7 +854,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
if checker.enabled(Rule::LateFutureImport) {
if checker.semantic.seen_futures_boundary() {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::LateFutureImport,
stmt.range(),
));
@@ -862,7 +863,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
} else if &alias.name == "*" {
if checker.enabled(Rule::UndefinedLocalWithNestedImportStarUsage) {
if !matches!(checker.semantic.current_scope().kind, ScopeKind::Module) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::UndefinedLocalWithNestedImportStarUsage {
name: helpers::format_import_from(level, module).to_string(),
},
@@ -871,7 +872,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
}
if checker.enabled(Rule::UndefinedLocalWithImportStar) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::UndefinedLocalWithImportStar {
name: helpers::format_import_from(level, module).to_string(),
},
@@ -888,14 +889,14 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
checker.module.qualified_name(),
checker.settings.flake8_tidy_imports.ban_relative_imports,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::Debugger) {
if let Some(diagnostic) =
flake8_debugger::rules::debugger_import(stmt, module, &alias.name)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::BannedImportAlias) {
@@ -910,7 +911,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.flake8_import_conventions.banned_aliases,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
}
@@ -925,7 +926,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::LowercaseImportedAsNonLowercase) {
@@ -938,7 +939,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsLowercase) {
@@ -951,7 +952,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&checker.settings.pep8_naming.ignore_names,
)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsConstant) {
@@ -962,7 +963,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
stmt,
&checker.settings.pep8_naming.ignore_names,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::CamelcaseImportedAsAcronym) {
@@ -973,7 +974,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
stmt,
checker,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if !checker.source_type.is_stub() {
@@ -993,7 +994,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
names,
checker.module.qualified_name(),
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::BannedImportFrom) {
@@ -1002,7 +1003,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
&helpers::format_import_from(level, module),
&checker.settings.flake8_import_conventions.banned_from,
) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::ByteStringUsage) {
@@ -1171,7 +1172,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
if checker.any_enabled(&[Rule::BadVersionInfoComparison, Rule::BadVersionInfoOrder]) {
fn bad_version_info_comparison(
checker: &mut Checker,
checker: &Checker,
test: &Expr,
has_else_clause: bool,
) {
@@ -1218,9 +1219,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
) => {
if !checker.semantic.in_type_checking_block() {
if checker.enabled(Rule::Assert) {
checker
.diagnostics
.push(flake8_bandit::rules::assert_used(stmt));
checker.report_diagnostic(flake8_bandit::rules::assert_used(stmt));
}
}
if checker.enabled(Rule::AssertTuple) {
@@ -1437,7 +1436,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if let Some(diagnostic) =
pyflakes::rules::default_except_not_last(handlers, checker.locator)
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.any_enabled(&[
@@ -1534,7 +1533,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
if checker.enabled(Rule::PandasDfVariableName) {
if let Some(diagnostic) = pandas_vet::rules::assignment_to_df(targets) {
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker
@@ -1732,7 +1731,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if let Some(diagnostic) =
ruff::rules::asyncio_dangling_task(value, checker.semantic())
{
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}
if checker.enabled(Rule::RepeatedAppend) {

View File

@@ -5,7 +5,7 @@ use crate::codes::Rule;
use crate::rules::{flake8_bandit, flake8_pyi, flake8_quotes, pycodestyle, ruff};
/// Run lint rules over a [`StringLike`] syntax nodes.
pub(crate) fn string_like(string_like: StringLike, checker: &mut Checker) {
pub(crate) fn string_like(string_like: StringLike, checker: &Checker) {
if checker.any_enabled(&[
Rule::AmbiguousUnicodeCharacterString,
Rule::AmbiguousUnicodeCharacterDocstring,

View File

@@ -6,7 +6,7 @@ use crate::rules::flake8_pie;
use crate::rules::refurb;
/// Run lint rules over a suite of [`Stmt`] syntax nodes.
pub(crate) fn suite(suite: &[Stmt], checker: &mut Checker) {
pub(crate) fn suite(suite: &[Stmt], checker: &Checker) {
if checker.enabled(Rule::UnnecessaryPlaceholder) {
flake8_pie::rules::unnecessary_placeholder(checker, suite);
}

View File

@@ -7,7 +7,7 @@ use crate::codes::Rule;
use crate::rules::pyflakes;
/// Run lint rules over all [`UnresolvedReference`] entities in the [`SemanticModel`].
pub(crate) fn unresolved_references(checker: &mut Checker) {
pub(crate) fn unresolved_references(checker: &Checker) {
if !checker.any_enabled(&[Rule::UndefinedLocalWithImportStarUsage, Rule::UndefinedName]) {
return;
}
@@ -15,7 +15,7 @@ pub(crate) fn unresolved_references(checker: &mut Checker) {
for reference in checker.semantic.unresolved_references() {
if reference.is_wildcard_import() {
if checker.enabled(Rule::UndefinedLocalWithImportStarUsage) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::UndefinedLocalWithImportStarUsage {
name: reference.name(checker.source()).to_string(),
},
@@ -42,7 +42,7 @@ pub(crate) fn unresolved_references(checker: &mut Checker) {
let symbol_name = reference.name(checker.source());
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
pyflakes::rules::UndefinedName {
name: symbol_name.to_string(),
minor_version_builtin_added: version_builtin_was_added(symbol_name),

View File

@@ -9,11 +9,6 @@
//! parent scopes have been fully traversed. Individual rules may also perform internal traversals
//! of the AST.
//!
//! While the [`Checker`] is typically passed by mutable reference to the individual lint rule
//! implementations, most of its constituent components are intended to be treated immutably, with
//! the exception of the [`Diagnostic`] vector, which is intended to be mutated by the individual
//! lint rules. In the future, this should be formalized in the API.
//!
//! The individual [`Visitor`] implementations within the [`Checker`] typically proceed in four
//! steps:
//!
@@ -31,7 +26,7 @@ use std::path::Path;
use itertools::Itertools;
use log::debug;
use rustc_hash::FxHashMap;
use rustc_hash::{FxHashMap, FxHashSet};
use ruff_diagnostics::{Diagnostic, IsolationLevel};
use ruff_notebook::{CellOffsets, NotebookIndex};
@@ -221,9 +216,9 @@ pub(crate) struct Checker<'a> {
/// A set of deferred nodes to be analyzed after the AST traversal (e.g., `for` loops).
analyze: deferred::Analyze,
/// The cumulative set of diagnostics computed across all lint rules.
pub(crate) diagnostics: Vec<Diagnostic>,
diagnostics: RefCell<Vec<Diagnostic>>,
/// The list of names already seen by flake8-bugbear diagnostics, to avoid duplicate violations.
pub(crate) flake8_bugbear_seen: Vec<TextRange>,
flake8_bugbear_seen: RefCell<FxHashSet<TextRange>>,
/// The end offset of the last visited statement.
last_stmt_end: TextSize,
/// A state describing if a docstring is expected or not.
@@ -248,6 +243,11 @@ impl<'a> Checker<'a> {
cell_offsets: Option<&'a CellOffsets>,
notebook_index: Option<&'a NotebookIndex>,
) -> Checker<'a> {
let mut semantic = SemanticModel::new(&settings.typing_modules, path, module);
if settings.preview.is_enabled() {
// Set the feature flag to test `TYPE_CHECKING` semantic changes
semantic.flags |= SemanticModelFlags::NEW_TYPE_CHECKING_BLOCK_DETECTION;
}
Self {
parsed,
parsed_type_annotation: None,
@@ -263,11 +263,11 @@ impl<'a> Checker<'a> {
stylist,
indexer,
importer: Importer::new(parsed, locator, stylist),
semantic: SemanticModel::new(&settings.typing_modules, path, module),
semantic,
visit: deferred::Visit::default(),
analyze: deferred::Analyze::default(),
diagnostics: Vec::default(),
flake8_bugbear_seen: Vec::default(),
diagnostics: RefCell::default(),
flake8_bugbear_seen: RefCell::default(),
cell_offsets,
notebook_index,
last_stmt_end: TextSize::default(),
@@ -357,6 +357,30 @@ impl<'a> Checker<'a> {
self.indexer.comment_ranges()
}
/// Push a new [`Diagnostic`] to the collection in the [`Checker`]
pub(crate) fn report_diagnostic(&self, diagnostic: Diagnostic) {
let mut diagnostics = self.diagnostics.borrow_mut();
diagnostics.push(diagnostic);
}
/// Extend the collection of [`Diagnostic`] objects in the [`Checker`]
pub(crate) fn report_diagnostics<I>(&self, diagnostics: I)
where
I: IntoIterator<Item = Diagnostic>,
{
let mut checker_diagnostics = self.diagnostics.borrow_mut();
checker_diagnostics.extend(diagnostics);
}
/// Adds a [`TextRange`] to the set of ranges of variable names
/// flagged in `flake8-bugbear` violations so far.
///
/// Returns whether the value was newly inserted.
pub(crate) fn insert_flake8_bugbear_range(&self, range: TextRange) -> bool {
let mut ranges = self.flake8_bugbear_seen.borrow_mut();
ranges.insert(range)
}
/// Returns the [`Tokens`] for the parsed type annotation if the checker is in a typing context
/// or the parsed source code.
pub(crate) fn tokens(&self) -> &'a Tokens {
@@ -471,9 +495,9 @@ impl<'a> Checker<'a> {
}
/// Push `diagnostic` if the checker is not in a `@no_type_check` context.
pub(crate) fn push_type_diagnostic(&mut self, diagnostic: Diagnostic) {
pub(crate) fn report_type_diagnostic(&self, diagnostic: Diagnostic) {
if !self.semantic.in_no_type_check() {
self.diagnostics.push(diagnostic);
self.report_diagnostic(diagnostic);
}
}
}
@@ -2414,7 +2438,7 @@ impl<'a> Checker<'a> {
self.semantic.restore(snapshot);
if self.enabled(Rule::ForwardAnnotationSyntaxError) {
self.push_type_diagnostic(Diagnostic::new(
self.report_type_diagnostic(Diagnostic::new(
pyflakes::rules::ForwardAnnotationSyntaxError {
parse_error: parse_error.error.to_string(),
},
@@ -2556,7 +2580,7 @@ impl<'a> Checker<'a> {
} else {
if self.semantic.global_scope().uses_star_imports() {
if self.enabled(Rule::UndefinedLocalWithImportStarUsage) {
self.diagnostics.push(
self.diagnostics.get_mut().push(
Diagnostic::new(
pyflakes::rules::UndefinedLocalWithImportStarUsage {
name: name.to_string(),
@@ -2571,7 +2595,7 @@ impl<'a> Checker<'a> {
if self.settings.preview.is_enabled()
|| !self.path.ends_with("__init__.py")
{
self.diagnostics.push(
self.diagnostics.get_mut().push(
Diagnostic::new(
pyflakes::rules::UndefinedExport {
name: name.to_string(),
@@ -2695,13 +2719,13 @@ pub(crate) fn check_ast(
analyze::deferred_lambdas(&mut checker);
analyze::deferred_for_loops(&mut checker);
analyze::definitions(&mut checker);
analyze::bindings(&mut checker);
analyze::unresolved_references(&mut checker);
analyze::bindings(&checker);
analyze::unresolved_references(&checker);
// Reset the scope to module-level, and check all consumed scopes.
checker.semantic.scope_id = ScopeId::global();
checker.analyze.scopes.push(ScopeId::global());
analyze::deferred_scopes(&mut checker);
analyze::deferred_scopes(&checker);
checker.diagnostics
checker.diagnostics.take()
}

View File

@@ -46,12 +46,7 @@ pub(crate) fn check_file_path(
// flake8-builtins
if settings.rules.enabled(Rule::StdlibModuleShadowing) {
if let Some(diagnostic) = stdlib_module_shadowing(
path,
package,
&settings.flake8_builtins.builtins_allowed_modules,
settings.target_version,
) {
if let Some(diagnostic) = stdlib_module_shadowing(path, settings) {
diagnostics.push(diagnostic);
}
}

View File

@@ -1005,6 +1005,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "049") => (RuleGroup::Preview, rules::ruff::rules::DataclassEnum),
(Ruff, "051") => (RuleGroup::Preview, rules::ruff::rules::IfKeyInDictDel),
(Ruff, "052") => (RuleGroup::Preview, rules::ruff::rules::UsedDummyVariable),
(Ruff, "053") => (RuleGroup::Preview, rules::ruff::rules::ClassWithMixedTypeVars),
(Ruff, "055") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryRegularExpression),
(Ruff, "056") => (RuleGroup::Preview, rules::ruff::rules::FalsyDictGetFallback),
(Ruff, "057") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryRound),

View File

@@ -9,7 +9,7 @@ use anyhow::Result;
use libcst_native::{ImportAlias, Name as cstName, NameOrAttribute};
use ruff_diagnostics::Edit;
use ruff_python_ast::{self as ast, ModModule, Stmt};
use ruff_python_ast::{self as ast, Expr, ModModule, Stmt};
use ruff_python_codegen::Stylist;
use ruff_python_parser::{Parsed, Tokens};
use ruff_python_semantic::{
@@ -125,7 +125,7 @@ impl<'a> Importer<'a> {
&self,
import: &ImportedMembers,
at: TextSize,
semantic: &SemanticModel,
semantic: &SemanticModel<'a>,
) -> Result<TypingImportEdit> {
// Generate the modified import statement.
let content = fix::codemods::retain_imports(
@@ -135,6 +135,39 @@ impl<'a> Importer<'a> {
self.stylist,
)?;
// Add the import to an existing `TYPE_CHECKING` block.
if let Some(block) = self.preceding_type_checking_block(at) {
// Add the import to the existing `TYPE_CHECKING` block.
let type_checking_edit =
if let Some(statement) = Self::type_checking_binding_statement(semantic, block) {
if statement == import.statement {
// Special-case: if the `TYPE_CHECKING` symbol is imported as part of the same
// statement that we're modifying, avoid adding a no-op edit. For example, here,
// the `TYPE_CHECKING` no-op edit would overlap with the edit to remove `Final`
// from the import:
// ```python
// from __future__ import annotations
//
// from typing import Final, TYPE_CHECKING
//
// Const: Final[dict] = {}
// ```
None
} else {
Some(Edit::range_replacement(
self.locator.slice(statement.range()).to_string(),
statement.range(),
))
}
} else {
None
};
return Ok(TypingImportEdit {
type_checking_edit,
add_import_edit: self.add_to_type_checking_block(&content, block.start()),
});
}
// Import the `TYPE_CHECKING` symbol from the typing module.
let (type_checking_edit, type_checking) =
if let Some(type_checking) = Self::find_type_checking(at, semantic)? {
@@ -179,13 +212,10 @@ impl<'a> Importer<'a> {
(Some(edit), name)
};
// Add the import to a `TYPE_CHECKING` block.
let add_import_edit = if let Some(block) = self.preceding_type_checking_block(at) {
// Add the import to the `TYPE_CHECKING` block.
self.add_to_type_checking_block(&content, block.start())
} else {
// Add the import to a new `TYPE_CHECKING` block.
self.add_type_checking_block(
// Add the import to a new `TYPE_CHECKING` block.
Ok(TypingImportEdit {
type_checking_edit,
add_import_edit: self.add_type_checking_block(
&format!(
"{}if {type_checking}:{}{}",
self.stylist.line_ending().as_str(),
@@ -193,13 +223,25 @@ impl<'a> Importer<'a> {
indent(&content, self.stylist.indentation())
),
at,
)?
)?,
})
}
fn type_checking_binding_statement(
semantic: &SemanticModel<'a>,
type_checking_block: &Stmt,
) -> Option<&'a Stmt> {
let Stmt::If(ast::StmtIf { test, .. }) = type_checking_block else {
return None;
};
Ok(TypingImportEdit {
type_checking_edit,
add_import_edit,
})
let mut source = test;
while let Expr::Attribute(ast::ExprAttribute { value, .. }) = source.as_ref() {
source = value;
}
semantic
.binding(semantic.resolve_name(source.as_name_expr()?)?)
.statement(semantic)
}
/// Find a reference to `typing.TYPE_CHECKING`.

View File

@@ -387,25 +387,36 @@ pub(crate) enum ShadowedKind {
}
impl ShadowedKind {
/// Determines the kind of shadowing or conflict for a given variable name.
/// Determines the kind of shadowing or conflict for the proposed new name of a given [`Binding`].
///
/// This function is useful for checking whether or not the `target` of a [`Rename::rename`]
/// This function is useful for checking whether or not the `target` of a [`Renamer::rename`]
/// will shadow another binding.
pub(crate) fn new(name: &str, checker: &Checker, scope_id: ScopeId) -> ShadowedKind {
pub(crate) fn new(binding: &Binding, new_name: &str, checker: &Checker) -> ShadowedKind {
// Check the kind in order of precedence
if is_keyword(name) {
if is_keyword(new_name) {
return ShadowedKind::Keyword;
}
if is_python_builtin(
name,
new_name,
checker.settings.target_version.minor(),
checker.source_type.is_ipynb(),
) {
return ShadowedKind::BuiltIn;
}
if !checker.semantic().is_available_in_scope(name, scope_id) {
let semantic = checker.semantic();
if !semantic.is_available_in_scope(new_name, binding.scope) {
return ShadowedKind::Some;
}
if binding
.references()
.map(|reference_id| semantic.reference(reference_id).scope_id())
.dedup()
.any(|scope| !semantic.is_available_in_scope(new_name, scope))
{
return ShadowedKind::Some;
}

View File

@@ -50,7 +50,7 @@ impl Violation for AirflowDagNoScheduleArgument {
}
/// AIR301
pub(crate) fn dag_no_schedule_argument(checker: &mut Checker, expr: &Expr) {
pub(crate) fn dag_no_schedule_argument(checker: &Checker, expr: &Expr) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
@@ -86,5 +86,5 @@ pub(crate) fn dag_no_schedule_argument(checker: &mut Checker, expr: &Expr) {
// Produce a diagnostic when the `schedule` keyword argument is not found.
let diagnostic = Diagnostic::new(AirflowDagNoScheduleArgument, expr.range());
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}

View File

@@ -83,7 +83,7 @@ impl Violation for Airflow3MovedToProvider {
}
/// AIR303
pub(crate) fn moved_to_provider_in_3(checker: &mut Checker, expr: &Expr) {
pub(crate) fn moved_to_provider_in_3(checker: &Checker, expr: &Expr) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
@@ -112,7 +112,7 @@ enum Replacement {
},
}
fn check_names_moved_to_provider(checker: &mut Checker, expr: &Expr, ranged: TextRange) {
fn check_names_moved_to_provider(checker: &Checker, expr: &Expr, ranged: TextRange) {
let Some(qualified_name) = checker.semantic().resolve_qualified_name(expr) else {
return;
};
@@ -1018,7 +1018,7 @@ fn check_names_moved_to_provider(checker: &mut Checker, expr: &Expr, ranged: Tex
},
_ => return,
};
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3MovedToProvider {
deprecated: qualified_name.to_string(),
replacement,

View File

@@ -80,7 +80,7 @@ enum Replacement {
}
/// AIR302
pub(crate) fn airflow_3_removal_expr(checker: &mut Checker, expr: &Expr) {
pub(crate) fn airflow_3_removal_expr(checker: &Checker, expr: &Expr) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
@@ -117,10 +117,7 @@ pub(crate) fn airflow_3_removal_expr(checker: &mut Checker, expr: &Expr) {
}
/// AIR302
pub(crate) fn airflow_3_removal_function_def(
checker: &mut Checker,
function_def: &StmtFunctionDef,
) {
pub(crate) fn airflow_3_removal_function_def(checker: &Checker, function_def: &StmtFunctionDef) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
@@ -156,7 +153,7 @@ const REMOVED_CONTEXT_KEYS: [&str; 12] = [
/// # 'execution_date' is removed in Airflow 3.0
/// pass
/// ```
fn check_function_parameters(checker: &mut Checker, function_def: &StmtFunctionDef) {
fn check_function_parameters(checker: &Checker, function_def: &StmtFunctionDef) {
if !is_airflow_task(function_def, checker.semantic())
&& !is_execute_method_inherits_from_airflow_operator(function_def, checker.semantic())
{
@@ -166,7 +163,7 @@ fn check_function_parameters(checker: &mut Checker, function_def: &StmtFunctionD
for param in function_def.parameters.iter_non_variadic_params() {
let param_name = param.name();
if REMOVED_CONTEXT_KEYS.contains(&param_name.as_str()) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: param_name.to_string(),
replacement: Replacement::None,
@@ -186,29 +183,25 @@ fn check_function_parameters(checker: &mut Checker, function_def: &StmtFunctionD
///
/// DAG(schedule_interval="@daily")
/// ```
fn check_call_arguments(
checker: &mut Checker,
qualified_name: &QualifiedName,
arguments: &Arguments,
) {
fn check_call_arguments(checker: &Checker, qualified_name: &QualifiedName, arguments: &Arguments) {
match qualified_name.segments() {
["airflow", .., "DAG" | "dag"] => {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"schedule_interval",
Some("schedule"),
));
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"timetable",
Some("schedule"),
));
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"sla_miss_callback",
None,
));
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"fail_stop",
Some("fail_fast"),
@@ -217,7 +210,7 @@ fn check_call_arguments(
_ => {
if is_airflow_auth_manager(qualified_name.segments()) {
if !arguments.is_empty() {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: String::from("appbuilder"),
replacement: Replacement::Message(
@@ -228,44 +221,42 @@ fn check_call_arguments(
));
}
} else if is_airflow_task_handler(qualified_name.segments()) {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"filename_template",
None,
));
} else if is_airflow_operator(qualified_name.segments()) {
checker
.diagnostics
.extend(diagnostic_for_argument(arguments, "sla", None));
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(arguments, "sla", None));
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"task_concurrency",
Some("max_active_tis_per_dag"),
));
match qualified_name.segments() {
["airflow", .., "operators", "trigger_dagrun", "TriggerDagRunOperator"] => {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"execution_date",
Some("logical_date"),
));
}
["airflow", .., "operators", "datetime", "BranchDateTimeOperator"] => {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"use_task_execution_day",
Some("use_task_logical_date"),
));
}
["airflow", .., "operators", "weekday", "DayOfWeekSensor"] => {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"use_task_execution_day",
Some("use_task_logical_date"),
));
}
["airflow", .., "operators", "weekday", "BranchDayOfWeekOperator"] => {
checker.diagnostics.extend(diagnostic_for_argument(
checker.report_diagnostics(diagnostic_for_argument(
arguments,
"use_task_execution_day",
Some("use_task_logical_date"),
@@ -288,7 +279,7 @@ fn check_call_arguments(
/// info = DatasetLineageInfo()
/// info.dataset
/// ```
fn check_class_attribute(checker: &mut Checker, attribute_expr: &ExprAttribute) {
fn check_class_attribute(checker: &Checker, attribute_expr: &ExprAttribute) {
let ExprAttribute { value, attr, .. } = attribute_expr;
let Some(qualname) = typing::resolve_assignment(value, checker.semantic()) else {
@@ -312,7 +303,7 @@ fn check_class_attribute(checker: &mut Checker, attribute_expr: &ExprAttribute)
};
if let Some(replacement) = replacement {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: attr.to_string(),
replacement,
@@ -350,7 +341,7 @@ fn check_class_attribute(checker: &mut Checker, attribute_expr: &ExprAttribute)
/// def my_task(**context):
/// context.get("conf") # 'conf' is removed in Airflow 3.0
/// ```
fn check_context_key_usage_in_call(checker: &mut Checker, call_expr: &ExprCall) {
fn check_context_key_usage_in_call(checker: &Checker, call_expr: &ExprCall) {
if !in_airflow_task_function(checker.semantic()) {
return;
}
@@ -386,7 +377,7 @@ fn check_context_key_usage_in_call(checker: &mut Checker, call_expr: &ExprCall)
continue;
};
if value == removed_key {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: removed_key.to_string(),
replacement: Replacement::None,
@@ -399,7 +390,7 @@ fn check_context_key_usage_in_call(checker: &mut Checker, call_expr: &ExprCall)
/// Check if a subscript expression accesses a removed Airflow context variable.
/// If a removed key is found, push a corresponding diagnostic.
fn check_context_key_usage_in_subscript(checker: &mut Checker, subscript: &ExprSubscript) {
fn check_context_key_usage_in_subscript(checker: &Checker, subscript: &ExprSubscript) {
if !in_airflow_task_function(checker.semantic()) {
return;
}
@@ -427,7 +418,7 @@ fn check_context_key_usage_in_subscript(checker: &mut Checker, subscript: &ExprS
}
if REMOVED_CONTEXT_KEYS.contains(&key.to_str()) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: key.to_string(),
replacement: Replacement::None,
@@ -463,7 +454,7 @@ fn is_kwarg_parameter(semantic: &SemanticModel, name: &ExprName) -> bool {
/// manager = DatasetManager()
/// manager.register_datsaet_change()
/// ```
fn check_method(checker: &mut Checker, call_expr: &ExprCall) {
fn check_method(checker: &Checker, call_expr: &ExprCall) {
let Expr::Attribute(ExprAttribute { attr, value, .. }) = &*call_expr.func else {
return;
};
@@ -528,7 +519,7 @@ fn check_method(checker: &mut Checker, call_expr: &ExprCall) {
}
};
if let Some(replacement) = replacement {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: attr.to_string(),
replacement,
@@ -552,7 +543,7 @@ fn check_method(checker: &mut Checker, call_expr: &ExprCall) {
/// # Or, directly
/// SubDagOperator()
/// ```
fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
fn check_name(checker: &Checker, expr: &Expr, range: TextRange) {
let Some(qualified_name) = checker.semantic().resolve_qualified_name(expr) else {
return;
};
@@ -690,16 +681,7 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
["airflow", "operators", "branch_operator", "BaseBranchOperator"] => {
Replacement::Name("airflow.operators.branch.BaseBranchOperator")
}
["airflow", "operators", " dummy", "EmptyOperator"] => {
Replacement::Name("airflow.operators.empty.EmptyOperator")
}
["airflow", "operators", "dummy", "DummyOperator"] => {
Replacement::Name("airflow.operators.empty.EmptyOperator")
}
["airflow", "operators", "dummy_operator", "EmptyOperator"] => {
Replacement::Name("airflow.operators.empty.EmptyOperator")
}
["airflow", "operators", "dummy_operator", "DummyOperator"] => {
["airflow", "operators", "dummy" | "dummy_operator", "EmptyOperator" | "DummyOperator"] => {
Replacement::Name("airflow.operators.empty.EmptyOperator")
}
["airflow", "operators", "email_operator", "EmailOperator"] => {
@@ -728,24 +710,21 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
}
// airflow.sensors
["airflow", "sensors", "external_task", "ExternalTaskSensorLink"] => {
Replacement::Name("airflow.sensors.external_task.ExternalDagLink")
}
["airflow", "sensors", "base_sensor_operator", "BaseSensorOperator"] => {
Replacement::Name("airflow.sensors.base.BaseSensorOperator")
}
["airflow", "sensors", "date_time_sensor", "DateTimeSensor"] => {
Replacement::Name("airflow.sensors.date_time.DateTimeSensor")
}
["airflow", "sensors", "external_task_sensor", "ExternalTaskMarker"] => {
["airflow", "sensors", "external_task" | "external_task_sensor", "ExternalTaskMarker"] => {
Replacement::Name("airflow.sensors.external_task.ExternalTaskMarker")
}
["airflow", "sensors", "external_task_sensor", "ExternalTaskSensor"] => {
Replacement::Name("airflow.sensors.external_task.ExternalTaskSensor")
}
["airflow", "sensors", "external_task_sensor", "ExternalTaskSensorLink"] => {
["airflow", "sensors", "external_task" | "external_task_sensor", "ExternalTaskSensorLink"] => {
Replacement::Name("airflow.sensors.external_task.ExternalDagLink")
}
["airflow", "sensors", "external_task" | "external_task_sensor", "ExternalTaskSensor"] => {
Replacement::Name("airflow.sensors.external_task.ExternalTaskSensor")
}
["airflow", "sensors", "time_delta_sensor", "TimeDeltaSensor"] => {
Replacement::Name("airflow.sensors.time_delta.TimeDeltaSensor")
}
@@ -764,10 +743,9 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
["airflow", "utils", "dates", "days_ago"] => {
Replacement::Name("pendulum.today('UTC').add(days=-N, ...)")
}
["airflow", "utils", "dates", "parse_execution_date"] => Replacement::None,
["airflow", "utils", "dates", "round_time"] => Replacement::None,
["airflow", "utils", "dates", "scale_time_units"] => Replacement::None,
["airflow", "utils", "dates", "infer_time_unit"] => Replacement::None,
["airflow", "utils", "dates", "parse_execution_date" | "round_time" | "scale_time_units" | "infer_time_unit"] => {
Replacement::None
}
// airflow.utils.file
["airflow", "utils", "file", "TemporaryDirectory"] => Replacement::None,
@@ -784,12 +762,10 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
}
// airflow.utils.state
["airflow", "utils", "state", "SHUTDOWN"] => Replacement::None,
["airflow", "utils", "state", "terminating_states"] => Replacement::None,
["airflow", "utils", "state", "SHUTDOWN" | "terminating_states"] => Replacement::None,
// airflow.utils.trigger_rule
["airflow", "utils", "trigger_rule", "TriggerRule", "DUMMY"] => Replacement::None,
["airflow", "utils", "trigger_rule", "TriggerRule", "NONE_FAILED_OR_SKIPPED"] => {
["airflow", "utils", "trigger_rule", "TriggerRule", "DUMMY" | "NONE_FAILED_OR_SKIPPED"] => {
Replacement::None
}
@@ -891,7 +867,7 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
_ => return,
};
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: qualified_name.to_string(),
replacement,
@@ -912,7 +888,7 @@ fn check_name(checker: &mut Checker, expr: &Expr, range: TextRange) {
/// executors = "some.third.party.executor"
/// ```
fn check_airflow_plugin_extension(
checker: &mut Checker,
checker: &Checker,
expr: &Expr,
name: &str,
class_def: &StmtClassDef,
@@ -929,7 +905,7 @@ fn check_airflow_plugin_extension(
)
})
}) {
checker.diagnostics.push(Diagnostic::new(
checker.report_diagnostic(Diagnostic::new(
Airflow3Removal {
deprecated: name.to_string(),
replacement: Replacement::Message(

View File

@@ -45,7 +45,7 @@ impl Violation for AirflowVariableNameTaskIdMismatch {
}
/// AIR001
pub(crate) fn variable_name_task_id(checker: &mut Checker, targets: &[Expr], value: &Expr) {
pub(crate) fn variable_name_task_id(checker: &Checker, targets: &[Expr], value: &Expr) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
@@ -116,5 +116,5 @@ pub(crate) fn variable_name_task_id(checker: &mut Checker, targets: &[Expr], val
},
target.range(),
);
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}

View File

@@ -88,7 +88,7 @@ impl Violation for FastApiNonAnnotatedDependency {
/// FAST002
pub(crate) fn fastapi_non_annotated_dependency(
checker: &mut Checker,
checker: &Checker,
function_def: &ast::StmtFunctionDef,
) {
if !checker.semantic().seen_module(Modules::FASTAPI)
@@ -219,7 +219,7 @@ impl<'a> DependencyCall<'a> {
/// necessary to determine this while generating the fix, thus the need to return an updated
/// `seen_default` here.
fn create_diagnostic(
checker: &mut Checker,
checker: &Checker,
parameter: &DependencyParameter,
dependency_call: Option<DependencyCall>,
mut seen_default: bool,
@@ -304,7 +304,7 @@ fn create_diagnostic(
}
diagnostic.try_set_optional_fix(|| fix);
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
seen_default
}

View File

@@ -74,10 +74,7 @@ impl AlwaysFixableViolation for FastApiRedundantResponseModel {
}
/// FAST001
pub(crate) fn fastapi_redundant_response_model(
checker: &mut Checker,
function_def: &StmtFunctionDef,
) {
pub(crate) fn fastapi_redundant_response_model(checker: &Checker, function_def: &StmtFunctionDef) {
if !checker.semantic().seen_module(Modules::FASTAPI) {
return;
}
@@ -98,7 +95,7 @@ pub(crate) fn fastapi_redundant_response_model(
)
.map(Fix::unsafe_edit)
});
checker.diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
}

View File

@@ -105,7 +105,7 @@ impl Violation for FastApiUnusedPathParameter {
/// FAST003
pub(crate) fn fastapi_unused_path_parameter(
checker: &mut Checker,
checker: &Checker,
function_def: &ast::StmtFunctionDef,
) {
if !checker.semantic().seen_module(Modules::FASTAPI) {
@@ -163,7 +163,6 @@ pub(crate) fn fastapi_unused_path_parameter(
}
// Check if any of the path parameters are not in the function signature.
let mut diagnostics = vec![];
for (path_param, range) in path_params {
// Ignore invalid identifiers (e.g., `user-id`, as opposed to `user_id`)
if !is_identifier(path_param) {
@@ -203,10 +202,8 @@ pub(crate) fn fastapi_unused_path_parameter(
checker.locator().contents(),
)));
}
diagnostics.push(diagnostic);
checker.report_diagnostic(diagnostic);
}
checker.diagnostics.extend(diagnostics);
}
/// Returns an iterator over the non-positional-only, non-variadic parameters of a function.

View File

@@ -223,7 +223,7 @@ impl Violation for SysVersionCmpStr10 {
}
/// YTT103, YTT201, YTT203, YTT204, YTT302
pub(crate) fn compare(checker: &mut Checker, left: &Expr, ops: &[CmpOp], comparators: &[Expr]) {
pub(crate) fn compare(checker: &Checker, left: &Expr, ops: &[CmpOp], comparators: &[Expr]) {
match left {
Expr::Subscript(ast::ExprSubscript { value, slice, .. })
if is_sys(value, "version_info", checker.semantic()) =>
@@ -243,9 +243,10 @@ pub(crate) fn compare(checker: &mut Checker, left: &Expr, ops: &[CmpOp], compara
) = (ops, comparators)
{
if *n == 3 && checker.enabled(Rule::SysVersionInfo0Eq3) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionInfo0Eq3, left.range()));
checker.report_diagnostic(Diagnostic::new(
SysVersionInfo0Eq3,
left.range(),
));
}
}
} else if *i == 1 {
@@ -258,9 +259,10 @@ pub(crate) fn compare(checker: &mut Checker, left: &Expr, ops: &[CmpOp], compara
) = (ops, comparators)
{
if checker.enabled(Rule::SysVersionInfo1CmpInt) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionInfo1CmpInt, left.range()));
checker.report_diagnostic(Diagnostic::new(
SysVersionInfo1CmpInt,
left.range(),
));
}
}
}
@@ -279,9 +281,10 @@ pub(crate) fn compare(checker: &mut Checker, left: &Expr, ops: &[CmpOp], compara
) = (ops, comparators)
{
if checker.enabled(Rule::SysVersionInfoMinorCmpInt) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionInfoMinorCmpInt, left.range()));
checker.report_diagnostic(Diagnostic::new(
SysVersionInfoMinorCmpInt,
left.range(),
));
}
}
}
@@ -297,14 +300,10 @@ pub(crate) fn compare(checker: &mut Checker, left: &Expr, ops: &[CmpOp], compara
{
if value.len() == 1 {
if checker.enabled(Rule::SysVersionCmpStr10) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionCmpStr10, left.range()));
checker.report_diagnostic(Diagnostic::new(SysVersionCmpStr10, left.range()));
}
} else if checker.enabled(Rule::SysVersionCmpStr3) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionCmpStr3, left.range()));
checker.report_diagnostic(Diagnostic::new(SysVersionCmpStr3, left.range()));
}
}
}

View File

@@ -46,7 +46,7 @@ impl Violation for SixPY3 {
}
/// YTT202
pub(crate) fn name_or_attribute(checker: &mut Checker, expr: &Expr) {
pub(crate) fn name_or_attribute(checker: &Checker, expr: &Expr) {
if !checker.semantic().seen_module(Modules::SIX) {
return;
}
@@ -56,8 +56,6 @@ pub(crate) fn name_or_attribute(checker: &mut Checker, expr: &Expr) {
.resolve_qualified_name(expr)
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["six", "PY3"]))
{
checker
.diagnostics
.push(Diagnostic::new(SixPY3, expr.range()));
checker.report_diagnostic(Diagnostic::new(SixPY3, expr.range()));
}
}

View File

@@ -168,7 +168,7 @@ impl Violation for SysVersionSlice1 {
}
/// YTT101, YTT102, YTT301, YTT303
pub(crate) fn subscript(checker: &mut Checker, value: &Expr, slice: &Expr) {
pub(crate) fn subscript(checker: &Checker, value: &Expr, slice: &Expr) {
if is_sys(value, "version", checker.semantic()) {
match slice {
Expr::Slice(ast::ExprSlice {
@@ -183,13 +183,9 @@ pub(crate) fn subscript(checker: &mut Checker, value: &Expr, slice: &Expr) {
}) = upper.as_ref()
{
if *i == 1 && checker.enabled(Rule::SysVersionSlice1) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionSlice1, value.range()));
checker.report_diagnostic(Diagnostic::new(SysVersionSlice1, value.range()));
} else if *i == 3 && checker.enabled(Rule::SysVersionSlice3) {
checker
.diagnostics
.push(Diagnostic::new(SysVersionSlice3, value.range()));
checker.report_diagnostic(Diagnostic::new(SysVersionSlice3, value.range()));
}
}
}
@@ -199,13 +195,9 @@ pub(crate) fn subscript(checker: &mut Checker, value: &Expr, slice: &Expr) {
..
}) => {
if *i == 2 && checker.enabled(Rule::SysVersion2) {
checker
.diagnostics
.push(Diagnostic::new(SysVersion2, value.range()));
checker.report_diagnostic(Diagnostic::new(SysVersion2, value.range()));
} else if *i == 0 && checker.enabled(Rule::SysVersion0) {
checker
.diagnostics
.push(Diagnostic::new(SysVersion0, value.range()));
checker.report_diagnostic(Diagnostic::new(SysVersion0, value.range()));
}
}

View File

@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/flake8_annotations/mod.rs
snapshot_kind: text
---
auto_return_type.py:1:5: ANN201 [*] Missing return type annotation for public function `func`
|
@@ -97,7 +96,7 @@ auto_return_type.py:27:5: ANN201 [*] Missing return type annotation for public f
| ^^^^ ANN201
28 | return 1 or 2.5 if x > 0 else 1.5 or "str"
|
= help: Add return type annotation: `Union[str | float]`
= help: Add return type annotation: `Union[str, float]`
Unsafe fix
1 |+from typing import Union
@@ -109,7 +108,7 @@ auto_return_type.py:27:5: ANN201 [*] Missing return type annotation for public f
25 26 |
26 27 |
27 |-def func(x: int):
28 |+def func(x: int) -> Union[str | float]:
28 |+def func(x: int) -> Union[str, float]:
28 29 | return 1 or 2.5 if x > 0 else 1.5 or "str"
29 30 |
30 31 |
@@ -120,7 +119,7 @@ auto_return_type.py:31:5: ANN201 [*] Missing return type annotation for public f
| ^^^^ ANN201
32 | return 1 + 2.5 if x > 0 else 1.5 or "str"
|
= help: Add return type annotation: `Union[str | float]`
= help: Add return type annotation: `Union[str, float]`
Unsafe fix
1 |+from typing import Union
@@ -132,7 +131,7 @@ auto_return_type.py:31:5: ANN201 [*] Missing return type annotation for public f
29 30 |
30 31 |
31 |-def func(x: int):
32 |+def func(x: int) -> Union[str | float]:
32 |+def func(x: int) -> Union[str, float]:
32 33 | return 1 + 2.5 if x > 0 else 1.5 or "str"
33 34 |
34 35 |
@@ -204,7 +203,7 @@ auto_return_type.py:59:5: ANN201 [*] Missing return type annotation for public f
60 | if not x:
61 | return 1
|
= help: Add return type annotation: `Union[str | int | None]`
= help: Add return type annotation: `Union[str, int, None]`
Unsafe fix
1 |+from typing import Union
@@ -216,7 +215,7 @@ auto_return_type.py:59:5: ANN201 [*] Missing return type annotation for public f
57 58 |
58 59 |
59 |-def func(x: int):
60 |+def func(x: int) -> Union[str | int | None]:
60 |+def func(x: int) -> Union[str, int, None]:
60 61 | if not x:
61 62 | return 1
62 63 | elif x > 5:
@@ -294,7 +293,7 @@ auto_return_type.py:82:5: ANN201 [*] Missing return type annotation for public f
83 | match x:
84 | case [1, 2, 3]:
|
= help: Add return type annotation: `Union[str | int | None]`
= help: Add return type annotation: `Union[str, int, None]`
Unsafe fix
1 |+from typing import Union
@@ -306,7 +305,7 @@ auto_return_type.py:82:5: ANN201 [*] Missing return type annotation for public f
80 81 |
81 82 |
82 |-def func(x: int):
83 |+def func(x: int) -> Union[str | int | None]:
83 |+def func(x: int) -> Union[str, int, None]:
83 84 | match x:
84 85 | case [1, 2, 3]:
85 86 | return 1
@@ -853,7 +852,7 @@ auto_return_type.py:299:5: ANN201 [*] Missing return type annotation for public
300 | match x:
301 | case [1, 2, 3]:
|
= help: Add return type annotation: `Union[str | int]`
= help: Add return type annotation: `Union[str, int]`
Unsafe fix
214 214 | return 1
@@ -869,7 +868,7 @@ auto_return_type.py:299:5: ANN201 [*] Missing return type annotation for public
297 297 |
298 298 |
299 |-def func(x: int):
299 |+def func(x: int) -> Union[str | int]:
299 |+def func(x: int) -> Union[str, int]:
300 300 | match x:
301 301 | case [1, 2, 3]:
302 302 | return 1

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