Compare commits

..

76 Commits

Author SHA1 Message Date
Micha Reiser
05854142e7 Test Salsa #851 2025-05-08 10:53:51 +02:00
David Peter
33eb008fb6 Disabled event handler if tracing is not enabled 2025-05-08 10:28:59 +02:00
David Peter
04d4febfc4 [ty] Update salsa 2025-05-08 10:17:32 +02:00
Shaygan Hooshyari
d566636ca5 Support typing.Self in methods (#17689)
## Summary

Fixes: astral-sh/ty#159 

This PR adds support for using `Self` in methods.
When the type of an annotation is `TypingSelf` it is converted to a type
var based on:
https://typing.python.org/en/latest/spec/generics.html#self

I just skipped Protocols because it had more problems and the tests was
not useful.
Also I need to create a follow up PR that implicitly assumes `self`
argument has type `Self`.

In order to infer the type in the `in_type_expression` method I needed
to have scope id and semantic index available. I used the idea from
[this PR](https://github.com/astral-sh/ruff/pull/17589/files) to pass
additional context to this method.
Also I think in all places that `in_type_expression` is called we need
to have this context because `Self` can be there so I didn't split the
method into one version with context and one without.

## Test Plan

Added new tests from spec.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
Co-authored-by: Carl Meyer <carl@astral.sh>
2025-05-07 15:58:00 -07:00
Alex Waygood
51cef5a72b [ty] Recognise functions containing yield from expressions as being generator functions (#17930) 2025-05-07 23:29:44 +01:00
Douglas Creager
2cf5cba7ff [ty] Check base classes when determining subtyping etc for generic aliases (#17927)
#17897 added variance handling for legacy typevars — but they were only
being considered when checking generic aliases of the same class:

```py
class A: ...
class B(A): ...

class C[T]: ...

static_assert(is_subtype_of(C[B], C[A]))
```

and not for generic subclasses:

```py
class D[U](C[U]): ...

static_assert(is_subtype_of(D[B], C[A]))
```

Now we check those too!

Closes https://github.com/astral-sh/ty/issues/101
2025-05-07 15:21:11 -04:00
yunchi
ce0800fccf [pylint] add fix safety section (PLC2801) (#17825)
parent: #15584 
fix was introduced at: #9587 
reasoning: #9572
2025-05-07 14:34:34 -04:00
Micha Reiser
d03a7069ad Add instructions on how to upgrade to a newer Rust version (#17928) 2025-05-07 20:11:58 +02:00
Abhijeet Prasad Bodas
f5096f2050 [parser] Flag single unparenthesized generator expr with trailing comma in arguments. (#17893)
Fixes #17867

## Summary

The CPython parser does not allow generator expressions which are the
sole arguments in an argument list to have a trailing comma.
With this change, we start flagging such instances.

## Test Plan

Added new inline tests.
2025-05-07 14:11:35 -04:00
Alex Waygood
895b6161a6 [ty] Ensure that T is disjoint from ~T even when T is a TypeVar (#17922)
Same as https://github.com/astral-sh/ruff/pull/17910 but for
disjointness
2025-05-07 10:59:16 -07:00
Alex Waygood
74fe7982ba [ty] Sort collected diagnostics before snapshotting them in mdtest (#17926) 2025-05-07 18:23:22 +01:00
Micha Reiser
51386b3c7a [ty] Add basic file watching to server (#17912) 2025-05-07 19:03:30 +02:00
Charlie Marsh
51e2effd2d Make completions an opt-in LSP feature (#17921)
## Summary

We now expect the client to send initialization options to opt-in to
experimental (but LSP-standardized) features, like completion support.
Specifically, the client should set `"experimental.completions.enable":
true`.

Closes https://github.com/astral-sh/ty/issues/74.
2025-05-07 16:39:35 +00:00
Dhruv Manilawala
82d31a6014 Add link to ty issue tracker (#17924)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-05-07 16:33:27 +00:00
Dhruv Manilawala
78054824c0 [ty] Add support for __all__ (#17856)
## Summary

This PR adds support for the `__all__` module variable.

Reference spec:
https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols

This PR adds a new `dunder_all_names` query that returns a set of
`Name`s defined in the `__all__` variable of the given `File`. The query
works by implementing the `StatementVisitor` and collects all the names
by recognizing the supported idioms as mentioned in the spec. Any idiom
that's not recognized are ignored.

The current implementation is minimum to what's required for us to
remove all the false positives that this is causing. Refer to the
"Follow-ups" section below to see what we can do next. I'll a open
separate issue to keep track of them.

Closes: astral-sh/ty#106 
Closes: astral-sh/ty#199

### Follow-ups

* Diagnostics:
* Add warning diagnostics for unrecognized `__all__` idioms, `__all__`
containing non-string element
* Add an error diagnostic for elements that are present in `__all__` but
not defined in the module. This could lead to runtime error
* Maybe we should return `<type>` instead of `Unknown | <type>` for
`module.__all__`. For example:
https://playknot.ruff.rs/2a6fe5d7-4e16-45b1-8ec3-d79f2d4ca894
* Mark a symbol that's mentioned in `__all__` as used otherwise it could
raise (possibly in the future) "unused-name" diagnostic

Supporting diagnostics will require that we update the return type of
the query to be something other than `Option<FxHashSet<Name>>`,
something that behaves like a result and provides a way to check whether
a name exists in `__all__`, loop over elements in `__all__`, loop over
the invalid elements, etc.

## Ecosystem analysis

The following are the maximum amount of diagnostics **removed** in the
ecosystem:

* "Type <module '...'> has no attribute ..."
    * `collections.abc` - 14
    * `numpy` - 35534
    * `numpy.ma` - 296
    * `numpy.char` - 37
    * `numpy.testing` - 175
    * `hashlib` - 311
    * `scipy.fft` - 2
    * `scipy.stats` - 38
* "Module '...' has no member ..."
    * `collections.abc` - 85
    * `numpy` - 508
    * `numpy.testing` - 741
    * `hashlib` - 36
    * `scipy.stats` - 68
    * `scipy.interpolate` - 7
    * `scipy.signal` - 5

The following modules have dynamic `__all__` definition, so `ty` assumes
that `__all__` doesn't exists in that module:
* `scipy.stats`
(95a5d6ea8b/scipy/stats/__init__.py (L665))
* `scipy.interpolate`
(95a5d6ea8b/scipy/interpolate/__init__.py (L221))
* `scipy.signal` (indirectly via
95a5d6ea8b/scipy/signal/_signal_api.py (L30))
* `numpy.testing`
(de784cd6ee/numpy/testing/__init__.py (L16-L18))

~There's this one category of **false positives** that have been added:~
Fixed the false positives by also ignoring `__all__` from a module that
uses unrecognized idioms.

<details><summary>Details about the false postivie:</summary>
<p>

The `scipy.stats` module has dynamic `__all__` and it imports a bunch of
symbols via star imports. Some of those modules have a mix of valid and
invalid `__all__` idioms. For example, in
95a5d6ea8b/scipy/stats/distributions.py (L18-L24),
2 out of 4 `__all__` idioms are invalid but currently `ty` recognizes
two of them and says that the module has a `__all__` with 5 values. This
leads to around **2055** newly added false positives of the form:
```
Type <module 'scipy.stats'> has no attribute ...
```

I think the fix here is to completely ignore `__all__`, not only if
there are invalid elements in it, but also if there are unrecognized
idioms used in the module.

</p>
</details> 

## Test Plan

Add a bunch of test cases using the new `ty_extensions.dunder_all_names`
function to extract a module's `__all__` names.

Update various test cases to remove false positives around `*` imports
and re-export convention.

Add new test cases for named import behavior as `*` imports covers all
of it already (thanks Alex!).
2025-05-07 21:42:42 +05:30
Alex Waygood
c6f4929cdc [ty] fix assigning a typevar to a union with itself (#17910)
Co-authored-by: Carl Meyer <carl@astral.sh>
2025-05-07 15:50:22 +00:00
Alex Waygood
2ec0d7e072 [ty] Improve UX for [duplicate-base] diagnostics (#17914) 2025-05-07 15:27:37 +00:00
Charlie Marsh
ad658f4d68 Clean up some Ruff references in the ty server (#17920)
## Summary

Anything user-facing, etc.
2025-05-07 10:55:16 -04:00
Abhijeet Prasad Bodas
3dedd70a92 [ty] Detect overloads decorated with @dataclass_transform (#17835)
## Summary

Fixes #17541

Before this change, in the case of overloaded functions,
`@dataclass_transform` was detected only when applied to the
implementation, not the overloads.
However, the spec also allows this decorator to be applied to any of the
overloads as well.
With this PR, we start handling `@dataclass_transform`s applied to
overloads.

## Test Plan

Fixed existing TODOs in the test suite.
2025-05-07 15:51:13 +02:00
David Peter
fab862c8cd [ty] Ecosystem checks: activate running on 'manticore' (#17916)
## Summary

This is sort of an anticlimactic resolution to #17863, but now that we
understand what the root cause for the stack overflows was, I think it's
fine to enable running on this project. See the linked ticket for the
full analysis.

closes #17863

## Test Plan

Ran lots of times locally and never observed a crash at worker thread
stack sizes > 8 MiB.
2025-05-07 06:27:36 -07:00
Douglas Creager
0d9b6a0975 [ty] Handle explicit variance in legacy typevars (#17897)
We now track the variance of each typevar, and obey the `covariant` and
`contravariant` parameters to the legacy `TypeVar` constructor. We still
don't yet infer variance for PEP-695 typevars or for the
`infer_variance` legacy constructor parameter.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Carl Meyer <carl@astral.sh>
2025-05-07 08:44:51 -04:00
Micha Reiser
c5e299e796 Update salsa (#17895) 2025-05-07 09:51:15 +02:00
Victor Hugo Gomes
c504001b32 [pyupgrade] Add spaces between tokens as necessary to avoid syntax errors in UP018 autofix (#17648)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-05-07 09:34:08 +02:00
David Peter
04457f99b6 [ty] Protocols: Fixpoint iteration for fully-static check (#17880)
## Summary

A recursive protocol like the following would previously lead to stack
overflows when attempting to create the union type for the `P | None`
member, because `UnionBuilder` checks if element types are fully static,
and the fully-static check on `P` would in turn list all members and
check whether all of them were fully static, leading to a cycle.

```py
from __future__ import annotations

from typing import Protocol

class P(Protocol):
    parent: P | None
```

Here, we make the fully-static check on protocols a salsa query and add
fixpoint iteration, starting with `true` as the initial value (assume
that the recursive protocol is fully-static). If the recursive protocol
has any non-fully-static members, we still return `false` when
re-executing the query (see newly added tests).

closes #17861

## Test Plan

Added regression test
2025-05-07 08:55:21 +02:00
InSync
a33d0d4bf4 [ty] Support generate-shell-completion (#17879)
## Summary

Resolves #15502.

`ty generate-shell-completion` now works in a similar manner to `ruff
generate-shell-completion`.

## Test Plan

Manually:

<details>

```shell
$ cargo run --package ty generate-shell-completion nushell
module completions {

  # An extremely fast Python type checker.
  export extern ty [
    --help(-h)                # Print help
    --version(-V)             # Print version
  ]
  
  # ...

}

export use completions *
```
</details>
2025-05-06 18:04:57 -07:00
Charlie Marsh
443f62e98d Remove condensed display type enum (#17902)
## Summary

See: https://github.com/astral-sh/ruff/pull/17889#discussion_r2076556002
2025-05-06 18:04:03 -07:00
Charlie Marsh
a2e9a7732a Update class literal display to use <class 'Foo'> style (#17889)
## Summary

Closes https://github.com/astral-sh/ruff/issues/17238.
2025-05-06 20:11:25 -04:00
Micha Reiser
b2de749c32 Add a note to diagnostics why the rule is enabled (#17854) 2025-05-06 20:29:03 +02:00
Douglas Creager
9085f18353 [ty] Propagate specializations to ancestor base classes (#17892)
@AlexWaygood discovered that even though we've been propagating
specializations to _parent_ base classes correctly, we haven't been
passing them on to _grandparent_ base classes:
https://github.com/astral-sh/ruff/pull/17832#issuecomment-2854360969

```py
class Bar[T]:
    x: T

class Baz[T](Bar[T]): ...
class Spam[T](Baz[T]): ...

reveal_type(Spam[int]().x) # revealed: `T`, but should be `int`
```

This PR updates the MRO machinery to apply the current specialization
when starting to iterate the MRO of each base class.
2025-05-06 14:25:21 -04:00
Dylan
8152ba7cb7 [ty] Add minimal docs for a few lints (#17874)
Just the bare minimum to remove a few TODOs - omitted examples, and only
did 9 but I will check back tomorrow and try to knock out a few more!
2025-05-06 10:36:47 -07:00
Aria Desires
3d01d3be3e update the repository for ty (#17891)
This metadata is used by cargo-dist for artifact URLs (in curl-sh
expressions)
2025-05-06 12:03:38 -04:00
Brent Westbrook
4510a236d3 Default to latest supported Python version for version-related syntax errors (#17529)
## Summary

This PR partially addresses #16418 via the following:

- `LinterSettings::unresolved_python_version` is now a `TargetVersion`,
which is a thin wrapper around an `Option<PythonVersion>`
- `Checker::target_version` now calls `TargetVersion::linter_version`
internally, which in turn uses `unwrap_or_default` to preserve the
current default behavior
- Calls to the parser now call `TargetVersion::parser_version`, which
calls `unwrap_or_else(PythonVersion::latest)`
- The `Checker`'s implementation of
`SemanticSyntaxContext::python_version` also uses
`TargetVersion::parser_version` to use `PythonVersion::latest` for
semantic errors

In short, all lint rule behavior should be unchanged, but we default to
the latest Python version for the new syntax errors, which should
minimize confusing version-related syntax errors for users without a
version configured.

## Test Plan

Existing tests, which showed no changes (except for printing default
settings).
2025-05-06 10:19:13 -04:00
Marcus Näslund
76b6d53d8b Add new rule InEmptyCollection (#16480)
## Summary

Introducing a new rule based on discussions in #15732 and #15729 that
checks for unnecessary in with empty collections.

I called it in_empty_collection and gave the rule number RUF060.

Rule is in preview group.
2025-05-06 13:52:07 +00:00
Zanie Blue
f82b72882b Display ty version for ty --version and ty -V (#17888)
e.g.,

```
❯ uv run -q -- ty -V
ty 0.0.0-alpha.4 (08881edba 2025-05-05)
❯ uv run -q -- ty --version
ty 0.0.0-alpha.4 (08881edba 2025-05-05)
```

Previously, this just displayed `ty 0.0.0` because it didn't use our
custom version implementation. We no longer have a short version —
matching the interface in uv. We could add a variant for it, if it seems
important to people. However, I think we found it more confusing than
not over there and didn't get any complaints about the change.

Closes https://github.com/astral-sh/ty/issues/54
2025-05-06 08:06:41 -05:00
Zanie Blue
d07eefc408 Parse dist-workspace.toml for version (#17868)
Extends https://github.com/astral-sh/ruff/pull/17866, using
`dist-workspace.toml` as a source of truth for versions to enable
version retrieval in distributions that are not Git repositories (i.e.,
Python source distributions and source tarballs consumed by Linux
distros).

I retain the Git tag lookup from
https://github.com/astral-sh/ruff/pull/17866 as a fallback — it seems
harmless, but we could drop it to simplify things here.

I confirmed this works from the repository as well as Python source and
binary distributions:

```
❯ uv run --refresh-package ty --reinstall-package ty -q --  ty version
ty 0.0.1-alpha.1+5 (2eadc9e61 2025-05-05)
❯ uv build
...
❯ uvx --from ty@dist/ty-0.0.0a1.tar.gz --no-cache -q -- ty version
ty 0.0.1-alpha.1
❯ uvx --from ty@dist/ty-0.0.0a1-py3-none-macosx_11_0_arm64.whl -q -- ty version
ty 0.0.1-alpha.1
```

Requires https://github.com/astral-sh/ty/pull/36

cc @Gankra and @MichaReiser for review.
2025-05-06 12:18:17 +00:00
Zanie Blue
f7237e3b69 Update ty version to use parent Git repository information (#17866)
Currently, `ty version` pulls its information from the Ruff repository —
but we want this to pull from the repository in the directory _above_
when Ruff is a submodule.

I tested this in the `ty` repository after tagging an arbitrary commit:

```
❯ uv run --refresh-package ty --reinstall-package ty ty version
      Built ty @ file:///Users/zb/workspace/ty
Uninstalled 1 package in 2ms
Installed 1 package in 1ms
ty 0.0.0+3 (34253b1d4 2025-05-05)
```

We also use the last Git tag as the source of truth for the version,
instead of the crate version. However, we'll need a way to set the
version for releases still, as the tag is published _after_ the build.
We can either tag early (without pushing the tag to the remote), or add
another environment variable. (**Note, this approach is changed in a
follow-up. See https://github.com/astral-sh/ruff/pull/17868**)

From this repository, the version will be `unknown`:

```
❯ cargo run -q --bin ty -- version
ty unknown
```

We could add special handling like... `ty unknown (ruff@...)` but I see
that as a secondary goal.

Closes https://github.com/astral-sh/ty/issues/5

The reviewer situation in this repository is unhinged, cc @Gankra and
@MichaReiser for review.
2025-05-06 07:14:30 -05:00
Alex Waygood
2f9992b6ef [ty] Fix duplicate diagnostics for unresolved module when an import from statement imports multiple members (#17886) 2025-05-06 12:37:10 +01:00
Alex Waygood
457ec4dddd Generalize special-casing for enums constructed with the functional syntax (#17885) 2025-05-06 11:02:55 +01:00
Micha Reiser
aa0614509b Update salsa to pull in fixpoint fixes (#17847) 2025-05-06 11:27:51 +02:00
Micha Reiser
9000eb3bfd Update favicon and URL for playground (#17860) 2025-05-06 10:51:45 +02:00
Micha Reiser
7f50b503cf Allowlist play.ty.dev (#17857) 2025-05-06 10:16:17 +02:00
Micha Reiser
24d3fc27fb Fixup wording of fatal error warning (#17881) 2025-05-06 09:44:23 +02:00
Micha Reiser
6f821ac846 Show a warning at the end of the diagnostic list if there are any fatal warnings (#17855) 2025-05-06 07:14:21 +00:00
Charlie Marsh
d410d12bc5 Update code of conduct email address (#17875)
## Summary

For consistency with the `pyproject.toml`, etc.
2025-05-05 21:34:45 -04:00
Alex Waygood
89424cce5f [ty] Do not emit errors if enums or NamedTuples constructed using functional syntax are used in type expressions (#17873)
## Summary

This fixes some false positives that showed up in the primer diff for
https://github.com/astral-sh/ruff/pull/17832

## Test Plan

new mdtests added that fail with false-positive diagnostics on `main`
2025-05-06 00:37:24 +01:00
Shunsuke Shibayama
fd76d70a31 [red-knot] fix narrowing in nested scopes (#17630)
## Summary

This PR fixes #17595.

## Test Plan

New test cases are added to `mdtest/narrow/conditionals/nested.md`.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-05-05 16:28:42 -07:00
yunchi
a4c8e43c5f [pylint] Add fix safety section (PLR1722) (#17826)
parent: #15584 
fix introduced at: #816
2025-05-05 19:13:04 -04:00
Douglas Creager
ada4c4cb1f [ty] Don't require default typevars when specializing (#17872)
If a typevar is declared as having a default, we shouldn't require a
type to be specified for that typevar when explicitly specializing a
generic class:

```py
class WithDefault[T, U = int]: ...

reveal_type(WithDefault[str]())  # revealed: WithDefault[str, int]
```

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2025-05-05 18:29:30 -04:00
Alex Waygood
bb6c7cad07 [ty] Fix false-positive [invalid-return-type] diagnostics on generator functions (#17871) 2025-05-05 21:44:59 +00:00
Douglas Creager
47e3aa40b3 [ty] Specialize bound methods and nominal instances (#17865)
Fixes
https://github.com/astral-sh/ruff/pull/17832#issuecomment-2851224968. We
had a comment that we did not need to apply specializations to generic
aliases, or to the bound `self` of a bound method, because they were
already specialized. But they might be specialized with a type variable,
which _does_ need to be specialized, in the case of a "multi-step"
specialization, such as:

```py
class LinkedList[T]: ...

class C[U]:
    def method(self) -> LinkedList[U]:
        return LinkedList[U]()
```

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2025-05-05 17:17:36 -04:00
David Peter
9a6633da0b [ty] ecosystem: activate running on 'sympy' (#17870)
## Summary

Following #17869, we can now run `ty` on `sympy`.
2025-05-05 21:50:13 +02:00
David Peter
de78da5ee6 [ty] Increase worker-thread stack size (#17869)
## Summary

closes #17472 

This is obviously just a band-aid solution to this problem (in that you
can always make your [pathological
inputs](28994edd82/sympy/polys/numberfields/resolvent_lookup.py)
bigger and it will still crash), but I think this is not an unreasonable
change — even if we add more sophisticated solutions later. I tried
using `stacker` as suggested by @MichaReiser, and it works. But it's
unclear where exactly would be the right place to put it, and even for
the `sympy` problem, we would need to add it both in the semantic index
builder AST traversal and in type inference. Increasing the default
stack size for worker threads, as proposed here, doesn't solve the
underlying problem (that there is a hard limit), but it is more
universal in the sense that it is not specific to large binary-operator
expression chains.

To determine a reasonable stack size, I created files that look like

*right associative*:
```py
from typing import reveal_type
total = (1 + (1 + (1 + (1 + (… + 1)))))
reveal_type(total)
```

*left associative*
```py
from typing import reveal_type
total = 1 + 1 + 1 + 1 + … + 1
reveal_type(total)
```

with a variable amount of operands (`N`). I then chose the stack size
large enough to still be able to handle cases that existing type
checkers can not:

```
right

  N = 20: mypy takes ~ 1min
  N = 350: pyright crashes with a stack overflow (mypy fails with "too many nested parentheses")
  N = 800: ty(main) infers Literal[800] instantly
  N = 1000: ty(main) crashes with "thread '<unknown>' has overflowed its stack"

  N = 7000: ty(this branch) infers Literal[7000] instantly
  N = 8000+: ty(this branch) crashes


left

  N = 300: pyright emits "Maximum parse depth exceeded; break expression into smaller sub-expressions"
           total is inferred as Unknown
  N = 5500: mypy crashes with "INTERNAL ERROR"
  N = 2500: ty(main) infers Literal[2500] instantly
  N = 3000: ty(main) crashes with "thread '<unknown>' has overflowed its stack"

  N = 22000: ty(this branch) infers Literal[22000] instantly
  N = 23000+: ty(this branch) crashes
```

## Test Plan

New regression test.
2025-05-05 21:31:55 +02:00
Carl Meyer
20d64b9c85 [ty] add passing projects to primer (#17834)
Add projects to primer that now pass, with
https://github.com/astral-sh/ruff/pull/17833
2025-05-05 12:21:06 -07:00
Carl Meyer
4850c187ea [ty] add cycle handling for FunctionType::signature query (#17833)
This fixes cycle panics in several ecosystem projects (moved to
`good.txt` in a following PR
https://github.com/astral-sh/ruff/pull/17834 because our mypy-primer job
doesn't handle it well if we move projects to `good.txt` in the same PR
that fixes `ty` to handle them), as well as in the minimal case in the
added mdtest. It also fixes a number of panicking fuzzer seeds. It
doesn't appear to cause any regression in any ecosystem project or any
fuzzer seed.
2025-05-05 12:12:38 -07:00
Vasco Schiavo
3f32446e16 [ruff] add fix safety section (RUF013) (#17759)
The PR add the fix safety section for rule `RUF013`
(https://github.com/astral-sh/ruff/issues/15584 )
The fix was introduced here #4831

The rule as a lot of False Negative (as it is explained in the docs of
the rule).

The main reason because the fix is unsafe is that it could change code
generation tools behaviour, as in the example here:

```python
def generate_api_docs(func):
    hints = get_type_hints(func)
    for param, hint in hints.items():
        if is_optional_type(hint):
            print(f"Parameter '{param}' is optional")
        else:
            print(f"Parameter '{param}' is required")

# Before fix
def create_user(name: str, roles: list[str] = None):
    pass

# After fix
def create_user(name: str, roles: Optional[list[str]] = None):
    pass

# Generated docs would change from "roles is required" to "roles is optional"
```
2025-05-05 13:47:56 -05:00
Aria Desires
784daae497 migrate to dist-workspace.toml, use new workspace.packages config (#17864) 2025-05-05 14:07:46 -04:00
Max Mynter
178c882740 [semantic-syntax-tests] Add test fixtures for AwaitOutsideAsyncFunction (#17785)
<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->
Re: #17526 
## Summary
Add test fixtures for `AwaitOutsideAsync` and
`AsyncComprehensionOutsideAsyncFunction` errors.

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

## Test Plan
This is a test. 

<!-- How was it tested? -->
2025-05-05 14:02:06 -04:00
Max Mynter
101e1a5ddd [semantic-syntax-tests] for for InvalidStarExpression, DuplicateMatchKey, and DuplicateMatchClassAttribute (#17754)
Re: #17526 

## Summary
Add integration tests for Python Semantic Syntax for
`InvalidStarExpression`, `DuplicateMatchKey`, and
`DuplicateMatchClassAttribute`.

## Note
- Red knot integration tests for `DuplicateMatchKey` exist already in
line 89-101.
<!-- What's the purpose of the change? What does it do, and why? -->

## Test Plan
This is a test.
<!-- How was it tested? -->
2025-05-05 17:30:16 +00:00
Dylan
965a4dd731 [isort] Check full module path against project root(s) when categorizing first-party (#16565)
When attempting to determine whether `import foo.bar.baz` is a known
first-party import relative to [user-provided source
paths](https://docs.astral.sh/ruff/settings/#src), when `preview` is
enabled we now check that `SRC/foo/bar/baz` is a directory or
`SRC/foo/bar/baz.py` or `SRC/foo/bar/baz.pyi` exist.

Previously, we just checked the analogous thing for `SRC/foo`, but this
can be misleading in situations with disjoint namespace packages that
share a common base name (e.g. we may be working inside the namespace
package `foo.buzz` and importing `foo.bar` from elsewhere).

Supersedes #12987 
Closes #12984
2025-05-05 11:40:01 -05:00
Victor Hugo Gomes
5e2c818417 [flake8-bandit] Mark tuples of string literals as trusted input in S603 (#17801)
<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

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

## Test Plan

Snapshot tests
<!-- How was it tested? -->
2025-05-05 10:50:44 -04:00
David Peter
90c12f4177 [ty] ecosystem: Activate running on 'materialize' (#17862) 2025-05-05 16:34:23 +02:00
Wei Lee
6e9fb9af38 [airflow] Skip attribute check in try catch block (AIR301) (#17790)
<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

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

Skip attribute check in try catch block (`AIR301`)

## Test Plan

<!-- How was it tested? -->
update
`crates/ruff_linter/resources/test/fixtures/airflow/AIR301_names_try.py`
2025-05-05 10:01:05 -04:00
Wei Lee
a507c1b8b3 [airflow] Remove airflow.utils.dag_parsing_context.get_parsing_context (AIR301) (#17852)
<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->
Remove `airflow.utils.dag_parsing_context.get_parsing_context` from
AIR301 as it has been moved to AIR311

## Test Plan

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

the test fixture was updated in the previous PR
2025-05-05 09:31:57 -04:00
David Peter
5a91badb8b [ty] Move 'scipy' to list of 'good' projects (#17850)
## Summary

Adds `scipy` as a new project to `good.txt` as a follow-up to #17849.
2025-05-05 13:52:57 +02:00
David Peter
1945bfdb84 [ty] Fix standalone expression type retrieval in presence of cycles (#17849)
## Summary

When entering an `infer_expression_types` cycle from
`TypeInferenceBuilder::infer_standalone_expression`, we might get back a
`TypeInference::cycle_fallback(…)` that doesn't actually contain any new
types, but instead it contains a `cycle_fallback_type` which is set to
`Some(Type::Never)`. When calling `self.extend(…)`, we therefore don't
really pull in a type for the expression we're interested in. This
caused us to panic if we tried to call `self.expression_type(…)` after
`self.extend(…)`.

The proposed fix here is to retrieve that type from the nested
`TypeInferenceBuilder` directly, which will correctly fall back to
`cycle_fallback_type`.

## Details

I minimized the second example from #17792 a bit further and used this
example for debugging:

```py
from __future__ import annotations

class C: ...

def f(arg: C):
    pass

x, _ = f(1)

assert x
```

This is self-referential because when we check the assignment statement
`x, _ = f(1)`, we need to look up the signature of `f`. Since evaluation
of annotations is deferred, we look up the public type of `C` for the
`arg` parameter. The public use of `C` is visibility-constraint by "`x`"
via the `assert` statement. While evaluating this constraint, we need to
look up the type of `x`, which in turn leads us back to the `x, _ =
f(1)` definition.

The reason why this only showed up in the relatively peculiar case with
unpack assignments is the code here:


78b4c3ccf1/crates/ty_python_semantic/src/types/infer.rs (L2709-L2718)

For a non-unpack assignment like `x = f(1)`, we would not try to infer
the right-hand side eagerly. Instead, we would enter a
`infer_definition_types` cycle that handles the situation correctly. For
unpack assignments, however, we try to infer the type of `value`
(`f(1)`) and therefore enter the cycle via `standalone_expression_type
=> infer_expression_type`.

closes #17792 

## Test Plan

* New regression test
* Made sure that we can now run successfully on scipy => see #17850
2025-05-05 13:52:08 +02:00
Dylan
a95c73d5d0 Implement deferred annotations for Python 3.14 (#17658)
This PR updates the semantic model for Python 3.14 by essentially
equating "run using Python 3.14" with "uses `from __future__ import
annotations`".

While this is not technically correct under the hood, it appears to be
correct for the purposes of our semantic model. That is: from the point
of view of deciding when to parse, bind, etc. annotations, these two
contexts behave the same. More generally these contexts behave the same
unless you are performing some kind of introspection like the following:


Without future import:
```pycon
>>> from annotationlib import get_annotations,Format
>>> def foo()->Bar:...
...
>>> get_annotations(foo,format=Format.FORWARDREF)
{'return': ForwardRef('Bar')}
>>> get_annotations(foo,format=Format.STRING)
{'return': 'Bar'}
>>> get_annotations(foo,format=Format.VALUE)
Traceback (most recent call last):
[...]
NameError: name 'Bar' is not defined
>>> get_annotations(foo)
Traceback (most recent call last):
[...]
NameError: name 'Bar' is not defined
```

With future import:
```
>>> from __future__ import annotations
>>> from annotationlib import get_annotations,Format
>>> def foo()->Bar:...
...
>>> get_annotations(foo,format=Format.FORWARDREF)
{'return': 'Bar'}
>>> get_annotations(foo,format=Format.STRING)
{'return': 'Bar'}
>>> get_annotations(foo,format=Format.VALUE)
{'return': 'Bar'}
>>> get_annotations(foo)
{'return': 'Bar'}
```

(Note: the result of the last call to `get_annotations` in these
examples relies on the fact that, as of this writing, the default value
for `format` is `Format.VALUE`).

If one day we support lint rules targeting code that introspects using
the new `annotationlib`, then it is possible we will need to revisit our
approximation.

Closes #15100
2025-05-05 06:40:36 -05:00
David Peter
78b4c3ccf1 [ty] Minor typo in environment variable name (#17848) 2025-05-05 10:25:48 +00:00
renovate[bot]
2485afe640 Update pre-commit dependencies (#17840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-05-05 07:36:09 +00:00
renovate[bot]
b8ed729f59 Update taiki-e/install-action digest to 86c23ee (#17838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 07:43:54 +02:00
renovate[bot]
108c470348 Update actions/download-artifact action to v4.3.0 (#17845)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 07:43:33 +02:00
renovate[bot]
87c64c9eab Update actions/setup-python action to v5.6.0 (#17846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 07:43:01 +02:00
renovate[bot]
a10606dda2 Update Rust crate jiff to v0.2.12 (#17843) 2025-05-05 07:34:09 +02:00
renovate[bot]
d1c6dd9ac1 Update Rust crate toml to v0.8.22 (#17844) 2025-05-05 07:32:50 +02:00
renovate[bot]
073b993ab0 Update Rust crate assert_fs to v1.1.3 (#17841) 2025-05-05 07:31:02 +02:00
renovate[bot]
6a36cd6f02 Update Rust crate hashbrown to v0.15.3 (#17842) 2025-05-05 07:30:46 +02:00
renovate[bot]
3b15af6d4f Update dependency ruff to v0.11.8 (#17839) 2025-05-05 07:29:36 +02:00
296 changed files with 8044 additions and 1836 deletions

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Report an issue with ty
url: https://github.com/astral-sh/ty/issues/new/choose
about: Please report issues for our type checker ty in the ty repository.
- name: Documentation
url: https://docs.astral.sh/ruff
about: Please consult the documentation before creating an issue.

View File

@@ -43,7 +43,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: "Prep README.md"
@@ -72,7 +72,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: x64
@@ -114,7 +114,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: arm64
@@ -170,7 +170,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: ${{ matrix.platform.arch }}
@@ -223,7 +223,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: x64
@@ -298,7 +298,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: "Prep README.md"
@@ -363,7 +363,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: x64
@@ -429,7 +429,7 @@ jobs:
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: "Prep README.md"

View File

@@ -113,7 +113,7 @@ jobs:
if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
steps:
- name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: /tmp/digests
pattern: digests-*
@@ -256,7 +256,7 @@ jobs:
if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
steps:
- name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -239,11 +239,11 @@ jobs:
- name: "Install mold"
uses: rui314/setup-mold@e16410e7f8d9e167b74ad5697a9089a35126eb50 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-insta
- name: ty mdtests (GitHub annotations)
@@ -297,11 +297,11 @@ jobs:
- name: "Install mold"
uses: rui314/setup-mold@e16410e7f8d9e167b74ad5697a9089a35126eb50 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-insta
- name: "Run tests"
@@ -324,7 +324,7 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install cargo nextest"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-nextest
- name: "Run tests"
@@ -407,11 +407,11 @@ jobs:
- name: "Install mold"
uses: rui314/setup-mold@e16410e7f8d9e167b74ad5697a9089a35126eb50 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-insta
- name: "Run tests"
@@ -460,7 +460,7 @@ jobs:
with:
persist-credentials: false
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
name: Download Ruff binary to test
id: download-cached-binary
with:
@@ -525,11 +525,11 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
name: Download comparison Ruff binary
id: ruff-target
with:
@@ -649,7 +649,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
name: Download new ty binary
id: ty-new
with:
@@ -705,7 +705,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: x64
@@ -759,7 +759,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"
- uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
@@ -830,12 +830,12 @@ jobs:
persist-credentials: false
repository: "astral-sh/ruff-lsp"
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
# installation fails on 3.13 and newer
python-version: "3.12"
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
name: Download development ruff binary
id: ruff-target
with:
@@ -908,7 +908,7 @@ jobs:
run: rustup show
- name: "Install codspeed"
uses: taiki-e/install-action@ab3728c7ba6948b9b429627f4d55a68842b27f18 # v2
uses: taiki-e/install-action@86c23eed46c17b80677df6d8151545ce3e236c61 # v2
with:
tool: cargo-codspeed

View File

@@ -28,7 +28,7 @@ jobs:
ref: ${{ inputs.ref }}
persist-credentials: true
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: 3.12

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: "Install uv"
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: wheels-*
path: wheels

View File

@@ -69,7 +69,7 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/cargo-dist/releases/download/v0.28.4/cargo-dist-installer.sh | sh"
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/cargo-dist/releases/download/v0.28.5-prerelease.1/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@6027e3dd177782cd8ab9af838c04fd81a07f1d47
with:

View File

@@ -65,7 +65,7 @@ repos:
- black==25.1.0
- repo: https://github.com/crate-ci/typos
rev: v1.31.1
rev: v1.32.0
hooks:
- id: typos
@@ -79,7 +79,7 @@ repos:
pass_filenames: false # This makes it a lot faster
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.7
rev: v0.11.8
hooks:
- id: ruff-format
- id: ruff

View File

@@ -71,8 +71,7 @@ representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<charlie.r.marsh@gmail.com>.
reported to the community leaders responsible for enforcement at <hey@astral.sh>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@@ -366,6 +366,15 @@ uvx --from ./python/ruff-ecosystem ruff-ecosystem format ruff "./target/debug/ru
See the [ruff-ecosystem package](https://github.com/astral-sh/ruff/tree/main/python/ruff-ecosystem) for more details.
## Upgrading Rust
1. Change the `channel` in `./rust-toolchain.toml` to the new Rust version (`<latest>`)
1. Change the `rust-version` in the `./Cargo.toml` to `<latest> - 2` (e.g. 1.84 if the latest is 1.86)
1. Run `cargo clippy --fix --allow-dirty --allow-staged` to fix new clippy warnings
1. Create and merge the PR
1. Bump the Rust version in Ruff's conda forge recipe. See [this PR](https://github.com/conda-forge/ruff-feedstock/pull/266) for an example.
1. Enjoy the new Rust version!
## Benchmarking and Profiling
We have several ways of benchmarking and profiling Ruff:

81
Cargo.lock generated
View File

@@ -150,9 +150,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert_fs"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7efdb1fdb47602827a342857666feb372712cbc64b414172bd6b167a02927674"
checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9"
dependencies = [
"anstyle",
"doc-comment",
@@ -478,7 +478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -487,7 +487,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -904,7 +904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1117,9 +1117,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.2"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
dependencies = [
"allocator-api2",
"equivalent",
@@ -1132,7 +1132,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.2",
"hashbrown 0.15.3",
]
[[package]]
@@ -1361,7 +1361,7 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2"
dependencies = [
"hashbrown 0.15.2",
"hashbrown 0.15.3",
]
[[package]]
@@ -1381,7 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.2",
"hashbrown 0.15.3",
"serde",
]
@@ -1485,7 +1485,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi 0.5.0",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1539,9 +1539,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jiff"
version = "0.2.10"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6"
checksum = "d07d8d955d798e7a4d6f9c58cd1f1916e790b42b092758a9ef6e16fef9f1b3fd"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
@@ -1549,14 +1549,14 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
name = "jiff-static"
version = "0.2.10"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254"
checksum = "f244cfe006d98d26f859c7abd1318d85327e1882dc9cef80f62daeeb0adcf300"
dependencies = [
"proc-macro2",
"quote",
@@ -2834,6 +2834,7 @@ dependencies = [
"smallvec",
"strum",
"strum_macros",
"tempfile",
"test-case",
"thiserror 2.0.12",
"toml",
@@ -3205,7 +3206,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3218,7 +3219,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.3",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3235,14 +3236,14 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "salsa"
version = "0.21.0"
source = "git+https://github.com/salsa-rs/salsa.git?rev=42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7#42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7"
version = "0.21.1"
source = "git+https://github.com/salsa-rs/salsa.git?rev=ef93d3830ffb8dc621fef1ba7b234ccef9dc3639#ef93d3830ffb8dc621fef1ba7b234ccef9dc3639"
dependencies = [
"boxcar",
"compact_str",
"crossbeam-queue",
"dashmap 6.1.0",
"hashbrown 0.15.2",
"hashbrown 0.15.3",
"hashlink",
"indexmap",
"parking_lot",
@@ -3258,13 +3259,13 @@ dependencies = [
[[package]]
name = "salsa-macro-rules"
version = "0.21.0"
source = "git+https://github.com/salsa-rs/salsa.git?rev=42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7#42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7"
version = "0.21.1"
source = "git+https://github.com/salsa-rs/salsa.git?rev=ef93d3830ffb8dc621fef1ba7b234ccef9dc3639#ef93d3830ffb8dc621fef1ba7b234ccef9dc3639"
[[package]]
name = "salsa-macros"
version = "0.21.0"
source = "git+https://github.com/salsa-rs/salsa.git?rev=42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7#42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7"
version = "0.21.1"
source = "git+https://github.com/salsa-rs/salsa.git?rev=ef93d3830ffb8dc621fef1ba7b234ccef9dc3639#ef93d3830ffb8dc621fef1ba7b234ccef9dc3639"
dependencies = [
"heck",
"proc-macro2",
@@ -3604,7 +3605,7 @@ dependencies = [
"getrandom 0.3.2",
"once_cell",
"rustix 1.0.2",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3799,9 +3800,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml"
version = "0.8.20"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
dependencies = [
"serde",
"serde_spanned",
@@ -3811,26 +3812,33 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.6.8"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.24"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
[[package]]
name = "tracing"
version = "0.1.41"
@@ -3947,6 +3955,7 @@ dependencies = [
"anyhow",
"argfile",
"clap",
"clap_complete_command",
"colored 3.0.0",
"countme",
"crossbeam",
@@ -4030,7 +4039,7 @@ dependencies = [
"countme",
"dir-test",
"drop_bomb",
"hashbrown 0.15.2",
"hashbrown 0.15.3",
"indexmap",
"insta",
"itertools 0.14.0",
@@ -4586,7 +4595,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -4824,9 +4833,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.4"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36"
checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3"
dependencies = [
"memchr",
]

View File

@@ -124,7 +124,7 @@ rayon = { version = "1.10.0" }
regex = { version = "1.10.2" }
rustc-hash = { version = "2.0.0" }
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "42f15835c0005c4b37aaf5bc1a15e3e1b3df14b7" }
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "ef93d3830ffb8dc621fef1ba7b234ccef9dc3639" }
schemars = { version = "0.8.16" }
seahash = { version = "4.1.0" }
serde = { version = "1.0.197", features = ["derive"] }
@@ -268,75 +268,3 @@ debug = 1
# The profile that 'cargo dist' will build with.
[profile.dist]
inherits = "release"
# Config for 'dist'
[workspace.metadata.dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.28.4"
# Make distability of apps opt-in instead of opt-out
dist = false
# CI backends to support
ci = "github"
# The installers to generate for each app
installers = ["shell", "powershell"]
# The archive format to use for windows builds (defaults .zip)
windows-archive = ".zip"
# The archive format to use for non-windows builds (defaults .tar.xz)
unix-archive = ".tar.gz"
# Target platforms to build apps for (Rust target-triple syntax)
targets = [
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"arm-unknown-linux-musleabihf",
"armv7-unknown-linux-gnueabihf",
"armv7-unknown-linux-musleabihf",
"i686-pc-windows-msvc",
"i686-unknown-linux-gnu",
"i686-unknown-linux-musl",
"powerpc64-unknown-linux-gnu",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
]
# Whether to auto-include files like READMEs, LICENSEs, and CHANGELOGs (default true)
auto-includes = false
# Whether dist should create a Github Release or use an existing draft
create-release = true
# Which actions to run on pull requests
pr-run-mode = "plan"
# Whether CI should trigger releases with dispatches instead of tag pushes
dispatch-releases = true
# Which phase dist should use to create the GitHub release
github-release = "announce"
# Whether CI should include auto-generated code to build local artifacts
build-local-artifacts = false
# Local artifacts jobs to run in CI
local-artifacts-jobs = ["./build-binaries", "./build-docker"]
# Publish jobs to run in CI
publish-jobs = ["./publish-pypi", "./publish-wasm"]
# Post-announce jobs to run in CI
post-announce-jobs = [
"./notify-dependents",
"./publish-docs",
"./publish-playground",
]
# Custom permissions for GitHub Jobs
github-custom-job-permissions = { "build-docker" = { packages = "write", contents = "read" }, "publish-wasm" = { contents = "read", id-token = "write", packages = "write" } }
# Whether to install an updater program
install-updater = false
# Path that installers should place binaries in
install-path = ["$XDG_BIN_HOME/", "$XDG_DATA_HOME/../bin", "~/.local/bin"]
[workspace.metadata.dist.github-custom-runners]
global = "depot-ubuntu-latest-4"
[workspace.metadata.dist.github-action-commits]
"actions/checkout" = "85e6279cec87321a52edac9c87bce653a07cf6c2" # v4
"actions/upload-artifact" = "6027e3dd177782cd8ab9af838c04fd81a07f1d47" # v4.6.2
"actions/download-artifact" = "d3f86a106a0bac45b974a628896c90dbdf5c8093" # v4.3.0
"actions/attest-build-provenance" = "c074443f1aee8d4aeeae555aebba3282517141b2" #v2.2.3

View File

@@ -616,7 +616,7 @@ mod tests {
let settings = Settings {
cache_dir,
linter: LinterSettings {
unresolved_target_version: PythonVersion::latest(),
unresolved_target_version: PythonVersion::latest().into(),
..Default::default()
},
..Settings::default()

View File

@@ -3930,7 +3930,7 @@ from typing import Union;foo: Union[int, str] = 1
linter.per_file_ignores = {}
linter.safety_table.forced_safe = []
linter.safety_table.forced_unsafe = []
linter.unresolved_target_version = 3.9
linter.unresolved_target_version = none
linter.per_file_target_version = {}
linter.preview = disabled
linter.explicit_preview_rules = false
@@ -4215,7 +4215,7 @@ from typing import Union;foo: Union[int, str] = 1
linter.per_file_ignores = {}
linter.safety_table.forced_safe = []
linter.safety_table.forced_unsafe = []
linter.unresolved_target_version = 3.9
linter.unresolved_target_version = none
linter.per_file_target_version = {}
linter.preview = disabled
linter.explicit_preview_rules = false

View File

@@ -5,7 +5,6 @@ info:
args:
- rule
- F401
snapshot_kind: text
---
success: true
exit_code: 0
@@ -84,6 +83,11 @@ else:
print("numpy is not installed")
```
## Preview
When [preview](https://docs.astral.sh/ruff/preview/) is enabled,
the criterion for determining whether an import is first-party
is stricter, which could affect the suggested fix. See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
## Options
- `lint.ignore-init-module-imports`
- `lint.pyflakes.allowed-unused-imports`

View File

@@ -232,6 +232,15 @@ impl Diagnostic {
pub fn primary_tags(&self) -> Option<&[DiagnosticTag]> {
self.primary_annotation().map(|ann| ann.tags.as_slice())
}
/// Returns a key that can be used to sort two diagnostics into the canonical order
/// in which they should appear when rendered.
pub fn rendering_sort_key<'a>(&'a self, db: &'a dyn Db) -> impl Ord + 'a {
RenderingSortKey {
db,
diagnostic: self,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -243,6 +252,64 @@ struct DiagnosticInner {
subs: Vec<SubDiagnostic>,
}
struct RenderingSortKey<'a> {
db: &'a dyn Db,
diagnostic: &'a Diagnostic,
}
impl Ord for RenderingSortKey<'_> {
// We sort diagnostics in a way that keeps them in source order
// and grouped by file. After that, we fall back to severity
// (with fatal messages sorting before info messages) and then
// finally the diagnostic ID.
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
if let (Some(span1), Some(span2)) = (
self.diagnostic.primary_span(),
other.diagnostic.primary_span(),
) {
let order = span1
.file()
.path(self.db)
.as_str()
.cmp(span2.file().path(self.db).as_str());
if order.is_ne() {
return order;
}
if let (Some(range1), Some(range2)) = (span1.range(), span2.range()) {
let order = range1.start().cmp(&range2.start());
if order.is_ne() {
return order;
}
}
}
// Reverse so that, e.g., Fatal sorts before Info.
let order = self
.diagnostic
.severity()
.cmp(&other.diagnostic.severity())
.reverse();
if order.is_ne() {
return order;
}
self.diagnostic.id().cmp(&other.diagnostic.id())
}
}
impl PartialOrd for RenderingSortKey<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for RenderingSortKey<'_> {
fn eq(&self, other: &Self) -> bool {
self.cmp(other).is_eq()
}
}
impl Eq for RenderingSortKey<'_> {}
/// A collection of information subservient to a diagnostic.
///
/// A sub-diagnostic is always rendered after the parent diagnostic it is
@@ -674,6 +741,10 @@ impl Severity {
Severity::Fatal => AnnotateLevel::Error,
}
}
pub const fn is_fatal(self) -> bool {
matches!(self, Severity::Fatal)
}
}
/// Configuration for rendering diagnostics.

View File

@@ -61,7 +61,7 @@ pub fn max_parallelism() -> NonZeroUsize {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use crate::files::Files;
use crate::system::TestSystem;
@@ -69,6 +69,8 @@ mod tests {
use crate::vendored::VendoredFileSystem;
use crate::Db;
type Events = Arc<Mutex<Vec<salsa::Event>>>;
/// Database that can be used for testing.
///
/// Uses an in memory filesystem and it stubs out the vendored files by default.
@@ -79,36 +81,37 @@ mod tests {
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
events: Arc<std::sync::Mutex<Vec<salsa::Event>>>,
events: Events,
}
impl TestDb {
pub(crate) fn new() -> Self {
let events = Events::default();
Self {
storage: salsa::Storage::default(),
storage: salsa::Storage::new(Some(Box::new({
let events = events.clone();
move |event| {
tracing::trace!("event: {:?}", event);
let mut events = events.lock().unwrap();
events.push(event);
}
}))),
system: TestSystem::default(),
vendored: VendoredFileSystem::default(),
events: std::sync::Arc::default(),
events,
files: Files::default(),
}
}
/// Empties the internal store of salsa events that have been emitted,
/// and returns them as a `Vec` (equivalent to [`std::mem::take`]).
///
/// ## Panics
/// If there are pending database snapshots.
pub(crate) fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
let inner = Arc::get_mut(&mut self.events)
.expect("expected no pending salsa database snapshots.");
let mut events = self.events.lock().unwrap();
std::mem::take(inner.get_mut().unwrap())
std::mem::take(&mut *events)
}
/// Clears the emitted salsa events.
///
/// ## Panics
/// If there are pending database snapshots.
pub(crate) fn clear_salsa_events(&mut self) {
self.take_salsa_events();
}
@@ -148,12 +151,5 @@ mod tests {
}
#[salsa::db]
impl salsa::Database for TestDb {
fn salsa_event(&self, event: &dyn Fn() -> salsa::Event) {
let event = event();
tracing::trace!("event: {:?}", event);
let mut events = self.events.lock().unwrap();
events.push(event);
}
}
impl salsa::Database for TestDb {}
}

View File

@@ -98,6 +98,4 @@ impl Db for ModuleDb {
}
#[salsa::db]
impl salsa::Database for ModuleDb {
fn salsa_event(&self, _event: &dyn Fn() -> salsa::Event) {}
}
impl salsa::Database for ModuleDb {}

View File

@@ -76,6 +76,7 @@ insta = { workspace = true, features = ["filters", "json", "redactions"] }
test-case = { workspace = true }
# Disable colored output in tests
colored = { workspace = true, features = ["no-color"] }
tempfile = { workspace = true }
[features]
default = []

View File

@@ -6,3 +6,12 @@ except ModuleNotFoundError:
from airflow.datasets import Dataset as Asset
Asset
try:
from airflow.sdk import Asset
except ModuleNotFoundError:
from airflow import datasets
Asset = datasets.Dataset
asset = Asset()

View File

@@ -39,3 +39,9 @@ run(c := "true")
# But non-instant are not.
(e := "echo")
run(e)
# https://github.com/astral-sh/ruff/issues/17798
# Tuple literals are trusted
check_output(("literal", "cmd", "using", "tuple"), text=True)
Popen(("literal", "cmd", "using", "tuple"))

View File

@@ -84,3 +84,9 @@ str(
'''Lorem
ipsum''' # Comment
).foo
# https://github.com/astral-sh/ruff/issues/17606
bool(True)and None
int(1)and None
float(1.)and None
bool(True)and()

View File

@@ -0,0 +1,37 @@
# Errors
1 in []
1 not in []
2 in list()
2 not in list()
_ in ()
_ not in ()
'x' in tuple()
'y' not in tuple()
'a' in set()
'a' not in set()
'b' in {}
'b' not in {}
1 in dict()
2 not in dict()
"a" in ""
b'c' in b""
"b" in f""
b"a" in bytearray()
b"a" in bytes()
1 in frozenset()
# OK
1 in [2]
1 in [1, 2, 3]
_ in ('a')
_ not in ('a')
'a' in set('a', 'b')
'a' not in set('b', 'c')
'b' in {1: 2}
'b' not in {3: 4}
"a" in "x"
b'c' in b"x"
"b" in f"x"
b"a" in bytearray([2])
b"a" in bytes("a", "utf-8")
1 in frozenset("c")

View File

@@ -0,0 +1,11 @@
async def elements(n):
yield n
def regular_function():
[x async for x in elements(1)]
async with elements(1) as x:
pass
async for _ in elements(1):
pass

View File

@@ -0,0 +1,5 @@
def func():
await 1
# Top-level await
await 1

View File

@@ -1504,6 +1504,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.enabled(Rule::NanComparison) {
pylint::rules::nan_comparison(checker, left, comparators);
}
if checker.enabled(Rule::InEmptyCollection) {
ruff::rules::in_empty_collection(checker, compare);
}
if checker.enabled(Rule::InDictKeys) {
flake8_simplify::rules::key_in_dict_compare(checker, compare);
}

View File

@@ -1,4 +1,4 @@
use ruff_python_ast::StmtFunctionDef;
use ruff_python_ast::{PythonVersion, StmtFunctionDef};
use ruff_python_semantic::{ScopeKind, SemanticModel};
use crate::rules::flake8_type_checking;
@@ -29,7 +29,11 @@ pub(super) enum AnnotationContext {
impl AnnotationContext {
/// Determine the [`AnnotationContext`] for an annotation based on the current scope of the
/// semantic model.
pub(super) fn from_model(semantic: &SemanticModel, settings: &LinterSettings) -> Self {
pub(super) fn from_model(
semantic: &SemanticModel,
settings: &LinterSettings,
version: PythonVersion,
) -> Self {
// If the annotation is in a class scope (e.g., an annotated assignment for a
// class field) or a function scope, and that class or function is marked as
// runtime-required, treat the annotation as runtime-required.
@@ -59,7 +63,7 @@ impl AnnotationContext {
// If `__future__` annotations are enabled or it's a stub file,
// then annotations are never evaluated at runtime,
// so we can treat them as typing-only.
if semantic.future_annotations_or_stub() {
if semantic.future_annotations_or_stub() || version.defers_annotations() {
return Self::TypingOnly;
}
@@ -81,6 +85,7 @@ impl AnnotationContext {
function_def: &StmtFunctionDef,
semantic: &SemanticModel,
settings: &LinterSettings,
version: PythonVersion,
) -> Self {
if flake8_type_checking::helpers::runtime_required_function(
function_def,
@@ -88,7 +93,7 @@ impl AnnotationContext {
semantic,
) {
Self::RuntimeRequired
} else if semantic.future_annotations_or_stub() {
} else if semantic.future_annotations_or_stub() || version.defers_annotations() {
Self::TypingOnly
} else {
Self::RuntimeEvaluated

View File

@@ -72,7 +72,7 @@ use crate::rules::pyflakes::rules::{
};
use crate::rules::pylint::rules::{AwaitOutsideAsync, LoadBeforeGlobalDeclaration};
use crate::rules::{flake8_pyi, flake8_type_checking, pyflakes, pyupgrade};
use crate::settings::{flags, LinterSettings};
use crate::settings::{flags, LinterSettings, TargetVersion};
use crate::{docstrings, noqa, Locator};
mod analyze;
@@ -232,7 +232,7 @@ pub(crate) struct Checker<'a> {
/// A state describing if a docstring is expected or not.
docstring_state: DocstringState,
/// The target [`PythonVersion`] for version-dependent checks.
target_version: PythonVersion,
target_version: TargetVersion,
/// Helper visitor for detecting semantic syntax errors.
#[expect(clippy::struct_field_names)]
semantic_checker: SemanticSyntaxChecker,
@@ -257,7 +257,7 @@ impl<'a> Checker<'a> {
source_type: PySourceType,
cell_offsets: Option<&'a CellOffsets>,
notebook_index: Option<&'a NotebookIndex>,
target_version: PythonVersion,
target_version: TargetVersion,
) -> Checker<'a> {
let semantic = SemanticModel::new(&settings.typing_modules, path, module);
Self {
@@ -523,9 +523,16 @@ impl<'a> Checker<'a> {
}
}
/// Return the [`PythonVersion`] to use for version-related checks.
pub(crate) const fn target_version(&self) -> PythonVersion {
self.target_version
/// Return the [`PythonVersion`] to use for version-related lint rules.
///
/// If the user did not provide a target version, this defaults to the lowest supported Python
/// version ([`PythonVersion::default`]).
///
/// Note that this method should not be used for version-related syntax errors emitted by the
/// parser or the [`SemanticSyntaxChecker`], which should instead default to the _latest_
/// supported Python version.
pub(crate) fn target_version(&self) -> PythonVersion {
self.target_version.linter_version()
}
fn with_semantic_checker(&mut self, f: impl FnOnce(&mut SemanticSyntaxChecker, &Checker)) {
@@ -583,7 +590,10 @@ impl TypingImporter<'_, '_> {
impl SemanticSyntaxContext for Checker<'_> {
fn python_version(&self) -> PythonVersion {
self.target_version
// Reuse `parser_version` here, which should default to `PythonVersion::latest` instead of
// `PythonVersion::default` to minimize version-related semantic syntax errors when
// `target_version` is unset.
self.target_version.parser_version()
}
fn global(&self, name: &str) -> Option<TextRange> {
@@ -1004,9 +1014,13 @@ impl<'a> Visitor<'a> for Checker<'a> {
}
// Function annotations are always evaluated at runtime, unless future annotations
// are enabled.
let annotation =
AnnotationContext::from_function(function_def, &self.semantic, self.settings);
// are enabled or the Python version is at least 3.14.
let annotation = AnnotationContext::from_function(
function_def,
&self.semantic,
self.settings,
self.target_version(),
);
// The first parameter may be a single dispatch.
let singledispatch =
@@ -1203,7 +1217,11 @@ impl<'a> Visitor<'a> for Checker<'a> {
value,
..
}) => {
match AnnotationContext::from_model(&self.semantic, self.settings) {
match AnnotationContext::from_model(
&self.semantic,
self.settings,
self.target_version(),
) {
AnnotationContext::RuntimeRequired => {
self.visit_runtime_required_annotation(annotation);
}
@@ -1358,7 +1376,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
// we can't defer again, or we'll infinitely recurse!
&& !self.semantic.in_deferred_type_definition()
&& self.semantic.in_type_definition()
&& self.semantic.future_annotations_or_stub()
&& (self.semantic.future_annotations_or_stub()||self.target_version().defers_annotations())
&& (self.semantic.in_annotation() || self.source_type.is_stub())
{
if let Expr::StringLiteral(string_literal) = expr {
@@ -2585,7 +2603,8 @@ impl<'a> Checker<'a> {
// if they are annotations in a module where `from __future__ import
// annotations` is active, or they are type definitions in a stub file.
debug_assert!(
self.semantic.future_annotations_or_stub()
(self.semantic.future_annotations_or_stub()
|| self.target_version().defers_annotations())
&& (self.source_type.is_stub() || self.semantic.in_annotation())
);
@@ -2923,7 +2942,7 @@ pub(crate) fn check_ast(
source_type: PySourceType,
cell_offsets: Option<&CellOffsets>,
notebook_index: Option<&NotebookIndex>,
target_version: PythonVersion,
target_version: TargetVersion,
) -> (Vec<Diagnostic>, Vec<SemanticSyntaxError>) {
let module_path = package
.map(PackageRoot::path)

View File

@@ -1014,6 +1014,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "057") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryRound),
(Ruff, "058") => (RuleGroup::Preview, rules::ruff::rules::StarmapZip),
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),
(Ruff, "102") => (RuleGroup::Preview, rules::ruff::rules::InvalidRuleCode),

View File

@@ -35,7 +35,7 @@ use crate::registry::{AsRule, Rule, RuleSet};
#[cfg(any(feature = "test-rules", test))]
use crate::rules::ruff::rules::test_rules::{self, TestRule, TEST_RULES};
use crate::settings::types::UnsafeFixes;
use crate::settings::{flags, LinterSettings};
use crate::settings::{flags, LinterSettings, TargetVersion};
use crate::source_kind::SourceKind;
use crate::{directives, fs, warn_user_once, Locator};
@@ -111,7 +111,7 @@ pub fn check_path(
source_kind: &SourceKind,
source_type: PySourceType,
parsed: &Parsed<ModModule>,
target_version: PythonVersion,
target_version: TargetVersion,
) -> Vec<Message> {
// Aggregate all diagnostics.
let mut diagnostics = vec![];
@@ -160,7 +160,7 @@ pub fn check_path(
locator,
comment_ranges,
settings,
target_version,
target_version.linter_version(),
));
}
@@ -215,7 +215,7 @@ pub fn check_path(
package,
source_type,
cell_offsets,
target_version,
target_version.linter_version(),
);
diagnostics.extend(import_diagnostics);
@@ -390,7 +390,7 @@ pub fn add_noqa_to_path(
) -> Result<usize> {
// Parse once.
let target_version = settings.resolve_target_version(path);
let parsed = parse_unchecked_source(source_kind, source_type, target_version);
let parsed = parse_unchecked_source(source_kind, source_type, target_version.parser_version());
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(source_kind.source_code());
@@ -451,11 +451,13 @@ pub fn lint_only(
) -> LinterResult {
let target_version = settings.resolve_target_version(path);
if matches!(target_version, PythonVersion::PY314) && !is_py314_support_enabled(settings) {
if matches!(target_version, TargetVersion(Some(PythonVersion::PY314)))
&& !is_py314_support_enabled(settings)
{
warn_user_once!("Support for Python 3.14 is under development and may be unstable. Enable `preview` to remove this warning.");
}
let parsed = source.into_parsed(source_kind, source_type, target_version);
let parsed = source.into_parsed(source_kind, source_type, target_version.parser_version());
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(source_kind.source_code());
@@ -563,14 +565,17 @@ pub fn lint_fix<'a>(
let target_version = settings.resolve_target_version(path);
if matches!(target_version, PythonVersion::PY314) && !is_py314_support_enabled(settings) {
if matches!(target_version, TargetVersion(Some(PythonVersion::PY314)))
&& !is_py314_support_enabled(settings)
{
warn_user_once!("Support for Python 3.14 is under development and may be unstable. Enable `preview` to remove this warning.");
}
// Continuously fix until the source code stabilizes.
loop {
// Parse once.
let parsed = parse_unchecked_source(&transformed, source_type, target_version);
let parsed =
parse_unchecked_source(&transformed, source_type, target_version.parser_version());
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(transformed.source_code());
@@ -972,8 +977,9 @@ mod tests {
settings: &LinterSettings,
) -> Vec<Message> {
let source_type = PySourceType::from(path);
let target_version = settings.resolve_target_version(path);
let options =
ParseOptions::from(source_type).with_target_version(settings.unresolved_target_version);
ParseOptions::from(source_type).with_target_version(target_version.parser_version());
let parsed = ruff_python_parser::parse_unchecked(source_kind.source_code(), options)
.try_into_module()
.expect("PySourceType always parses into a module");
@@ -998,7 +1004,7 @@ mod tests {
source_kind,
source_type,
&parsed,
settings.unresolved_target_version,
target_version,
);
messages.sort_by_key(Ranged::start);
messages
@@ -1063,6 +1069,53 @@ mod tests {
PythonVersion::PY310,
"MultipleCaseAssignment"
)]
#[test_case(
"duplicate_match_key",
"
match x:
case {'key': 1, 'key': 2}:
pass
",
PythonVersion::PY310,
"DuplicateMatchKey"
)]
#[test_case(
"duplicate_match_class_attribute",
"
match x:
case Point(x=1, x=2):
pass
",
PythonVersion::PY310,
"DuplicateMatchClassAttribute"
)]
#[test_case(
"invalid_star_expression",
"
def func():
return *x
",
PythonVersion::PY310,
"InvalidStarExpression"
)]
#[test_case(
"invalid_star_expression_for",
"
for *x in range(10):
pass
",
PythonVersion::PY310,
"InvalidStarExpression"
)]
#[test_case(
"invalid_star_expression_yield",
"
def func():
yield *x
",
PythonVersion::PY310,
"InvalidStarExpression"
)]
fn test_semantic_errors(
name: &str,
contents: &str,
@@ -1074,7 +1127,7 @@ mod tests {
contents,
&LinterSettings {
rules: settings::rule_table::RuleTable::empty(),
unresolved_target_version: python_version,
unresolved_target_version: python_version.into(),
preview: settings::types::PreviewMode::Enabled,
..Default::default()
},
@@ -1092,7 +1145,7 @@ mod tests {
&SourceKind::IpyNotebook(Notebook::from_path(path)?),
path,
&LinterSettings {
unresolved_target_version: python_version,
unresolved_target_version: python_version.into(),
rules: settings::rule_table::RuleTable::empty(),
preview: settings::types::PreviewMode::Enabled,
..Default::default()
@@ -1110,6 +1163,8 @@ mod tests {
Rule::LoadBeforeGlobalDeclaration,
Path::new("load_before_global_declaration.py")
)]
#[test_case(Rule::AwaitOutsideAsync, Path::new("await_outside_async_function.py"))]
#[test_case(Rule::AwaitOutsideAsync, Path::new("async_comprehension.py"))]
fn test_syntax_errors(rule: Rule, path: &Path) -> Result<()> {
let snapshot = path.to_string_lossy().to_string();
let path = Path::new("resources/test/fixtures/syntax_errors").join(path);
@@ -1156,7 +1211,7 @@ mod tests {
"pyi019_adds_typing_extensions",
PYI019_EXAMPLE,
&LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
typing_extensions: true,
..LinterSettings::for_rule(Rule::CustomTypeVarForSelf)
}
@@ -1165,7 +1220,7 @@ mod tests {
"pyi019_does_not_add_typing_extensions",
PYI019_EXAMPLE,
&LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
typing_extensions: false,
..LinterSettings::for_rule(Rule::CustomTypeVarForSelf)
}
@@ -1174,7 +1229,7 @@ mod tests {
"pyi019_adds_typing_without_extensions_disabled",
PYI019_EXAMPLE,
&LinterSettings {
unresolved_target_version: PythonVersion::PY311,
unresolved_target_version: PythonVersion::PY311.into(),
typing_extensions: true,
..LinterSettings::for_rule(Rule::CustomTypeVarForSelf)
}
@@ -1183,7 +1238,7 @@ mod tests {
"pyi019_adds_typing_with_extensions_disabled",
PYI019_EXAMPLE,
&LinterSettings {
unresolved_target_version: PythonVersion::PY311,
unresolved_target_version: PythonVersion::PY311.into(),
typing_extensions: false,
..LinterSettings::for_rule(Rule::CustomTypeVarForSelf)
}
@@ -1195,7 +1250,7 @@ mod tests {
def __new__(cls) -> C: ...
",
&LinterSettings {
unresolved_target_version: PythonVersion { major: 3, minor: 10 },
unresolved_target_version: PythonVersion { major: 3, minor: 10 }.into(),
typing_extensions: false,
..LinterSettings::for_rule(Rule::NonSelfReturnType)
}
@@ -1212,7 +1267,7 @@ mod tests {
return commons
"#,
&LinterSettings {
unresolved_target_version: PythonVersion { major: 3, minor: 8 },
unresolved_target_version: PythonVersion { major: 3, minor: 8 }.into(),
typing_extensions: false,
..LinterSettings::for_rule(Rule::FastApiNonAnnotatedDependency)
}
@@ -1227,7 +1282,7 @@ mod tests {
"pyi026_disabled",
"Vector = list[float]",
&LinterSettings {
unresolved_target_version: PythonVersion { major: 3, minor: 9 },
unresolved_target_version: PythonVersion { major: 3, minor: 9 }.into(),
typing_extensions: false,
..LinterSettings::for_rule(Rule::TypeAliasWithoutAnnotation)
}

View File

@@ -81,7 +81,7 @@ expression: value
"rules": [
{
"fullDescription": {
"text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/source/libraries.html#library-interface-public-and-private-symbols)\n"
"text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Preview\nWhen [preview](https://docs.astral.sh/ruff/preview/) is enabled,\nthe criterion for determining whether an import is first-party\nis stricter, which could affect the suggested fix. See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/source/libraries.html#library-interface-public-and-private-symbols)\n"
},
"help": {
"text": "`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability"

View File

@@ -22,6 +22,11 @@ pub(crate) const fn is_py314_support_enabled(settings: &LinterSettings) -> bool
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/16565
pub(crate) const fn is_full_path_match_source_strategy_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// Rule-specific behavior
// https://github.com/astral-sh/ruff/pull/17136

View File

@@ -1,5 +1,7 @@
use crate::rules::numpy::helpers::ImportSearcher;
use crate::rules::numpy::helpers::{AttributeSearcher, ImportSearcher};
use ruff_python_ast::name::QualifiedNameBuilder;
use ruff_python_ast::statement_visitor::StatementVisitor;
use ruff_python_ast::visitor::Visitor;
use ruff_python_ast::{Expr, ExprName, StmtTry};
use ruff_python_semantic::Exceptions;
use ruff_python_semantic::SemanticModel;
@@ -47,6 +49,22 @@ pub(crate) fn is_guarded_by_try_except(
semantic: &SemanticModel,
) -> bool {
match expr {
Expr::Attribute(_) => {
if !semantic.in_exception_handler() {
return false;
}
let Some(try_node) = semantic
.current_statements()
.find_map(|stmt| stmt.as_try_stmt())
else {
return false;
};
let suspended_exceptions = Exceptions::from_try_stmt(try_node, semantic);
if !suspended_exceptions.contains(Exceptions::ATTRIBUTE_ERROR) {
return false;
}
try_block_contains_undeprecated_attribute(try_node, replacement, semantic)
}
Expr::Name(ExprName { id, .. }) => {
let Some(binding_id) = semantic.lookup_symbol(id.as_str()) else {
return false;
@@ -78,7 +96,31 @@ pub(crate) fn is_guarded_by_try_except(
}
/// Given an [`ast::StmtTry`] node, does the `try` branch of that node
/// contain any [`ast::StmtImportFrom`] nodes that indicate the numpy
/// contain any [`ast::ExprAttribute`] nodes that indicate the airflow
/// member is being accessed from the non-deprecated location?
fn try_block_contains_undeprecated_attribute(
try_node: &StmtTry,
replacement: &Replacement,
semantic: &SemanticModel,
) -> bool {
let Replacement::AutoImport { module, name } = replacement else {
return false;
};
let undeprecated_qualified_name = {
let mut builder = QualifiedNameBuilder::default();
for part in module.split('.') {
builder.push(part);
}
builder.push(name);
builder.build()
};
let mut attribute_searcher = AttributeSearcher::new(undeprecated_qualified_name, semantic);
attribute_searcher.visit_body(&try_node.body);
attribute_searcher.found_attribute
}
/// Given an [`ast::StmtTry`] node, does the `try` branch of that node
/// contain any [`ast::StmtImportFrom`] nodes that indicate the airflow
/// member is being imported from the non-deprecated location?
fn try_block_contains_undeprecated_import(try_node: &StmtTry, replacement: &Replacement) -> bool {
let Replacement::AutoImport { module, name } = replacement else {

View File

@@ -710,11 +710,6 @@ fn check_name(checker: &Checker, expr: &Expr, range: TextRange) {
// airflow.utils.dag_cycle_tester
["dag_cycle_tester", "test_cycle"] => Replacement::None,
// airflow.utils.dag_parsing_context
["dag_parsing_context", "get_parsing_context"] => {
Replacement::Name("airflow.sdk.get_parsing_context")
}
// airflow.utils.db
["db", "create_session"] => Replacement::None,

View File

@@ -36,7 +36,7 @@ mod tests {
let diagnostics = test_path(
Path::new("fastapi").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: ruff_python_ast::PythonVersion::PY38,
unresolved_target_version: ruff_python_ast::PythonVersion::PY38.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -128,7 +128,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_annotations/auto_return_type.py"),
&LinterSettings {
unresolved_target_version: PythonVersion::PY38,
unresolved_target_version: PythonVersion::PY38.into(),
..LinterSettings::for_rules(vec![
Rule::MissingReturnTypeUndocumentedPublicFunction,
Rule::MissingReturnTypePrivateFunction,

View File

@@ -44,7 +44,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_async").join(path),
&LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(Rule::AsyncFunctionWithTimeout)
},
)?;

View File

@@ -289,11 +289,11 @@ impl Violation for UnixCommandWildcardInjection {
}
/// Check if an expression is a trusted input for subprocess.run.
/// We assume that any str or list[str] literal can be trusted.
/// We assume that any str, list[str] or tuple[str] literal can be trusted.
fn is_trusted_input(arg: &Expr) -> bool {
match arg {
Expr::StringLiteral(_) => true,
Expr::List(ast::ExprList { elts, .. }) => {
Expr::List(ast::ExprList { elts, .. }) | Expr::Tuple(ast::ExprTuple { elts, .. }) => {
elts.iter().all(|elt| matches!(elt, Expr::StringLiteral(_)))
}
Expr::Named(named) => is_trusted_input(&named.value),

View File

@@ -200,3 +200,20 @@ S603.py:41:1: S603 `subprocess` call: check for execution of untrusted input
41 | run(e)
| ^^^ S603
|
S603.py:46:1: S603 `subprocess` call: check for execution of untrusted input
|
44 | # https://github.com/astral-sh/ruff/issues/17798
45 | # Tuple literals are trusted
46 | check_output(("literal", "cmd", "using", "tuple"), text=True)
| ^^^^^^^^^^^^ S603
47 | Popen(("literal", "cmd", "using", "tuple"))
|
S603.py:47:1: S603 `subprocess` call: check for execution of untrusted input
|
45 | # Tuple literals are trusted
46 | check_output(("literal", "cmd", "using", "tuple"), text=True)
47 | Popen(("literal", "cmd", "using", "tuple"))
| ^^^^^ S603
|

View File

@@ -100,7 +100,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_bugbear").join(path).as_path(),
&LinterSettings {
unresolved_target_version: target_version,
unresolved_target_version: target_version.into(),
..LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -217,7 +217,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_builtins").join(path).as_path(),
&LinterSettings {
unresolved_target_version: PythonVersion::PY38,
unresolved_target_version: PythonVersion::PY38.into(),
..LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -30,7 +30,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_future_annotations").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY37,
unresolved_target_version: PythonVersion::PY37.into(),
..settings::LinterSettings::for_rule(Rule::FutureRewritableTypeAnnotation)
},
)?;
@@ -49,7 +49,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_future_annotations").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY37,
unresolved_target_version: PythonVersion::PY37.into(),
..settings::LinterSettings::for_rule(Rule::FutureRequiredTypeAnnotation)
},
)?;

View File

@@ -165,7 +165,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_pyi").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY38,
unresolved_target_version: PythonVersion::PY38.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -92,7 +92,7 @@ mod tests {
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY39,
unresolved_target_version: PythonVersion::PY39.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -12,10 +12,12 @@ use crate::checkers::ast::Checker;
use crate::codes::Rule;
use crate::fix;
use crate::importer::ImportedMembers;
use crate::preview::is_full_path_match_source_strategy_enabled;
use crate::rules::flake8_type_checking::helpers::{
filter_contained, is_typing_reference, quote_annotation,
};
use crate::rules::flake8_type_checking::imports::ImportBinding;
use crate::rules::isort::categorize::MatchSourceStrategy;
use crate::rules::isort::{categorize, ImportSection, ImportType};
/// ## What it does
@@ -63,6 +65,12 @@ use crate::rules::isort::{categorize, ImportSection, ImportType};
/// return len(sized)
/// ```
///
///
/// ## Preview
/// When [preview](https://docs.astral.sh/ruff/preview/) is enabled,
/// the criterion for determining whether an import is first-party
/// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-third-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.runtime-evaluated-base-classes`
@@ -138,6 +146,11 @@ impl Violation for TypingOnlyFirstPartyImport {
/// return len(df)
/// ```
///
/// ## Preview
/// When [preview](https://docs.astral.sh/ruff/preview/) is enabled,
/// the criterion for determining whether an import is first-party
/// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-first-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.runtime-evaluated-base-classes`
@@ -299,9 +312,18 @@ pub(crate) fn typing_only_runtime_import(
continue;
}
let source_name = import.source_name().join(".");
// Categorize the import, using coarse-grained categorization.
let match_source_strategy =
if is_full_path_match_source_strategy_enabled(checker.settings) {
MatchSourceStrategy::FullPath
} else {
MatchSourceStrategy::Root
};
let import_type = match categorize(
&qualified_name.to_string(),
&source_name,
qualified_name.is_unresolved_import(),
&checker.settings.src,
checker.package(),
@@ -311,6 +333,7 @@ pub(crate) fn typing_only_runtime_import(
checker.settings.isort.no_sections,
&checker.settings.isort.section_order,
&checker.settings.isort.default_section,
match_source_strategy,
) {
ImportSection::Known(ImportType::LocalFolder | ImportType::FirstParty) => {
ImportType::FirstParty

View File

@@ -1,7 +1,8 @@
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::iter;
use std::path::{Path, PathBuf};
use std::{fs, iter};
use log::debug;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
@@ -100,6 +101,7 @@ pub(crate) fn categorize<'a>(
no_sections: bool,
section_order: &'a [ImportSection],
default_section: &'a ImportSection,
match_source_strategy: MatchSourceStrategy,
) -> &'a ImportSection {
let module_base = module_name.split('.').next().unwrap();
let (mut import_type, mut reason) = {
@@ -127,7 +129,7 @@ pub(crate) fn categorize<'a>(
&ImportSection::Known(ImportType::FirstParty),
Reason::SamePackage,
)
} else if let Some(src) = match_sources(src, module_base) {
} else if let Some(src) = match_sources(src, module_name, match_source_strategy) {
(
&ImportSection::Known(ImportType::FirstParty),
Reason::SourceMatch(src),
@@ -156,20 +158,64 @@ fn same_package(package: Option<PackageRoot<'_>>, module_base: &str) -> bool {
.is_some_and(|package| package.ends_with(module_base))
}
fn match_sources<'a>(paths: &'a [PathBuf], base: &str) -> Option<&'a Path> {
for path in paths {
if let Ok(metadata) = fs::metadata(path.join(base)) {
if metadata.is_dir() {
return Some(path);
/// Returns the source path with respect to which the module `name`
/// should be considered first party, or `None` if no path is found.
///
/// The [`MatchSourceStrategy`] is the criterion used to decide whether
/// the module path matches a given source directory.
///
/// # Examples
///
/// - The module named `foo` will match `[SRC]` if `[SRC]/foo` is a directory,
/// no matter the strategy.
///
/// - With `match_source_strategy == MatchSourceStrategy::Root`, the module
/// named `foo.baz` will match `[SRC]` if `[SRC]/foo` is a
/// directory or `[SRC]/foo.py` exists.
///
/// - With `match_source_stratgy == MatchSourceStrategy::FullPath`, the module
/// named `foo.baz` will match `[SRC]` only if `[SRC]/foo/baz` is a directory,
/// or `[SRC]/foo/baz.py` exists or `[SRC]/foo/baz.pyi` exists.
fn match_sources<'a>(
paths: &'a [PathBuf],
name: &str,
match_source_strategy: MatchSourceStrategy,
) -> Option<&'a Path> {
match match_source_strategy {
MatchSourceStrategy::Root => {
let base = name.split('.').next()?;
for path in paths {
if let Ok(metadata) = fs::metadata(path.join(base)) {
if metadata.is_dir() {
return Some(path);
}
}
if let Ok(metadata) = fs::metadata(path.join(format!("{base}.py"))) {
if metadata.is_file() {
return Some(path);
}
}
}
None
}
if let Ok(metadata) = fs::metadata(path.join(format!("{base}.py"))) {
if metadata.is_file() {
return Some(path);
MatchSourceStrategy::FullPath => {
let relative_path: PathBuf = name.split('.').collect();
relative_path.components().next()?;
for root in paths {
let candidate = root.join(&relative_path);
if candidate.is_dir() {
return Some(root);
}
if ["py", "pyi"]
.into_iter()
.any(|extension| candidate.with_extension(extension).is_file())
{
return Some(root);
}
}
None
}
}
None
}
#[expect(clippy::too_many_arguments)]
@@ -183,6 +229,7 @@ pub(crate) fn categorize_imports<'a>(
no_sections: bool,
section_order: &'a [ImportSection],
default_section: &'a ImportSection,
match_source_strategy: MatchSourceStrategy,
) -> BTreeMap<&'a ImportSection, ImportBlock<'a>> {
let mut block_by_type: BTreeMap<&ImportSection, ImportBlock> = BTreeMap::default();
// Categorize `Stmt::Import`.
@@ -198,6 +245,7 @@ pub(crate) fn categorize_imports<'a>(
no_sections,
section_order,
default_section,
match_source_strategy,
);
block_by_type
.entry(import_type)
@@ -218,6 +266,7 @@ pub(crate) fn categorize_imports<'a>(
no_sections,
section_order,
default_section,
match_source_strategy,
);
block_by_type
.entry(classification)
@@ -238,6 +287,7 @@ pub(crate) fn categorize_imports<'a>(
no_sections,
section_order,
default_section,
match_source_strategy,
);
block_by_type
.entry(classification)
@@ -258,6 +308,7 @@ pub(crate) fn categorize_imports<'a>(
no_sections,
section_order,
default_section,
match_source_strategy,
);
block_by_type
.entry(classification)
@@ -409,3 +460,463 @@ impl fmt::Display for KnownModules {
Ok(())
}
}
/// Rule to determine whether a module path matches
/// a relative path from a source directory.
#[derive(Debug, Clone, Copy)]
pub(crate) enum MatchSourceStrategy {
/// Matches if first term in module path is found in file system
///
/// # Example
/// Module is `foo.bar.baz` and `[SRC]/foo` exists
Root,
/// Matches only if full module path is reflected in file system
///
/// # Example
/// Module is `foo.bar.baz` and `[SRC]/foo/bar/baz` exists
FullPath,
}
#[cfg(test)]
mod tests {
use crate::rules::isort::categorize::{match_sources, MatchSourceStrategy};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
/// Helper function to create a file with parent directories
fn create_file<P: AsRef<Path>>(path: P) {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, "").unwrap();
}
/// Helper function to create a directory and all parent directories
fn create_dir<P: AsRef<Path>>(path: P) {
fs::create_dir_all(path).unwrap();
}
/// Tests a traditional Python package layout:
/// ```
/// project/
/// └── mypackage/
/// ├── __init__.py
/// ├── module1.py
/// └── module2.py
/// ```
#[test]
fn test_traditional_layout() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
// Create traditional layout
create_dir(project_dir.join("mypackage"));
create_file(project_dir.join("mypackage/__init__.py"));
create_file(project_dir.join("mypackage/module1.py"));
create_file(project_dir.join("mypackage/module2.py"));
let paths = vec![project_dir.clone()];
// Test with Root strategy
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.module1", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.nonexistent", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "nonexistent", MatchSourceStrategy::Root),
None
);
// Test with FullPath strategy
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::FullPath),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.module1", MatchSourceStrategy::FullPath),
Some(project_dir.as_path())
);
// Differs in behavior from [`MatchSourceStrategy::Root`]
assert_eq!(
match_sources(
&paths,
"mypackage.nonexistent",
MatchSourceStrategy::FullPath
),
None
);
}
/// Tests a src-based Python package layout:
/// ```
/// project/
/// └── src/
/// └── mypackage/
/// ├── __init__.py
/// └── module1.py
/// ```
#[test]
fn test_src_layout() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
let src_dir = project_dir.join("src");
// Create src layout
create_dir(src_dir.join("mypackage"));
create_file(src_dir.join("mypackage/__init__.py"));
create_file(src_dir.join("mypackage/module1.py"));
let paths = vec![src_dir.clone()];
// Test with Root strategy
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::Root),
Some(src_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.module1", MatchSourceStrategy::Root),
Some(src_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.nonexistent", MatchSourceStrategy::Root),
Some(src_dir.as_path())
);
// Test with FullPath strategy
assert_eq!(
match_sources(&paths, "mypackage.module1", MatchSourceStrategy::FullPath),
Some(src_dir.as_path())
);
// Differs in behavior from [`MatchSourceStrategy::Root`]
assert_eq!(
match_sources(
&paths,
"mypackage.nonexistent",
MatchSourceStrategy::FullPath
),
None
);
}
/// Tests a nested package layout:
/// ```
/// project/
/// └── mypackage/
/// ├── __init__.py
/// ├── module1.py
/// └── subpackage/
/// ├── __init__.py
/// └── module2.py
/// ```
#[test]
fn test_nested_packages() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
// Create nested package layout
create_dir(project_dir.join("mypackage/subpackage"));
create_file(project_dir.join("mypackage/__init__.py"));
create_file(project_dir.join("mypackage/module1.py"));
create_file(project_dir.join("mypackage/subpackage/__init__.py"));
create_file(project_dir.join("mypackage/subpackage/module2.py"));
let paths = vec![project_dir.clone()];
// Test with Root strategy
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "mypackage.subpackage", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
// Test with FullPath strategy
assert_eq!(
match_sources(
&paths,
"mypackage.subpackage.module2",
MatchSourceStrategy::FullPath
),
Some(project_dir.as_path())
);
// Differs in behavior from [`MatchSourceStrategy::Root`]
assert_eq!(
match_sources(
&paths,
"mypackage.subpackage.nonexistent",
MatchSourceStrategy::FullPath
),
None
);
}
/// Tests a namespace package layout (PEP 420):
/// ```
/// project/
/// └── namespace/ # No __init__.py (namespace package)
/// └── package1/
/// ├── __init__.py
/// └── module1.py
/// ```
#[test]
fn test_namespace_packages() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
// Create namespace package layout
create_dir(project_dir.join("namespace/package1"));
create_file(project_dir.join("namespace/package1/__init__.py"));
create_file(project_dir.join("namespace/package1/module1.py"));
let paths = vec![project_dir.clone()];
// Test with Root strategy
assert_eq!(
match_sources(&paths, "namespace", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "namespace.package1", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(
&paths,
"namespace.package2.module1",
MatchSourceStrategy::Root
),
Some(project_dir.as_path())
);
// Test with FullPath strategy
assert_eq!(
match_sources(&paths, "namespace.package1", MatchSourceStrategy::FullPath),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(
&paths,
"namespace.package1.module1",
MatchSourceStrategy::FullPath
),
Some(project_dir.as_path())
);
// Differs in behavior from [`MatchSourceStrategy::Root`]
assert_eq!(
match_sources(
&paths,
"namespace.package2.module1",
MatchSourceStrategy::FullPath
),
None
);
}
/// Tests a package with type stubs (.pyi files):
/// ```
/// project/
/// └── mypackage/
/// ├── __init__.py
/// └── module1.pyi # Only .pyi file, no .py
/// ```
#[test]
fn test_type_stubs() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
// Create package with type stub
create_dir(project_dir.join("mypackage"));
create_file(project_dir.join("mypackage/__init__.py"));
create_file(project_dir.join("mypackage/module1.pyi")); // Only create .pyi file, not .py
// Test with FullPath strategy
let paths = vec![project_dir.clone()];
// Module "mypackage.module1" should match project_dir using .pyi file
assert_eq!(
match_sources(&paths, "mypackage.module1", MatchSourceStrategy::FullPath),
Some(project_dir.as_path())
);
}
/// Tests a package with both a module and a directory having the same name:
/// ```
/// project/
/// └── mypackage/
/// ├── __init__.py
/// ├── feature.py # Module with same name as directory
/// └── feature/ # Directory with same name as module
/// ├── __init__.py
/// └── submodule.py
/// ```
#[test]
fn test_same_name_module_and_directory() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
// Create package with module and directory of the same name
create_dir(project_dir.join("mypackage/feature"));
create_file(project_dir.join("mypackage/__init__.py"));
create_file(project_dir.join("mypackage/feature.py")); // Module with same name as directory
create_file(project_dir.join("mypackage/feature/__init__.py"));
create_file(project_dir.join("mypackage/feature/submodule.py"));
// Test with Root strategy
let paths = vec![project_dir.clone()];
// Module "mypackage.feature" should match project_dir (matches the file first)
assert_eq!(
match_sources(&paths, "mypackage.feature", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
// Test with FullPath strategy
// Module "mypackage.feature" should match project_dir
assert_eq!(
match_sources(&paths, "mypackage.feature", MatchSourceStrategy::FullPath),
Some(project_dir.as_path())
);
// Module "mypackage.feature.submodule" should match project_dir
assert_eq!(
match_sources(
&paths,
"mypackage.feature.submodule",
MatchSourceStrategy::FullPath
),
Some(project_dir.as_path())
);
}
/// Tests multiple source directories with different packages:
/// ```
/// project1/
/// └── package1/
/// ├── __init__.py
/// └── module1.py
///
/// project2/
/// └── package2/
/// ├── __init__.py
/// └── module2.py
/// ```
#[test]
fn test_multiple_source_paths() {
let temp_dir = tempdir().unwrap();
let project1_dir = temp_dir.path().join("project1");
let project2_dir = temp_dir.path().join("project2");
// Create files in project1
create_dir(project1_dir.join("package1"));
create_file(project1_dir.join("package1/__init__.py"));
create_file(project1_dir.join("package1/module1.py"));
// Create files in project2
create_dir(project2_dir.join("package2"));
create_file(project2_dir.join("package2/__init__.py"));
create_file(project2_dir.join("package2/module2.py"));
// Test with multiple paths in search order
let paths = vec![project1_dir.clone(), project2_dir.clone()];
// Module "package1" should match project1_dir
assert_eq!(
match_sources(&paths, "package1", MatchSourceStrategy::Root),
Some(project1_dir.as_path())
);
// Module "package2" should match project2_dir
assert_eq!(
match_sources(&paths, "package2", MatchSourceStrategy::Root),
Some(project2_dir.as_path())
);
// Try with reversed order to check search order
let paths_reversed = vec![project2_dir, project1_dir.clone()];
// Module "package1" should still match project1_dir
assert_eq!(
match_sources(&paths_reversed, "package1", MatchSourceStrategy::Root),
Some(project1_dir.as_path())
);
}
/// Tests behavior with an empty module name
/// ```
/// project/
/// └── mypackage/
/// ```
///
/// In theory this should never happen since we expect
/// module names to have been normalized by the time we
/// call `match_sources`. But it is worth noting that the
/// behavior is different depending on the [`MatchSourceStrategy`]
#[test]
fn test_empty_module_name() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
create_dir(project_dir.join("mypackage"));
let paths = vec![project_dir.clone()];
assert_eq!(
match_sources(&paths, "", MatchSourceStrategy::Root),
Some(project_dir.as_path())
);
assert_eq!(
match_sources(&paths, "", MatchSourceStrategy::FullPath),
None
);
}
/// Tests behavior with an empty list of source paths
#[test]
fn test_empty_paths() {
let paths: Vec<PathBuf> = vec![];
// Empty paths should return None
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::Root),
None
);
assert_eq!(
match_sources(&paths, "mypackage", MatchSourceStrategy::FullPath),
None
);
}
}

View File

@@ -5,7 +5,7 @@ use std::path::PathBuf;
use annotate::annotate_imports;
use block::{Block, Trailer};
pub(crate) use categorize::categorize;
use categorize::categorize_imports;
use categorize::{categorize_imports, MatchSourceStrategy};
pub use categorize::{ImportSection, ImportType};
use comments::Comment;
use normalize::normalize_imports;
@@ -76,6 +76,7 @@ pub(crate) fn format_imports(
source_type: PySourceType,
target_version: PythonVersion,
settings: &Settings,
match_source_strategy: MatchSourceStrategy,
tokens: &Tokens,
) -> String {
let trailer = &block.trailer;
@@ -103,6 +104,7 @@ pub(crate) fn format_imports(
package,
target_version,
settings,
match_source_strategy,
);
if !block_output.is_empty() && !output.is_empty() {
@@ -159,6 +161,7 @@ fn format_import_block(
package: Option<PackageRoot<'_>>,
target_version: PythonVersion,
settings: &Settings,
match_source_strategy: MatchSourceStrategy,
) -> String {
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum LineInsertion {
@@ -169,7 +172,6 @@ fn format_import_block(
Inserted,
}
// Categorize by type (e.g., first-party vs. third-party).
let mut block_by_type = categorize_imports(
block,
src,
@@ -180,6 +182,7 @@ fn format_import_block(
settings.no_sections,
&settings.section_order,
&settings.default_section,
match_source_strategy,
);
let mut output = String::new();

View File

@@ -15,6 +15,8 @@ use super::super::block::Block;
use super::super::{comments, format_imports};
use crate::line_width::LineWidthBuilder;
use crate::package::PackageRoot;
use crate::preview::is_full_path_match_source_strategy_enabled;
use crate::rules::isort::categorize::MatchSourceStrategy;
use crate::settings::LinterSettings;
use crate::Locator;
@@ -36,6 +38,13 @@ use crate::Locator;
/// import numpy as np
/// import pandas
/// ```
///
/// ## Preview
/// When [`preview`](https://docs.astral.sh/ruff/preview/) mode is enabled, Ruff applies a stricter criterion
/// for determining whether an import should be classified as first-party.
/// Specifically, for an import of the form `import foo.bar.baz`, Ruff will
/// check that `foo/bar`, relative to a [user-specified `src`](https://docs.astral.sh/ruff/settings/#src) directory, contains either
/// the directory `baz` or else a file with the name `baz.py` or `baz.pyi`.
#[derive(ViolationMetadata)]
pub(crate) struct UnsortedImports;
@@ -117,6 +126,12 @@ pub(crate) fn organize_imports(
trailing_lines_end(block.imports.last().unwrap(), locator.contents())
};
let match_source_strategy = if is_full_path_match_source_strategy_enabled(settings) {
MatchSourceStrategy::FullPath
} else {
MatchSourceStrategy::Root
};
// Generate the sorted import block.
let expected = format_imports(
block,
@@ -130,6 +145,7 @@ pub(crate) fn organize_imports(
source_type,
target_version,
&settings.isort,
match_source_strategy,
tokens,
);

View File

@@ -1,5 +1,10 @@
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::statement_visitor::StatementVisitor;
use ruff_python_ast::visitor::Visitor;
use ruff_python_ast::visitor::{walk_expr, walk_stmt};
use ruff_python_ast::Expr;
use ruff_python_ast::{statement_visitor, Alias, Stmt, StmtImportFrom};
use ruff_python_semantic::SemanticModel;
/// AST visitor that searches an AST tree for [`ast::StmtImportFrom`] nodes
/// that match a certain [`QualifiedName`].
@@ -43,3 +48,57 @@ impl StatementVisitor<'_> for ImportSearcher<'_> {
}
}
}
/// AST visitor that searches an AST tree for [`ast::ExprAttribute`] nodes
/// that match a certain [`QualifiedName`].
pub(crate) struct AttributeSearcher<'a> {
attribute_to_find: QualifiedName<'a>,
semantic: &'a SemanticModel<'a>,
pub found_attribute: bool,
}
impl<'a> AttributeSearcher<'a> {
pub(crate) fn new(
attribute_to_find: QualifiedName<'a>,
semantic: &'a SemanticModel<'a>,
) -> Self {
Self {
attribute_to_find,
semantic,
found_attribute: false,
}
}
}
impl Visitor<'_> for AttributeSearcher<'_> {
fn visit_expr(&mut self, expr: &'_ Expr) {
if self.found_attribute {
return;
}
if expr.is_attribute_expr()
&& self
.semantic
.resolve_qualified_name(expr)
.is_some_and(|qualified_name| qualified_name == self.attribute_to_find)
{
self.found_attribute = true;
return;
}
walk_expr(self, expr);
}
fn visit_stmt(&mut self, stmt: &ruff_python_ast::Stmt) {
if !self.found_attribute {
walk_stmt(self, stmt);
}
}
fn visit_body(&mut self, body: &[ruff_python_ast::Stmt]) {
for stmt in body {
self.visit_stmt(stmt);
if self.found_attribute {
return;
}
}
}
}

View File

@@ -1,7 +1,7 @@
use crate::rules::numpy::helpers::ImportSearcher;
use crate::rules::numpy::helpers::{AttributeSearcher, ImportSearcher};
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder};
use ruff_python_ast::name::QualifiedNameBuilder;
use ruff_python_ast::statement_visitor::StatementVisitor;
use ruff_python_ast::visitor::Visitor;
use ruff_python_ast::{self as ast, Expr};
@@ -823,57 +823,6 @@ fn try_block_contains_undeprecated_attribute(
attribute_searcher.found_attribute
}
/// AST visitor that searches an AST tree for [`ast::ExprAttribute`] nodes
/// that match a certain [`QualifiedName`].
struct AttributeSearcher<'a> {
attribute_to_find: QualifiedName<'a>,
semantic: &'a SemanticModel<'a>,
found_attribute: bool,
}
impl<'a> AttributeSearcher<'a> {
fn new(attribute_to_find: QualifiedName<'a>, semantic: &'a SemanticModel<'a>) -> Self {
Self {
attribute_to_find,
semantic,
found_attribute: false,
}
}
}
impl Visitor<'_> for AttributeSearcher<'_> {
fn visit_expr(&mut self, expr: &'_ Expr) {
if self.found_attribute {
return;
}
if expr.is_attribute_expr()
&& self
.semantic
.resolve_qualified_name(expr)
.is_some_and(|qualified_name| qualified_name == self.attribute_to_find)
{
self.found_attribute = true;
return;
}
ast::visitor::walk_expr(self, expr);
}
fn visit_stmt(&mut self, stmt: &ruff_python_ast::Stmt) {
if !self.found_attribute {
ast::visitor::walk_stmt(self, stmt);
}
}
fn visit_body(&mut self, body: &[ruff_python_ast::Stmt]) {
for stmt in body {
self.visit_stmt(stmt);
if self.found_attribute {
return;
}
}
}
}
/// Given an [`ast::StmtTry`] node, does the `try` branch of that node
/// contain any [`ast::StmtImportFrom`] nodes that indicate the numpy
/// member is being imported from the non-deprecated location?

View File

@@ -44,7 +44,7 @@ mod tests {
Path::new("perflint").join(path).as_path(),
&LinterSettings {
preview: PreviewMode::Enabled,
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(rule_code)
},
)?;

View File

@@ -219,7 +219,7 @@ mod tests {
let diagnostics = test_snippet(
"PythonFinalizationError",
&LinterSettings {
unresolved_target_version: ruff_python_ast::PythonVersion::PY312,
unresolved_target_version: ruff_python_ast::PythonVersion::PY312.into(),
..LinterSettings::for_rule(Rule::UndefinedName)
},
);
@@ -744,8 +744,9 @@ mod tests {
let source_type = PySourceType::default();
let source_kind = SourceKind::Python(contents.to_string());
let settings = LinterSettings::for_rules(Linter::Pyflakes.rules());
let target_version = settings.unresolved_target_version;
let options =
ParseOptions::from(source_type).with_target_version(settings.unresolved_target_version);
ParseOptions::from(source_type).with_target_version(target_version.parser_version());
let parsed = ruff_python_parser::parse_unchecked(source_kind.source_code(), options)
.try_into_module()
.expect("PySourceType always parses into a module");
@@ -770,7 +771,7 @@ mod tests {
&source_kind,
source_type,
&parsed,
settings.unresolved_target_version,
target_version,
);
messages.sort_by_key(Ranged::start);
let actual = messages

View File

@@ -16,8 +16,11 @@ use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::fix;
use crate::preview::is_dunder_init_fix_unused_import_enabled;
use crate::preview::{
is_dunder_init_fix_unused_import_enabled, is_full_path_match_source_strategy_enabled,
};
use crate::registry::Rule;
use crate::rules::isort::categorize::MatchSourceStrategy;
use crate::rules::{isort, isort::ImportSection, isort::ImportType};
/// ## What it does
@@ -88,6 +91,11 @@ use crate::rules::{isort, isort::ImportSection, isort::ImportType};
/// print("numpy is not installed")
/// ```
///
/// ## Preview
/// When [preview](https://docs.astral.sh/ruff/preview/) is enabled,
/// the criterion for determining whether an import is first-party
/// is stricter, which could affect the suggested fix. See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details.
///
/// ## Options
/// - `lint.ignore-init-module-imports`
/// - `lint.pyflakes.allowed-unused-imports`
@@ -222,10 +230,15 @@ enum UnusedImportContext {
}
fn is_first_party(import: &AnyImport, checker: &Checker) -> bool {
let qualified_name = import.qualified_name();
let source_name = import.source_name().join(".");
let match_source_strategy = if is_full_path_match_source_strategy_enabled(checker.settings) {
MatchSourceStrategy::FullPath
} else {
MatchSourceStrategy::Root
};
let category = isort::categorize(
&qualified_name.to_string(),
qualified_name.is_unresolved_import(),
&source_name,
import.qualified_name().is_unresolved_import(),
&checker.settings.src,
checker.package(),
checker.settings.isort.detect_same_package,
@@ -234,6 +247,7 @@ fn is_first_party(import: &AnyImport, checker: &Checker) -> bool {
checker.settings.isort.no_sections,
&checker.settings.isort.section_order,
&checker.settings.isort.default_section,
match_source_strategy,
);
matches! {
category,

View File

@@ -19,6 +19,20 @@ use ruff_text_size::Ranged;
/// Prefer `sys.exit()`, as the `sys` module is guaranteed to exist in all
/// contexts.
///
/// ## Fix safety
/// This fix is always unsafe. When replacing `exit` or `quit` with `sys.exit`,
/// the behavior can change in the following ways:
///
/// 1. If the code runs in an environment where the `site` module is not imported
/// (e.g., with `python -S`), the original code would raise a `NameError`, while
/// the fixed code would execute normally.
///
/// 2. `site.exit` and `sys.exit` handle tuple arguments differently. `site.exit`
/// treats tuples as regular objects and always returns exit code 1, while `sys.exit`
/// interprets tuple contents to determine the exit code: an empty tuple () results in
/// exit code 0, and a single-element tuple like (2,) uses that element's value (2) as
/// the exit code.
///
/// ## Example
/// ```python
/// if __name__ == "__main__":

View File

@@ -15,6 +15,34 @@ use ruff_python_ast::PythonVersion;
/// Dunder names are not meant to be called explicitly and, in most cases, can
/// be replaced with builtins or operators.
///
/// ## Fix safety
/// This fix is always unsafe. When replacing dunder method calls with operators
/// or builtins, the behavior can change in the following ways:
///
/// 1. Types may implement only a subset of related dunder methods. Calling a
/// missing dunder method directly returns `NotImplemented`, but using the
/// equivalent operator raises a `TypeError`.
/// ```python
/// class C: pass
/// c = C()
/// c.__gt__(1) # before fix: NotImplemented
/// c > 1 # after fix: raises TypeError
/// ```
/// 2. Instance-assigned dunder methods are ignored by operators and builtins.
/// ```python
/// class C: pass
/// c = C()
/// c.__bool__ = lambda: False
/// c.__bool__() # before fix: False
/// bool(c) # after fix: True
/// ```
///
/// 3. Even with built-in types, behavior can differ.
/// ```python
/// (1).__gt__(1.0) # before fix: NotImplemented
/// 1 > 1.0 # after fix: False
/// ```
///
/// ## Example
/// ```python
/// three = (3.0).__str__()

View File

@@ -139,7 +139,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/UP041.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..settings::LinterSettings::for_rule(Rule::TimeoutErrorAlias)
},
)?;
@@ -152,7 +152,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/UP040.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY311,
unresolved_target_version: PythonVersion::PY311.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP695TypeAlias)
},
)?;
@@ -168,7 +168,7 @@ mod tests {
pyupgrade: pyupgrade::settings::Settings {
keep_runtime_typing: true,
},
unresolved_target_version: PythonVersion::PY37,
unresolved_target_version: PythonVersion::PY37.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP585Annotation)
},
)?;
@@ -184,7 +184,7 @@ mod tests {
pyupgrade: pyupgrade::settings::Settings {
keep_runtime_typing: true,
},
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP585Annotation)
},
)?;
@@ -197,7 +197,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/future_annotations.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY37,
unresolved_target_version: PythonVersion::PY37.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP585Annotation)
},
)?;
@@ -210,7 +210,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/future_annotations.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP585Annotation)
},
)?;
@@ -223,7 +223,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/future_annotations.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY37,
unresolved_target_version: PythonVersion::PY37.into(),
..settings::LinterSettings::for_rules([
Rule::NonPEP604AnnotationUnion,
Rule::NonPEP604AnnotationOptional,
@@ -239,7 +239,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/future_annotations.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..settings::LinterSettings::for_rules([
Rule::NonPEP604AnnotationUnion,
Rule::NonPEP604AnnotationOptional,
@@ -255,7 +255,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/UP017.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY311,
unresolved_target_version: PythonVersion::PY311.into(),
..settings::LinterSettings::for_rule(Rule::DatetimeTimezoneUTC)
},
)?;
@@ -268,7 +268,7 @@ mod tests {
let diagnostics = test_path(
Path::new("pyupgrade/UP044.py"),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY311,
unresolved_target_version: PythonVersion::PY311.into(),
..settings::LinterSettings::for_rule(Rule::NonPEP646Unpack)
},
)?;

View File

@@ -161,13 +161,14 @@ pub(crate) fn native_literals(
keywords,
range: _,
},
range: _,
range: call_range,
} = call;
if !keywords.is_empty() || args.len() > 1 {
return;
}
let tokens = checker.tokens();
let semantic = checker.semantic();
let Some(builtin) = semantic.resolve_builtin_symbol(func) else {
@@ -244,7 +245,20 @@ pub(crate) fn native_literals(
let arg_code = checker.locator().slice(arg);
let content = match (parent_expr, literal_type, has_unary_op) {
let mut needs_space = false;
// Look for the `Rpar` token of the call expression and check if there is a keyword token right
// next to it without any space separating them. Without this check, the fix for this
// rule would create a syntax error.
// Ex) `bool(True)and None` no space between `)` and the keyword `and`.
//
// Subtract 1 from the end of the range to include `Rpar` token in the slice.
if let [paren_token, next_token, ..] = tokens.after(call_range.sub_end(1.into()).end())
{
needs_space = next_token.kind().is_keyword()
&& paren_token.range().end() == next_token.range().start();
}
let mut content = match (parent_expr, literal_type, has_unary_op) {
// Expressions including newlines must be parenthesised to be valid syntax
(_, _, true) if find_newline(arg_code).is_some() => format!("({arg_code})"),
@@ -265,6 +279,10 @@ pub(crate) fn native_literals(
_ => arg_code.to_string(),
};
if needs_space {
content.push(' ');
}
let applicability = if checker.comment_ranges().intersects(call.range) {
Applicability::Unsafe
} else {

View File

@@ -602,6 +602,8 @@ UP018.py:83:1: UP018 [*] Unnecessary `str` call (rewrite as a literal)
85 | | ipsum''' # Comment
86 | | ).foo
| |_^ UP018
87 |
88 | # https://github.com/astral-sh/ruff/issues/17606
|
= help: Replace with string literal
@@ -615,3 +617,80 @@ UP018.py:83:1: UP018 [*] Unnecessary `str` call (rewrite as a literal)
86 |-).foo
83 |+'''Lorem
84 |+ ipsum'''.foo
87 85 |
88 86 | # https://github.com/astral-sh/ruff/issues/17606
89 87 | bool(True)and None
UP018.py:89:1: UP018 [*] Unnecessary `bool` call (rewrite as a literal)
|
88 | # https://github.com/astral-sh/ruff/issues/17606
89 | bool(True)and None
| ^^^^^^^^^^ UP018
90 | int(1)and None
91 | float(1.)and None
|
= help: Replace with boolean literal
Safe fix
86 86 | ).foo
87 87 |
88 88 | # https://github.com/astral-sh/ruff/issues/17606
89 |-bool(True)and None
89 |+True and None
90 90 | int(1)and None
91 91 | float(1.)and None
92 92 | bool(True)and()
UP018.py:90:1: UP018 [*] Unnecessary `int` call (rewrite as a literal)
|
88 | # https://github.com/astral-sh/ruff/issues/17606
89 | bool(True)and None
90 | int(1)and None
| ^^^^^^ UP018
91 | float(1.)and None
92 | bool(True)and()
|
= help: Replace with integer literal
Safe fix
87 87 |
88 88 | # https://github.com/astral-sh/ruff/issues/17606
89 89 | bool(True)and None
90 |-int(1)and None
90 |+1 and None
91 91 | float(1.)and None
92 92 | bool(True)and()
UP018.py:91:1: UP018 [*] Unnecessary `float` call (rewrite as a literal)
|
89 | bool(True)and None
90 | int(1)and None
91 | float(1.)and None
| ^^^^^^^^^ UP018
92 | bool(True)and()
|
= help: Replace with float literal
Safe fix
88 88 | # https://github.com/astral-sh/ruff/issues/17606
89 89 | bool(True)and None
90 90 | int(1)and None
91 |-float(1.)and None
91 |+1. and None
92 92 | bool(True)and()
UP018.py:92:1: UP018 [*] Unnecessary `bool` call (rewrite as a literal)
|
90 | int(1)and None
91 | float(1.)and None
92 | bool(True)and()
| ^^^^^^^^^^ UP018
|
= help: Replace with boolean literal
Safe fix
89 89 | bool(True)and None
90 90 | int(1)and None
91 91 | float(1.)and None
92 |-bool(True)and()
92 |+True and()

View File

@@ -99,6 +99,7 @@ mod tests {
#[test_case(Rule::UnusedUnpackedVariable, Path::new("RUF059_1.py"))]
#[test_case(Rule::UnusedUnpackedVariable, Path::new("RUF059_2.py"))]
#[test_case(Rule::UnusedUnpackedVariable, Path::new("RUF059_3.py"))]
#[test_case(Rule::InEmptyCollection, Path::new("RUF060.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_0.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_1.py"))]
#[test_case(Rule::InvalidRuleCode, Path::new("RUF102.py"))]
@@ -135,7 +136,7 @@ mod tests {
ruff: super::settings::Settings {
parenthesize_tuple_in_subscript: false,
},
unresolved_target_version: PythonVersion::PY310,
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(Rule::IncorrectlyParenthesizedTupleInSubscript)
},
)?;

View File

@@ -72,6 +72,11 @@ use super::super::typing::type_hint_explicitly_allows_none;
/// ## Options
/// - `target-version`
///
/// ## Fix safety
///
/// This fix is always marked as unsafe because it can change the behavior of code that relies on
/// type hints, and it assumes the default value is always appropriate—which might not be the case.
///
/// [PEP 484]: https://peps.python.org/pep-0484/#union-types
#[derive(ViolationMetadata)]
pub(crate) struct ImplicitOptional {

View File

@@ -0,0 +1,89 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{self as ast, CmpOp, Expr};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
/// ## What it does
/// Checks for membership tests on empty collections (such as `list`, `tuple`, `set` or `dict`).
///
/// ## Why is this bad?
/// If the collection is always empty, the check is unnecessary, and can be removed.
///
/// ## Example
///
/// ```python
/// if 1 not in set():
/// print("got it!")
/// ```
///
/// Use instead:
///
/// ```python
/// print("got it!")
/// ```
#[derive(ViolationMetadata)]
pub(crate) struct InEmptyCollection;
impl Violation for InEmptyCollection {
#[derive_message_formats]
fn message(&self) -> String {
"Unnecessary membership test on empty collection".to_string()
}
}
/// RUF060
pub(crate) fn in_empty_collection(checker: &Checker, compare: &ast::ExprCompare) {
let [op] = &*compare.ops else {
return;
};
if !matches!(op, CmpOp::In | CmpOp::NotIn) {
return;
}
let [right] = &*compare.comparators else {
return;
};
let semantic = checker.semantic();
let collection_methods = [
"list",
"tuple",
"set",
"frozenset",
"dict",
"bytes",
"bytearray",
"str",
];
let is_empty_collection = match right {
Expr::List(ast::ExprList { elts, .. }) => elts.is_empty(),
Expr::Tuple(ast::ExprTuple { elts, .. }) => elts.is_empty(),
Expr::Set(ast::ExprSet { elts, .. }) => elts.is_empty(),
Expr::Dict(ast::ExprDict { items, .. }) => items.is_empty(),
Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. }) => value.is_empty(),
Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => value.is_empty(),
Expr::FString(s) => s
.value
.elements()
.all(|elt| elt.as_literal().is_some_and(|elt| elt.is_empty())),
Expr::Call(ast::ExprCall {
func,
arguments,
range: _,
}) => {
arguments.is_empty()
&& collection_methods
.iter()
.any(|s| semantic.match_builtin_expr(func, s))
}
_ => false,
};
if is_empty_collection {
checker.report_diagnostic(Diagnostic::new(InEmptyCollection, compare.range()));
}
}

View File

@@ -13,6 +13,7 @@ pub(crate) use function_call_in_dataclass_default::*;
pub(crate) use if_key_in_dict_del::*;
pub(crate) use implicit_classvar_in_dataclass::*;
pub(crate) use implicit_optional::*;
pub(crate) use in_empty_collection::*;
pub(crate) use incorrectly_parenthesized_tuple_in_subscript::*;
pub(crate) use indented_form_feed::*;
pub(crate) use invalid_assert_message_literal_argument::*;
@@ -73,6 +74,7 @@ mod helpers;
mod if_key_in_dict_del;
mod implicit_classvar_in_dataclass;
mod implicit_optional;
mod in_empty_collection;
mod incorrectly_parenthesized_tuple_in_subscript;
mod indented_form_feed;
mod invalid_assert_message_literal_argument;

View File

@@ -0,0 +1,200 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF060.py:2:1: RUF060 Unnecessary membership test on empty collection
|
1 | # Errors
2 | 1 in []
| ^^^^^^^ RUF060
3 | 1 not in []
4 | 2 in list()
|
RUF060.py:3:1: RUF060 Unnecessary membership test on empty collection
|
1 | # Errors
2 | 1 in []
3 | 1 not in []
| ^^^^^^^^^^^ RUF060
4 | 2 in list()
5 | 2 not in list()
|
RUF060.py:4:1: RUF060 Unnecessary membership test on empty collection
|
2 | 1 in []
3 | 1 not in []
4 | 2 in list()
| ^^^^^^^^^^^ RUF060
5 | 2 not in list()
6 | _ in ()
|
RUF060.py:5:1: RUF060 Unnecessary membership test on empty collection
|
3 | 1 not in []
4 | 2 in list()
5 | 2 not in list()
| ^^^^^^^^^^^^^^^ RUF060
6 | _ in ()
7 | _ not in ()
|
RUF060.py:6:1: RUF060 Unnecessary membership test on empty collection
|
4 | 2 in list()
5 | 2 not in list()
6 | _ in ()
| ^^^^^^^ RUF060
7 | _ not in ()
8 | 'x' in tuple()
|
RUF060.py:7:1: RUF060 Unnecessary membership test on empty collection
|
5 | 2 not in list()
6 | _ in ()
7 | _ not in ()
| ^^^^^^^^^^^ RUF060
8 | 'x' in tuple()
9 | 'y' not in tuple()
|
RUF060.py:8:1: RUF060 Unnecessary membership test on empty collection
|
6 | _ in ()
7 | _ not in ()
8 | 'x' in tuple()
| ^^^^^^^^^^^^^^ RUF060
9 | 'y' not in tuple()
10 | 'a' in set()
|
RUF060.py:9:1: RUF060 Unnecessary membership test on empty collection
|
7 | _ not in ()
8 | 'x' in tuple()
9 | 'y' not in tuple()
| ^^^^^^^^^^^^^^^^^^ RUF060
10 | 'a' in set()
11 | 'a' not in set()
|
RUF060.py:10:1: RUF060 Unnecessary membership test on empty collection
|
8 | 'x' in tuple()
9 | 'y' not in tuple()
10 | 'a' in set()
| ^^^^^^^^^^^^ RUF060
11 | 'a' not in set()
12 | 'b' in {}
|
RUF060.py:11:1: RUF060 Unnecessary membership test on empty collection
|
9 | 'y' not in tuple()
10 | 'a' in set()
11 | 'a' not in set()
| ^^^^^^^^^^^^^^^^ RUF060
12 | 'b' in {}
13 | 'b' not in {}
|
RUF060.py:12:1: RUF060 Unnecessary membership test on empty collection
|
10 | 'a' in set()
11 | 'a' not in set()
12 | 'b' in {}
| ^^^^^^^^^ RUF060
13 | 'b' not in {}
14 | 1 in dict()
|
RUF060.py:13:1: RUF060 Unnecessary membership test on empty collection
|
11 | 'a' not in set()
12 | 'b' in {}
13 | 'b' not in {}
| ^^^^^^^^^^^^^ RUF060
14 | 1 in dict()
15 | 2 not in dict()
|
RUF060.py:14:1: RUF060 Unnecessary membership test on empty collection
|
12 | 'b' in {}
13 | 'b' not in {}
14 | 1 in dict()
| ^^^^^^^^^^^ RUF060
15 | 2 not in dict()
16 | "a" in ""
|
RUF060.py:15:1: RUF060 Unnecessary membership test on empty collection
|
13 | 'b' not in {}
14 | 1 in dict()
15 | 2 not in dict()
| ^^^^^^^^^^^^^^^ RUF060
16 | "a" in ""
17 | b'c' in b""
|
RUF060.py:16:1: RUF060 Unnecessary membership test on empty collection
|
14 | 1 in dict()
15 | 2 not in dict()
16 | "a" in ""
| ^^^^^^^^^ RUF060
17 | b'c' in b""
18 | "b" in f""
|
RUF060.py:17:1: RUF060 Unnecessary membership test on empty collection
|
15 | 2 not in dict()
16 | "a" in ""
17 | b'c' in b""
| ^^^^^^^^^^^ RUF060
18 | "b" in f""
19 | b"a" in bytearray()
|
RUF060.py:18:1: RUF060 Unnecessary membership test on empty collection
|
16 | "a" in ""
17 | b'c' in b""
18 | "b" in f""
| ^^^^^^^^^^ RUF060
19 | b"a" in bytearray()
20 | b"a" in bytes()
|
RUF060.py:19:1: RUF060 Unnecessary membership test on empty collection
|
17 | b'c' in b""
18 | "b" in f""
19 | b"a" in bytearray()
| ^^^^^^^^^^^^^^^^^^^ RUF060
20 | b"a" in bytes()
21 | 1 in frozenset()
|
RUF060.py:20:1: RUF060 Unnecessary membership test on empty collection
|
18 | "b" in f""
19 | b"a" in bytearray()
20 | b"a" in bytes()
| ^^^^^^^^^^^^^^^ RUF060
21 | 1 in frozenset()
|
RUF060.py:21:1: RUF060 Unnecessary membership test on empty collection
|
19 | b"a" in bytearray()
20 | b"a" in bytes()
21 | 1 in frozenset()
| ^^^^^^^^^^^^^^^^ RUF060
22 |
23 | # OK
|

View File

@@ -225,7 +225,7 @@ pub struct LinterSettings {
///
/// Otherwise, see [`LinterSettings::resolve_target_version`] for a way to obtain the Python
/// version for a given file, while respecting the overrides in `per_file_target_version`.
pub unresolved_target_version: PythonVersion,
pub unresolved_target_version: TargetVersion,
/// Path-specific overrides to `unresolved_target_version`.
///
/// If you have a `Checker` available, see its `target_version` method instead.
@@ -378,7 +378,7 @@ impl LinterSettings {
pub fn for_rule(rule_code: Rule) -> Self {
Self {
rules: RuleTable::from_iter([rule_code]),
unresolved_target_version: PythonVersion::latest(),
unresolved_target_version: PythonVersion::latest().into(),
..Self::default()
}
}
@@ -386,7 +386,7 @@ impl LinterSettings {
pub fn for_rules(rules: impl IntoIterator<Item = Rule>) -> Self {
Self {
rules: RuleTable::from_iter(rules),
unresolved_target_version: PythonVersion::latest(),
unresolved_target_version: PythonVersion::latest().into(),
..Self::default()
}
}
@@ -394,7 +394,7 @@ impl LinterSettings {
pub fn new(project_root: &Path) -> Self {
Self {
exclude: FilePatternSet::default(),
unresolved_target_version: PythonVersion::default(),
unresolved_target_version: TargetVersion(None),
per_file_target_version: CompiledPerFileTargetVersionList::default(),
project_root: project_root.to_path_buf(),
rules: DEFAULT_SELECTORS
@@ -458,19 +458,19 @@ impl LinterSettings {
#[must_use]
pub fn with_target_version(mut self, target_version: PythonVersion) -> Self {
self.unresolved_target_version = target_version;
self.unresolved_target_version = target_version.into();
self
}
/// Resolve the [`PythonVersion`] to use for linting.
/// Resolve the [`TargetVersion`] to use for linting.
///
/// This method respects the per-file version overrides in
/// [`LinterSettings::per_file_target_version`] and falls back on
/// [`LinterSettings::unresolved_target_version`] if none of the override patterns match.
pub fn resolve_target_version(&self, path: &Path) -> PythonVersion {
pub fn resolve_target_version(&self, path: &Path) -> TargetVersion {
self.per_file_target_version
.is_match(path)
.unwrap_or(self.unresolved_target_version)
.map_or(self.unresolved_target_version, TargetVersion::from)
}
}
@@ -479,3 +479,48 @@ impl Default for LinterSettings {
Self::new(fs::get_cwd())
}
}
/// A thin wrapper around `Option<PythonVersion>` to clarify the reason for different `unwrap`
/// calls in various places.
///
/// For example, we want to default to `PythonVersion::latest()` for parsing and detecting semantic
/// syntax errors because this will minimize version-related diagnostics when the Python version is
/// unset. In contrast, we want to default to `PythonVersion::default()` for lint rules. These
/// correspond to the [`TargetVersion::parser_version`] and [`TargetVersion::linter_version`]
/// methods, respectively.
#[derive(Debug, Clone, Copy, CacheKey)]
pub struct TargetVersion(pub Option<PythonVersion>);
impl TargetVersion {
/// Return the [`PythonVersion`] to use for parsing.
///
/// This will be either the Python version specified by the user or the latest supported
/// version if unset.
pub fn parser_version(&self) -> PythonVersion {
self.0.unwrap_or_else(PythonVersion::latest)
}
/// Return the [`PythonVersion`] to use for version-dependent lint rules.
///
/// This will either be the Python version specified by the user or the default Python version
/// if unset.
pub fn linter_version(&self) -> PythonVersion {
self.0.unwrap_or_default()
}
}
impl From<PythonVersion> for TargetVersion {
fn from(value: PythonVersion) -> Self {
Self(Some(value))
}
}
impl Display for TargetVersion {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
// manual inlining of display_settings!
match self.0 {
Some(value) => write!(f, "{value}"),
None => f.write_str("none"),
}
}
}

View File

@@ -0,0 +1,31 @@
---
source: crates/ruff_linter/src/linter.rs
---
resources/test/fixtures/syntax_errors/async_comprehension.py:5:8: PLE1142 `await` should be used within an async function
|
4 | def regular_function():
5 | [x async for x in elements(1)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ PLE1142
6 |
7 | async with elements(1) as x:
|
resources/test/fixtures/syntax_errors/async_comprehension.py:7:5: PLE1142 `await` should be used within an async function
|
5 | [x async for x in elements(1)]
6 |
7 | / async with elements(1) as x:
8 | | pass
| |____________^ PLE1142
9 |
10 | async for _ in elements(1):
|
resources/test/fixtures/syntax_errors/async_comprehension.py:10:5: PLE1142 `await` should be used within an async function
|
8 | pass
9 |
10 | / async for _ in elements(1):
11 | | pass
| |____________^ PLE1142
|

View File

@@ -0,0 +1,18 @@
---
source: crates/ruff_linter/src/linter.rs
---
resources/test/fixtures/syntax_errors/await_outside_async_function.py:2:5: PLE1142 `await` should be used within an async function
|
1 | def func():
2 | await 1
| ^^^^^^^ PLE1142
3 |
4 | # Top-level await
|
resources/test/fixtures/syntax_errors/await_outside_async_function.py:5:1: PLE1142 `await` should be used within an async function
|
4 | # Top-level await
5 | await 1
| ^^^^^^^ PLE1142
|

View File

@@ -0,0 +1,10 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:3:21: SyntaxError: attribute name `x` repeated in class pattern
|
2 | match x:
3 | case Point(x=1, x=2):
| ^
4 | pass
|

View File

@@ -0,0 +1,10 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:3:21: SyntaxError: mapping pattern checks duplicate key `'key'`
|
2 | match x:
3 | case {'key': 1, 'key': 2}:
| ^^^^^
4 | pass
|

View File

@@ -0,0 +1,9 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:3:12: SyntaxError: Starred expression cannot be used here
|
2 | def func():
3 | return *x
| ^^
|

View File

@@ -0,0 +1,9 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:2:5: SyntaxError: Starred expression cannot be used here
|
2 | for *x in range(10):
| ^^
3 | pass
|

View File

@@ -0,0 +1,9 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:3:11: SyntaxError: Starred expression cannot be used here
|
2 | def func():
3 | yield *x
| ^^
|

View File

@@ -110,7 +110,8 @@ pub(crate) fn test_contents<'a>(
) -> (Vec<Message>, Cow<'a, SourceKind>) {
let source_type = PySourceType::from(path);
let target_version = settings.resolve_target_version(path);
let options = ParseOptions::from(source_type).with_target_version(target_version);
let options =
ParseOptions::from(source_type).with_target_version(target_version.parser_version());
let parsed = ruff_python_parser::parse_unchecked(source_kind.source_code(), options.clone())
.try_into_module()
.expect("PySourceType always parses into a module");

View File

@@ -73,6 +73,10 @@ impl PythonVersion {
pub fn supports_pep_701(self) -> bool {
self >= Self::PY312
}
pub fn defers_annotations(self) -> bool {
self >= Self::PY314
}
}
impl Default for PythonVersion {

View File

@@ -1,2 +1,3 @@
sum(x for x in range(10), 5)
total(1, 2, x for x in range(5), 6)
sum(x for x in range(10),)

View File

@@ -1 +1,3 @@
zip((x for x in range(10)), (y for y in range(10)))
sum(x for x in range(10))
sum((x for x in range(10)),)

View File

@@ -661,117 +661,120 @@ impl<'src> Parser<'src> {
let mut seen_keyword_argument = false; // foo = 1
let mut seen_keyword_unpacking = false; // **foo
self.parse_comma_separated_list(RecoveryContextKind::Arguments, |parser| {
let argument_start = parser.node_start();
if parser.eat(TokenKind::DoubleStar) {
let value = parser.parse_conditional_expression_or_higher();
keywords.push(ast::Keyword {
arg: None,
value: value.expr,
range: parser.node_range(argument_start),
});
seen_keyword_unpacking = true;
} else {
let start = parser.node_start();
let mut parsed_expr = parser
.parse_named_expression_or_higher(ExpressionContext::starred_conditional());
match parser.current_token_kind() {
TokenKind::Async | TokenKind::For => {
if parsed_expr.is_unparenthesized_starred_expr() {
parser.add_error(
ParseErrorType::IterableUnpackingInComprehension,
&parsed_expr,
);
}
parsed_expr = Expr::Generator(parser.parse_generator_expression(
parsed_expr.expr,
start,
Parenthesized::No,
))
.into();
}
_ => {
if seen_keyword_unpacking && parsed_expr.is_unparenthesized_starred_expr() {
parser.add_error(
ParseErrorType::InvalidArgumentUnpackingOrder,
&parsed_expr,
);
}
}
}
let arg_range = parser.node_range(start);
if parser.eat(TokenKind::Equal) {
seen_keyword_argument = true;
let arg = if let ParsedExpr {
expr: Expr::Name(ident_expr),
is_parenthesized,
} = parsed_expr
{
// test_ok parenthesized_kwarg_py37
// # parse_options: {"target-version": "3.7"}
// f((a)=1)
// test_err parenthesized_kwarg_py38
// # parse_options: {"target-version": "3.8"}
// f((a)=1)
// f((a) = 1)
// f( ( a ) = 1)
if is_parenthesized {
parser.add_unsupported_syntax_error(
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName,
arg_range,
);
}
ast::Identifier {
id: ident_expr.id,
range: ident_expr.range,
}
} else {
// TODO(dhruvmanila): Parser shouldn't drop the `parsed_expr` if it's
// not a name expression. We could add the expression into `args` but
// that means the error is a missing comma instead.
parser.add_error(
ParseErrorType::OtherError("Expected a parameter name".to_string()),
&parsed_expr,
);
ast::Identifier {
id: Name::empty(),
range: parsed_expr.range(),
}
};
let has_trailing_comma =
self.parse_comma_separated_list(RecoveryContextKind::Arguments, |parser| {
let argument_start = parser.node_start();
if parser.eat(TokenKind::DoubleStar) {
let value = parser.parse_conditional_expression_or_higher();
keywords.push(ast::Keyword {
arg: Some(arg),
arg: None,
value: value.expr,
range: parser.node_range(argument_start),
});
seen_keyword_unpacking = true;
} else {
if !parsed_expr.is_unparenthesized_starred_expr() {
if seen_keyword_unpacking {
parser.add_error(
ParseErrorType::PositionalAfterKeywordUnpacking,
&parsed_expr,
);
} else if seen_keyword_argument {
parser.add_error(
ParseErrorType::PositionalAfterKeywordArgument,
&parsed_expr,
);
let start = parser.node_start();
let mut parsed_expr = parser
.parse_named_expression_or_higher(ExpressionContext::starred_conditional());
match parser.current_token_kind() {
TokenKind::Async | TokenKind::For => {
if parsed_expr.is_unparenthesized_starred_expr() {
parser.add_error(
ParseErrorType::IterableUnpackingInComprehension,
&parsed_expr,
);
}
parsed_expr = Expr::Generator(parser.parse_generator_expression(
parsed_expr.expr,
start,
Parenthesized::No,
))
.into();
}
_ => {
if seen_keyword_unpacking
&& parsed_expr.is_unparenthesized_starred_expr()
{
parser.add_error(
ParseErrorType::InvalidArgumentUnpackingOrder,
&parsed_expr,
);
}
}
}
args.push(parsed_expr.expr);
let arg_range = parser.node_range(start);
if parser.eat(TokenKind::Equal) {
seen_keyword_argument = true;
let arg = if let ParsedExpr {
expr: Expr::Name(ident_expr),
is_parenthesized,
} = parsed_expr
{
// test_ok parenthesized_kwarg_py37
// # parse_options: {"target-version": "3.7"}
// f((a)=1)
// test_err parenthesized_kwarg_py38
// # parse_options: {"target-version": "3.8"}
// f((a)=1)
// f((a) = 1)
// f( ( a ) = 1)
if is_parenthesized {
parser.add_unsupported_syntax_error(
UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName,
arg_range,
);
}
ast::Identifier {
id: ident_expr.id,
range: ident_expr.range,
}
} else {
// TODO(dhruvmanila): Parser shouldn't drop the `parsed_expr` if it's
// not a name expression. We could add the expression into `args` but
// that means the error is a missing comma instead.
parser.add_error(
ParseErrorType::OtherError("Expected a parameter name".to_string()),
&parsed_expr,
);
ast::Identifier {
id: Name::empty(),
range: parsed_expr.range(),
}
};
let value = parser.parse_conditional_expression_or_higher();
keywords.push(ast::Keyword {
arg: Some(arg),
value: value.expr,
range: parser.node_range(argument_start),
});
} else {
if !parsed_expr.is_unparenthesized_starred_expr() {
if seen_keyword_unpacking {
parser.add_error(
ParseErrorType::PositionalAfterKeywordUnpacking,
&parsed_expr,
);
} else if seen_keyword_argument {
parser.add_error(
ParseErrorType::PositionalAfterKeywordArgument,
&parsed_expr,
);
}
}
args.push(parsed_expr.expr);
}
}
}
});
});
self.expect(TokenKind::Rpar);
@@ -781,7 +784,7 @@ impl<'src> Parser<'src> {
keywords: keywords.into_boxed_slice(),
};
self.validate_arguments(&arguments);
self.validate_arguments(&arguments, has_trailing_comma);
arguments
}
@@ -2521,9 +2524,9 @@ impl<'src> Parser<'src> {
/// Performs the following validations on the function call arguments:
/// 1. There aren't any duplicate keyword argument
/// 2. If there are more than one argument (positional or keyword), all generator expressions
/// present should be parenthesized.
fn validate_arguments(&mut self, arguments: &ast::Arguments) {
/// 2. If there are more than one argument (positional or keyword) or a single argument with a
/// trailing comma, all generator expressions present should be parenthesized.
fn validate_arguments(&mut self, arguments: &ast::Arguments, has_trailing_comma: bool) {
let mut all_arg_names =
FxHashSet::with_capacity_and_hasher(arguments.keywords.len(), FxBuildHasher);
@@ -2541,7 +2544,7 @@ impl<'src> Parser<'src> {
}
}
if arguments.len() > 1 {
if has_trailing_comma || arguments.len() > 1 {
for arg in &*arguments.args {
if let Some(ast::ExprGenerator {
range,
@@ -2550,11 +2553,14 @@ impl<'src> Parser<'src> {
}) = arg.as_generator_expr()
{
// test_ok args_unparenthesized_generator
// zip((x for x in range(10)), (y for y in range(10)))
// sum(x for x in range(10))
// sum((x for x in range(10)),)
// test_err args_unparenthesized_generator
// sum(x for x in range(10), 5)
// total(1, 2, x for x in range(5), 6)
// sum(x for x in range(10),)
self.add_error(ParseErrorType::UnparenthesizedGeneratorExpression, range);
}
}

View File

@@ -539,17 +539,19 @@ impl<'src> Parser<'src> {
}
/// Parses a comma separated list of elements where each element is parsed
/// sing the given `parse_element` function.
/// using the given `parse_element` function.
///
/// The difference between this function and `parse_comma_separated_list_into_vec`
/// is that this function does not return the parsed elements. Instead, it is the
/// caller's responsibility to handle the parsed elements. This is the reason
/// that the `parse_element` parameter is bound to [`FnMut`] instead of [`Fn`].
///
/// Returns `true` if there is a trailing comma present.
fn parse_comma_separated_list(
&mut self,
recovery_context_kind: RecoveryContextKind,
mut parse_element: impl FnMut(&mut Parser<'src>),
) {
) -> bool {
let mut progress = ParserProgress::default();
let saved_context = self.recovery_context;
@@ -659,6 +661,8 @@ impl<'src> Parser<'src> {
}
self.recovery_context = saved_context;
trailing_comma_range.is_some()
}
#[cold]

View File

@@ -1,14 +1,13 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/err/args_unparenthesized_generator.py
snapshot_kind: text
---
## AST
```
Module(
ModModule {
range: 0..65,
range: 0..92,
body: [
Expr(
StmtExpr {
@@ -194,6 +193,82 @@ Module(
),
},
),
Expr(
StmtExpr {
range: 65..91,
value: Call(
ExprCall {
range: 65..91,
func: Name(
ExprName {
range: 65..68,
id: Name("sum"),
ctx: Load,
},
),
arguments: Arguments {
range: 68..91,
args: [
Generator(
ExprGenerator {
range: 69..89,
elt: Name(
ExprName {
range: 69..70,
id: Name("x"),
ctx: Load,
},
),
generators: [
Comprehension {
range: 71..89,
target: Name(
ExprName {
range: 75..76,
id: Name("x"),
ctx: Store,
},
),
iter: Call(
ExprCall {
range: 80..89,
func: Name(
ExprName {
range: 80..85,
id: Name("range"),
ctx: Load,
},
),
arguments: Arguments {
range: 85..89,
args: [
NumberLiteral(
ExprNumberLiteral {
range: 86..88,
value: Int(
10,
),
},
),
],
keywords: [],
},
},
),
ifs: [],
is_async: false,
},
],
parenthesized: false,
},
),
],
keywords: [],
},
},
),
},
),
],
},
)
@@ -204,6 +279,7 @@ Module(
1 | sum(x for x in range(10), 5)
| ^^^^^^^^^^^^^^^^^^^^ Syntax Error: Unparenthesized generator expression cannot be used here
2 | total(1, 2, x for x in range(5), 6)
3 | sum(x for x in range(10),)
|
@@ -211,4 +287,13 @@ Module(
1 | sum(x for x in range(10), 5)
2 | total(1, 2, x for x in range(5), 6)
| ^^^^^^^^^^^^^^^^^^^ Syntax Error: Unparenthesized generator expression cannot be used here
3 | sum(x for x in range(10),)
|
|
1 | sum(x for x in range(10), 5)
2 | total(1, 2, x for x in range(5), 6)
3 | sum(x for x in range(10),)
| ^^^^^^^^^^^^^^^^^^^^ Syntax Error: Unparenthesized generator expression cannot be used here
|

View File

@@ -1,67 +1,195 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/ok/args_unparenthesized_generator.py
snapshot_kind: text
---
## AST
```
Module(
ModModule {
range: 0..26,
range: 0..107,
body: [
Expr(
StmtExpr {
range: 0..25,
range: 0..51,
value: Call(
ExprCall {
range: 0..25,
range: 0..51,
func: Name(
ExprName {
range: 0..3,
id: Name("sum"),
id: Name("zip"),
ctx: Load,
},
),
arguments: Arguments {
range: 3..25,
range: 3..51,
args: [
Generator(
ExprGenerator {
range: 4..24,
range: 4..26,
elt: Name(
ExprName {
range: 4..5,
range: 5..6,
id: Name("x"),
ctx: Load,
},
),
generators: [
Comprehension {
range: 6..24,
range: 7..25,
target: Name(
ExprName {
range: 10..11,
range: 11..12,
id: Name("x"),
ctx: Store,
},
),
iter: Call(
ExprCall {
range: 15..24,
range: 16..25,
func: Name(
ExprName {
range: 15..20,
range: 16..21,
id: Name("range"),
ctx: Load,
},
),
arguments: Arguments {
range: 20..24,
range: 21..25,
args: [
NumberLiteral(
ExprNumberLiteral {
range: 21..23,
range: 22..24,
value: Int(
10,
),
},
),
],
keywords: [],
},
},
),
ifs: [],
is_async: false,
},
],
parenthesized: true,
},
),
Generator(
ExprGenerator {
range: 28..50,
elt: Name(
ExprName {
range: 29..30,
id: Name("y"),
ctx: Load,
},
),
generators: [
Comprehension {
range: 31..49,
target: Name(
ExprName {
range: 35..36,
id: Name("y"),
ctx: Store,
},
),
iter: Call(
ExprCall {
range: 40..49,
func: Name(
ExprName {
range: 40..45,
id: Name("range"),
ctx: Load,
},
),
arguments: Arguments {
range: 45..49,
args: [
NumberLiteral(
ExprNumberLiteral {
range: 46..48,
value: Int(
10,
),
},
),
],
keywords: [],
},
},
),
ifs: [],
is_async: false,
},
],
parenthesized: true,
},
),
],
keywords: [],
},
},
),
},
),
Expr(
StmtExpr {
range: 52..77,
value: Call(
ExprCall {
range: 52..77,
func: Name(
ExprName {
range: 52..55,
id: Name("sum"),
ctx: Load,
},
),
arguments: Arguments {
range: 55..77,
args: [
Generator(
ExprGenerator {
range: 56..76,
elt: Name(
ExprName {
range: 56..57,
id: Name("x"),
ctx: Load,
},
),
generators: [
Comprehension {
range: 58..76,
target: Name(
ExprName {
range: 62..63,
id: Name("x"),
ctx: Store,
},
),
iter: Call(
ExprCall {
range: 67..76,
func: Name(
ExprName {
range: 67..72,
id: Name("range"),
ctx: Load,
},
),
arguments: Arguments {
range: 72..76,
args: [
NumberLiteral(
ExprNumberLiteral {
range: 73..75,
value: Int(
10,
),
@@ -86,6 +214,82 @@ Module(
),
},
),
Expr(
StmtExpr {
range: 78..106,
value: Call(
ExprCall {
range: 78..106,
func: Name(
ExprName {
range: 78..81,
id: Name("sum"),
ctx: Load,
},
),
arguments: Arguments {
range: 81..106,
args: [
Generator(
ExprGenerator {
range: 82..104,
elt: Name(
ExprName {
range: 83..84,
id: Name("x"),
ctx: Load,
},
),
generators: [
Comprehension {
range: 85..103,
target: Name(
ExprName {
range: 89..90,
id: Name("x"),
ctx: Store,
},
),
iter: Call(
ExprCall {
range: 94..103,
func: Name(
ExprName {
range: 94..99,
id: Name("range"),
ctx: Load,
},
),
arguments: Arguments {
range: 99..103,
args: [
NumberLiteral(
ExprNumberLiteral {
range: 100..102,
value: Int(
10,
),
},
),
],
keywords: [],
},
},
),
ifs: [],
is_async: false,
},
],
parenthesized: true,
},
),
],
keywords: [],
},
},
),
},
),
],
},
)

View File

@@ -714,6 +714,15 @@ pub trait Imported<'a> {
/// Returns the member name of the imported symbol. For a straight import, this is equivalent
/// to the qualified name; for a `from` import, this is the name of the imported symbol.
fn member_name(&self) -> Cow<'a, str>;
/// Returns the source module of the imported symbol.
///
/// For example:
///
/// - `import foo` returns `["foo"]`
/// - `import foo.bar` returns `["foo","bar"]`
/// - `from foo import bar` returns `["foo"]`
fn source_name(&self) -> &[&'a str];
}
impl<'a> Imported<'a> for Import<'a> {
@@ -731,6 +740,10 @@ impl<'a> Imported<'a> for Import<'a> {
fn member_name(&self) -> Cow<'a, str> {
Cow::Owned(self.qualified_name().to_string())
}
fn source_name(&self) -> &[&'a str] {
self.qualified_name.segments()
}
}
impl<'a> Imported<'a> for SubmoduleImport<'a> {
@@ -748,6 +761,10 @@ impl<'a> Imported<'a> for SubmoduleImport<'a> {
fn member_name(&self) -> Cow<'a, str> {
Cow::Owned(self.qualified_name().to_string())
}
fn source_name(&self) -> &[&'a str] {
self.qualified_name.segments()
}
}
impl<'a> Imported<'a> for FromImport<'a> {
@@ -765,6 +782,10 @@ impl<'a> Imported<'a> for FromImport<'a> {
fn member_name(&self) -> Cow<'a, str> {
Cow::Borrowed(self.qualified_name.segments()[self.qualified_name.segments().len() - 1])
}
fn source_name(&self) -> &[&'a str] {
self.module_name()
}
}
/// A wrapper around an import [`BindingKind`] that can be any of the three types of imports.
@@ -799,6 +820,14 @@ impl<'ast> Imported<'ast> for AnyImport<'_, 'ast> {
Self::FromImport(import) => import.member_name(),
}
}
fn source_name(&self) -> &[&'ast str] {
match self {
Self::Import(import) => import.source_name(),
Self::SubmoduleImport(import) => import.source_name(),
Self::FromImport(import) => import.source_name(),
}
}
}
#[cfg(test)]

View File

@@ -101,7 +101,8 @@ pub(crate) fn check(
settings.linter.unresolved_target_version
};
let parse_options = ParseOptions::from(source_type).with_target_version(target_version);
let parse_options =
ParseOptions::from(source_type).with_target_version(target_version.parser_version());
// Parse once.
let parsed = ruff_python_parser::parse_unchecked(source_kind.source_code(), parse_options)

View File

@@ -166,7 +166,8 @@ impl Workspace {
let target_version = self.settings.linter.unresolved_target_version;
// Parse once.
let options = ParseOptions::from(source_type).with_target_version(target_version);
let options =
ParseOptions::from(source_type).with_target_version(target_version.parser_version());
let parsed = parse_unchecked(source_kind.source_code(), options)
.try_into_module()
.expect("`PySourceType` always parses to a `ModModule`.");

View File

@@ -65,7 +65,7 @@ fn syntax_error() {
fn unsupported_syntax_error() {
check!(
"match 2:\n case 1: ...",
r#"{"preview": true}"#,
r#"{"preview": true, "target-version": "py39"}"#,
[ExpandedMessage {
code: None,
message: "SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)".to_string(),

View File

@@ -33,7 +33,9 @@ use ruff_linter::settings::types::{
FilePatternSet, GlobPath, OutputFormat, PerFileIgnore, PerFileTargetVersion, PreviewMode,
RequiredVersion, UnsafeFixes,
};
use ruff_linter::settings::{LinterSettings, DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, TASK_TAGS};
use ruff_linter::settings::{
LinterSettings, TargetVersion, DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, TASK_TAGS,
};
use ruff_linter::{
fs, warn_user_once, warn_user_once_by_id, warn_user_once_by_message, RuleSelector,
RUFF_PKG_VERSION,
@@ -164,6 +166,7 @@ impl Configuration {
}
}
let linter_target_version = TargetVersion(self.target_version);
let target_version = self.target_version.unwrap_or_default();
let global_preview = self.preview.unwrap_or_default();
@@ -278,7 +281,7 @@ impl Configuration {
exclude: FilePatternSet::try_from_iter(lint.exclude.unwrap_or_default())?,
extension: self.extension.unwrap_or_default(),
preview: lint_preview,
unresolved_target_version: target_version,
unresolved_target_version: linter_target_version,
per_file_target_version,
project_root: project_root.to_path_buf(),
allowed_confusables: lint

View File

@@ -5,7 +5,8 @@ edition.workspace = true
rust-version.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
# Releases occur in this other repository!
repository = "https://github.com/astral-sh/ty/"
authors.workspace = true
license.workspace = true
@@ -20,7 +21,8 @@ ty_server = { workspace = true }
anyhow = { workspace = true }
argfile = { workspace = true }
clap = { workspace = true, features = ["wrap_help"] }
clap = { workspace = true, features = ["wrap_help", "string"] }
clap_complete_command = { workspace = true }
colored = { workspace = true }
countme = { workspace = true, features = ["enable"] }
crossbeam = { workspace = true }

View File

@@ -8,15 +8,43 @@ fn main() {
// The workspace root directory is not available without walking up the tree
// https://github.com/rust-lang/cargo/issues/3946
let workspace_root = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("..")
.join("..")
.join("..");
version_info(&workspace_root);
commit_info(&workspace_root);
let target = std::env::var("TARGET").unwrap();
println!("cargo::rustc-env=RUST_HOST_TARGET={target}");
}
/// Retrieve the version from the `dist-workspace.toml` file and set `TY_VERSION`.
fn version_info(workspace_root: &Path) {
let dist_file = workspace_root.join("dist-workspace.toml");
if !dist_file.exists() {
return;
}
println!("cargo:rerun-if-changed={}", dist_file.display());
let dist_file = fs::read_to_string(dist_file);
if let Ok(dist_file) = dist_file {
let lines = dist_file.lines();
for line in lines {
if line.starts_with("version =") {
let (_key, version) = line.split_once('=').unwrap();
println!(
"cargo::rustc-env=TY_VERSION={}",
version.trim().trim_matches('"')
);
break;
}
}
}
}
/// Retrieve commit information from the Git repository.
fn commit_info(workspace_root: &Path) {
// If not in a git repository, do not attempt to retrieve commit information
let git_dir = workspace_root.join(".git");
@@ -48,7 +76,8 @@ fn commit_info(workspace_root: &Path) {
.arg("-1")
.arg("--date=short")
.arg("--abbrev=9")
.arg("--format=%H %h %cd %(describe)")
.arg("--format=%H %h %cd %(describe:tags)")
.current_dir(workspace_root)
.output()
{
Ok(output) if output.status.success() => output,
@@ -65,7 +94,9 @@ fn commit_info(workspace_root: &Path) {
// https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem
if let Some(describe) = parts.next() {
let mut describe_parts = describe.split('-');
let _last_tag = describe_parts.next().unwrap();
let last_tag = describe_parts.next().unwrap();
println!("cargo::rustc-env=TY_LAST_TAG={last_tag}");
// If this is the tagged commit, this component will be missing
println!(

View File

@@ -31,7 +31,7 @@ The `TY_MAX_PARALLELISM` environment variable, meanwhile, can be used to control
By default, ty will attempt to parallelize its work so that multiple files are checked simultaneously,
but this can result in a confused logging output where messages from different threads are intertwined and non
determinism.
To switch off parallelism entirely and have more readable logs, use `TY_MAX_PARALLELSIM=1` (or `RAYON_NUM_THREADS=1`).
To switch off parallelism entirely and have more readable logs, use `TY_MAX_PARALLELISM=1` (or `RAYON_NUM_THREADS=1`).
### Examples

View File

@@ -8,7 +8,7 @@ use ty_python_semantic::lint;
#[derive(Debug, Parser)]
#[command(author, name = "ty", about = "An extremely fast Python type checker.")]
#[command(version)]
#[command(long_version = crate::version::version())]
pub(crate) struct Args {
#[command(subcommand)]
pub(crate) command: Command,
@@ -24,6 +24,10 @@ pub(crate) enum Command {
/// Display ty's version
Version,
/// Generate shell completion
#[clap(hide = true)]
GenerateShellCompletion { shell: clap_complete_command::Shell },
}
#[derive(Debug, Parser)]

View File

@@ -7,7 +7,7 @@ use std::sync::Mutex;
use crate::args::{Args, CheckCommand, Command, TerminalColor};
use crate::logging::setup_tracing;
use anyhow::{anyhow, Context};
use clap::Parser;
use clap::{CommandFactory, Parser};
use colored::Colorize;
use crossbeam::channel as crossbeam_channel;
use rayon::ThreadPoolBuilder;
@@ -68,6 +68,10 @@ fn run() -> anyhow::Result<ExitStatus> {
Command::Server => run_server().map(|()| ExitStatus::Success),
Command::Check(check_args) => run_check(check_args),
Command::Version => version().map(|()| ExitStatus::Success),
Command::GenerateShellCompletion { shell } => {
shell.generate(&mut Args::command(), &mut stdout());
Ok(ExitStatus::Success)
}
}
}
@@ -306,6 +310,10 @@ impl MainLoop {
if diagnostics_count > 1 { "s" } else { "" }
)?;
if max_severity.is_fatal() {
tracing::warn!("A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details.");
}
if self.watcher.is_none() {
return Ok(match max_severity {
Severity::Info => ExitStatus::Success,
@@ -401,6 +409,12 @@ fn set_colored_override(color: Option<TerminalColor>) {
fn setup_rayon() {
ThreadPoolBuilder::default()
.num_threads(max_parallelism().get())
// Use a reasonably large stack size to avoid running into stack overflows too easily. The
// size was chosen in such a way as to still be able to handle large expressions involving
// binary operators (x + x + … + x) both during the AST walk in semantic index building as
// well as during type checking. Using this stack size, we can handle handle expressions
// that are several times larger than the corresponding limits in existing type checkers.
.stack_size(16 * 1024 * 1024)
.build_global()
.unwrap();
}

View File

@@ -6,6 +6,7 @@ pub(crate) struct CommitInfo {
short_commit_hash: String,
commit_date: String,
commits_since_last_tag: u32,
last_tag: Option<String>,
}
/// ty's version.
@@ -34,6 +35,12 @@ impl fmt::Display for VersionInfo {
}
}
impl From<VersionInfo> for clap::builder::Str {
fn from(val: VersionInfo) -> Self {
val.to_string().into()
}
}
/// Returns information about ty's version.
pub(crate) fn version() -> VersionInfo {
// Environment variables are only read at compile-time
@@ -43,9 +50,6 @@ pub(crate) fn version() -> VersionInfo {
};
}
// This version is pulled from Cargo.toml and set by Cargo
let version = option_env_str!("CARGO_PKG_VERSION").unwrap();
// Commit info is pulled from git and set by `build.rs`
let commit_info = option_env_str!("TY_COMMIT_SHORT_HASH").map(|short_commit_hash| CommitInfo {
short_commit_hash,
@@ -53,6 +57,22 @@ pub(crate) fn version() -> VersionInfo {
commits_since_last_tag: option_env_str!("TY_LAST_TAG_DISTANCE")
.as_deref()
.map_or(0, |value| value.parse::<u32>().unwrap_or(0)),
last_tag: option_env_str!("TY_LAST_TAG"),
});
// The version is pulled from `dist-workspace.toml` and set by `build.rs`
let version = option_env_str!("TY_VERSION").unwrap_or_else(|| {
// If missing, using the last tag
commit_info
.as_ref()
.and_then(|info| {
info.last_tag.as_ref().map(|tag| {
tag.strip_prefix("v")
.map(std::string::ToString::to_string)
.unwrap_or(tag.clone())
})
})
.unwrap_or("unknown".to_string())
});
VersionInfo {
@@ -84,6 +104,7 @@ mod tests {
short_commit_hash: "53b0f5d92".to_string(),
commit_date: "2023-10-19".to_string(),
commits_since_last_tag: 0,
last_tag: None,
}),
};
assert_snapshot!(version, @"0.0.0 (53b0f5d92 2023-10-19)");
@@ -97,6 +118,7 @@ mod tests {
short_commit_hash: "53b0f5d92".to_string(),
commit_date: "2023-10-19".to_string(),
commits_since_last_tag: 24,
last_tag: None,
}),
};
assert_snapshot!(version, @"0.0.0+24 (53b0f5d92 2023-10-19)");

View File

@@ -1,6 +1,7 @@
use anyhow::Context;
use insta::internals::SettingsBindDropGuard;
use insta_cmd::{assert_cmd_snapshot, get_cargo_bin};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
@@ -149,6 +150,7 @@ fn config_override_python_version() -> anyhow::Result<()> {
5 | print(sys.last_exc)
| ^^^^^^^^^^^^
|
info: `lint:unresolved-attribute` is enabled by default
Found 1 diagnostic
@@ -275,7 +277,7 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
success: false
exit_code: 1
----- stdout -----
error: lint:unresolved-import: Cannot resolve import `utils`
error: lint:unresolved-import: Cannot resolve imported module `utils`
--> test.py:2:6
|
2 | from utils import add
@@ -283,6 +285,7 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
3 |
4 | stat = add(10, 15)
|
info: `lint:unresolved-import` is enabled by default
Found 1 diagnostic
@@ -383,6 +386,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` is enabled by default
warning: lint:possibly-unresolved-reference: Name `x` used when possibly not defined
--> test.py:7:7
@@ -392,6 +396,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
7 | print(x) # possibly-unresolved-reference
| ^
|
info: `lint:possibly-unresolved-reference` is enabled by default
Found 2 diagnostics
@@ -419,6 +424,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` was selected in the configuration file
Found 1 diagnostic
@@ -451,7 +457,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
success: false
exit_code: 1
----- stdout -----
error: lint:unresolved-import: Cannot resolve import `does_not_exit`
error: lint:unresolved-import: Cannot resolve imported module `does_not_exit`
--> test.py:2:8
|
2 | import does_not_exit
@@ -459,6 +465,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
3 |
4 | y = 4 / 0
|
info: `lint:unresolved-import` is enabled by default
error: lint:division-by-zero: Cannot divide object of type `Literal[4]` by zero
--> test.py:4:5
@@ -470,6 +477,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
5 |
6 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` is enabled by default
warning: lint:possibly-unresolved-reference: Name `x` used when possibly not defined
--> test.py:9:7
@@ -479,6 +487,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
9 | print(x) # possibly-unresolved-reference
| ^
|
info: `lint:possibly-unresolved-reference` is enabled by default
Found 3 diagnostics
@@ -498,7 +507,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
success: true
exit_code: 0
----- stdout -----
warning: lint:unresolved-import: Cannot resolve import `does_not_exit`
warning: lint:unresolved-import: Cannot resolve imported module `does_not_exit`
--> test.py:2:8
|
2 | import does_not_exit
@@ -506,6 +515,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
3 |
4 | y = 4 / 0
|
info: `lint:unresolved-import` was selected on the command line
warning: lint:division-by-zero: Cannot divide object of type `Literal[4]` by zero
--> test.py:4:5
@@ -517,6 +527,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
5 |
6 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` was selected on the command line
Found 2 diagnostics
@@ -557,6 +568,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` is enabled by default
warning: lint:possibly-unresolved-reference: Name `x` used when possibly not defined
--> test.py:7:7
@@ -566,6 +578,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
7 | print(x) # possibly-unresolved-reference
| ^
|
info: `lint:possibly-unresolved-reference` is enabled by default
Found 2 diagnostics
@@ -594,6 +607,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` was selected on the command line
Found 1 diagnostic
@@ -671,6 +685,7 @@ fn exit_code_only_warnings() -> anyhow::Result<()> {
1 | print(x) # [unresolved-reference]
| ^
|
info: `lint:unresolved-reference` is enabled by default
Found 1 diagnostic
@@ -754,6 +769,7 @@ fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
1 | print(x) # [unresolved-reference]
| ^
|
info: `lint:unresolved-reference` is enabled by default
Found 1 diagnostic
@@ -786,6 +802,7 @@ fn exit_code_no_errors_but_error_on_warning_is_enabled_in_configuration() -> any
1 | print(x) # [unresolved-reference]
| ^
|
info: `lint:unresolved-reference` is enabled by default
Found 1 diagnostic
@@ -816,6 +833,7 @@ fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
| ^
3 | print(4[1]) # [non-subscriptable]
|
info: `lint:unresolved-reference` is enabled by default
error: lint:non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
--> test.py:3:7
@@ -824,6 +842,7 @@ fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
3 | print(4[1]) # [non-subscriptable]
| ^
|
info: `lint:non-subscriptable` is enabled by default
Found 2 diagnostics
@@ -854,6 +873,7 @@ fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::
| ^
3 | print(4[1]) # [non-subscriptable]
|
info: `lint:unresolved-reference` is enabled by default
error: lint:non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
--> test.py:3:7
@@ -862,6 +882,7 @@ fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::
3 | print(4[1]) # [non-subscriptable]
| ^
|
info: `lint:non-subscriptable` is enabled by default
Found 2 diagnostics
@@ -892,6 +913,7 @@ fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
| ^
3 | print(4[1]) # [non-subscriptable]
|
info: `lint:unresolved-reference` is enabled by default
error: lint:non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
--> test.py:3:7
@@ -900,6 +922,7 @@ fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
3 | print(4[1]) # [non-subscriptable]
| ^
|
info: `lint:non-subscriptable` is enabled by default
Found 2 diagnostics
@@ -953,6 +976,7 @@ fn user_configuration() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` was selected in the configuration file
warning: lint:possibly-unresolved-reference: Name `x` used when possibly not defined
--> main.py:7:7
@@ -962,6 +986,7 @@ fn user_configuration() -> anyhow::Result<()> {
7 | print(x)
| ^
|
info: `lint:possibly-unresolved-reference` is enabled by default
Found 2 diagnostics
@@ -995,6 +1020,7 @@ fn user_configuration() -> anyhow::Result<()> {
3 |
4 | for a in range(0, int(y)):
|
info: `lint:division-by-zero` was selected in the configuration file
error: lint:possibly-unresolved-reference: Name `x` used when possibly not defined
--> main.py:7:7
@@ -1004,6 +1030,7 @@ fn user_configuration() -> anyhow::Result<()> {
7 | print(x)
| ^
|
info: `lint:possibly-unresolved-reference` was selected in the configuration file
Found 2 diagnostics
@@ -1051,8 +1078,9 @@ fn check_specific_paths() -> anyhow::Result<()> {
2 | y = 4 / 0 # error: division-by-zero
| ^^^^^
|
info: `lint:division-by-zero` is enabled by default
error: lint:unresolved-import: Cannot resolve import `main2`
error: lint:unresolved-import: Cannot resolve imported module `main2`
--> project/other.py:2:6
|
2 | from main2 import z # error: unresolved-import
@@ -1060,13 +1088,15 @@ fn check_specific_paths() -> anyhow::Result<()> {
3 |
4 | print(z)
|
info: `lint:unresolved-import` is enabled by default
error: lint:unresolved-import: Cannot resolve import `does_not_exist`
error: lint:unresolved-import: Cannot resolve imported module `does_not_exist`
--> project/tests/test_main.py:2:8
|
2 | import does_not_exist # error: unresolved-import
| ^^^^^^^^^^^^^^
|
info: `lint:unresolved-import` is enabled by default
Found 3 diagnostics
@@ -1082,7 +1112,7 @@ fn check_specific_paths() -> anyhow::Result<()> {
success: false
exit_code: 1
----- stdout -----
error: lint:unresolved-import: Cannot resolve import `main2`
error: lint:unresolved-import: Cannot resolve imported module `main2`
--> project/other.py:2:6
|
2 | from main2 import z # error: unresolved-import
@@ -1090,13 +1120,15 @@ fn check_specific_paths() -> anyhow::Result<()> {
3 |
4 | print(z)
|
info: `lint:unresolved-import` is enabled by default
error: lint:unresolved-import: Cannot resolve import `does_not_exist`
error: lint:unresolved-import: Cannot resolve imported module `does_not_exist`
--> project/tests/test_main.py:2:8
|
2 | import does_not_exist # error: unresolved-import
| ^^^^^^^^^^^^^^
|
info: `lint:unresolved-import` is enabled by default
Found 2 diagnostics
@@ -1195,6 +1227,42 @@ fn concise_revealed_type() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn can_handle_large_binop_expressions() -> anyhow::Result<()> {
let mut content = String::new();
writeln!(
&mut content,
"
from typing_extensions import reveal_type
total = 1{plus_one_repeated}
reveal_type(total)
",
plus_one_repeated = " + 1".repeat(2000 - 1)
)?;
let case = TestCase::with_file("test.py", &ruff_python_trivia::textwrap::dedent(&content))?;
assert_cmd_snapshot!(case.command(), @r"
success: true
exit_code: 0
----- stdout -----
info: revealed-type: Revealed type
--> test.py:4:1
|
2 | from typing_extensions import reveal_type
3 | total = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1...
4 | reveal_type(total)
| ^^^^^^^^^^^^^^^^^^ `Literal[2000]`
|
Found 1 diagnostic
----- stderr -----
");
Ok(())
}
struct TestCase {
_temp_dir: TempDir,
_settings_scope: SettingsBindDropGuard,

View File

@@ -6,6 +6,7 @@ use ruff_text_size::TextSize;
use crate::Db;
#[derive(Debug, Clone)]
pub struct Completion {
pub label: String,
}

View File

@@ -6,7 +6,7 @@ pub trait Db: SemanticDb + Upcast<dyn SemanticDb> + Upcast<dyn SourceDb> {}
#[cfg(test)]
pub(crate) mod tests {
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use super::Db;
use ruff_db::files::{File, Files};
@@ -16,6 +16,8 @@ pub(crate) mod tests {
use ty_python_semantic::lint::{LintRegistry, RuleSelection};
use ty_python_semantic::{default_lint_registry, Db as SemanticDb, Program};
type Events = Arc<Mutex<Vec<salsa::Event>>>;
#[salsa::db]
#[derive(Clone)]
pub(crate) struct TestDb {
@@ -23,31 +25,35 @@ pub(crate) mod tests {
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
events: Arc<std::sync::Mutex<Vec<salsa::Event>>>,
events: Events,
rule_selection: Arc<RuleSelection>,
}
#[expect(dead_code)]
impl TestDb {
pub(crate) fn new() -> Self {
let events = Events::default();
Self {
storage: salsa::Storage::default(),
storage: salsa::Storage::new(Some(Box::new({
let events = events.clone();
move |event| {
tracing::trace!("event: {event:?}");
let mut events = events.lock().unwrap();
events.push(event);
}
}))),
system: TestSystem::default(),
vendored: ty_vendored::file_system().clone(),
events: Arc::default(),
events,
files: Files::default(),
rule_selection: Arc::new(RuleSelection::from_registry(default_lint_registry())),
}
}
/// Takes the salsa events.
///
/// ## Panics
/// If there are any pending salsa snapshots.
pub(crate) fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
let inner = Arc::get_mut(&mut self.events).expect("no pending salsa snapshots");
let mut events = self.events.lock().unwrap();
let events = inner.get_mut().unwrap();
std::mem::take(&mut *events)
}
@@ -127,12 +133,5 @@ pub(crate) mod tests {
impl Db for TestDb {}
#[salsa::db]
impl salsa::Database for TestDb {
fn salsa_event(&self, event: &dyn Fn() -> salsa::Event) {
let event = event();
tracing::trace!("event: {event:?}");
let mut events = self.events.lock().unwrap();
events.push(event);
}
}
impl salsa::Database for TestDb {}
}

View File

@@ -0,0 +1,15 @@
# Regression test for https://github.com/astral-sh/ruff/issues/17792
from __future__ import annotations
class C: ...
def f(arg: C):
pass
x, _ = f(1)
assert x

View File

@@ -37,7 +37,19 @@ impl ProjectDatabase {
{
let mut db = Self {
project: None,
storage: salsa::Storage::default(),
storage: salsa::Storage::new(if tracing::enabled!(tracing::Level::TRACE) {
Some(Box::new({
move |event: Event| {
if matches!(event.kind, salsa::EventKind::WillCheckCancellation) {
return;
}
tracing::trace!("Salsa event: {event:?}");
}
}))
} else {
None
}),
files: Files::default(),
system: Arc::new(system),
};
@@ -156,20 +168,7 @@ impl SourceDb for ProjectDatabase {
}
#[salsa::db]
impl salsa::Database for ProjectDatabase {
fn salsa_event(&self, event: &dyn Fn() -> Event) {
if !tracing::enabled!(tracing::Level::TRACE) {
return;
}
let event = event();
if matches!(event.kind, salsa::EventKind::WillCheckCancellation) {
return;
}
tracing::trace!("Salsa event: {event:?}");
}
}
impl salsa::Database for ProjectDatabase {}
#[salsa::db]
impl Db for ProjectDatabase {
@@ -206,9 +205,7 @@ mod format {
#[cfg(test)]
pub(crate) mod tests {
use std::sync::Arc;
use salsa::Event;
use std::sync::{Arc, Mutex};
use ruff_db::files::Files;
use ruff_db::system::{DbWithTestSystem, System, TestSystem};
@@ -221,11 +218,13 @@ pub(crate) mod tests {
use crate::DEFAULT_LINT_REGISTRY;
use crate::{Project, ProjectMetadata};
type Events = Arc<Mutex<Vec<salsa::Event>>>;
#[salsa::db]
#[derive(Clone)]
pub(crate) struct TestDb {
storage: salsa::Storage<Self>,
events: Arc<std::sync::Mutex<Vec<Event>>>,
events: Events,
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
@@ -234,12 +233,19 @@ pub(crate) mod tests {
impl TestDb {
pub(crate) fn new(project: ProjectMetadata) -> Self {
let events = Events::default();
let mut db = Self {
storage: salsa::Storage::default(),
storage: salsa::Storage::new(Some(Box::new({
let events = events.clone();
move |event| {
let mut events = events.lock().unwrap();
events.push(event);
}
}))),
system: TestSystem::default(),
vendored: ty_vendored::file_system().clone(),
files: Files::default(),
events: Arc::default(),
events,
project: None,
};
@@ -251,13 +257,9 @@ pub(crate) mod tests {
impl TestDb {
/// Takes the salsa events.
///
/// ## Panics
/// If there are any pending salsa snapshots.
pub(crate) fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
let inner = Arc::get_mut(&mut self.events).expect("no pending salsa snapshots");
let mut events = self.events.lock().unwrap();
let events = inner.get_mut().unwrap();
std::mem::take(&mut *events)
}
}
@@ -332,10 +334,5 @@ pub(crate) mod tests {
}
#[salsa::db]
impl salsa::Database for TestDb {
fn salsa_event(&self, event: &dyn Fn() -> Event) {
let mut events = self.events.lock().unwrap();
events.push(event());
}
}
impl salsa::Database for TestDb {}
}

View File

@@ -219,34 +219,10 @@ impl Project {
.unwrap()
.into_inner()
.unwrap();
// We sort diagnostics in a way that keeps them in source order
// and grouped by file. After that, we fall back to severity
// (with fatal messages sorting before info messages) and then
// finally the diagnostic ID.
file_diagnostics.sort_by(|d1, d2| {
if let (Some(span1), Some(span2)) = (d1.primary_span(), d2.primary_span()) {
let order = span1
.file()
.path(db)
.as_str()
.cmp(span2.file().path(db).as_str());
if order.is_ne() {
return order;
}
if let (Some(range1), Some(range2)) = (span1.range(), span2.range()) {
let order = range1.start().cmp(&range2.start());
if order.is_ne() {
return order;
}
}
}
// Reverse so that, e.g., Fatal sorts before Info.
let order = d1.severity().cmp(&d2.severity()).reverse();
if order.is_ne() {
return order;
}
d1.id().cmp(&d2.id())
file_diagnostics.sort_by(|left, right| {
left.rendering_sort_key(db)
.cmp(&right.rendering_sort_key(db))
});
diagnostics.extend(file_diagnostics);
diagnostics

View File

@@ -76,7 +76,7 @@ from typing_extensions import Annotated
class C(Annotated[int, "foo"]): ...
# TODO: Should be `tuple[Literal[C], Literal[int], Literal[object]]`
reveal_type(C.__mro__) # revealed: tuple[Literal[C], @Todo(Inference of subscript on special form), Literal[object]]
reveal_type(C.__mro__) # revealed: tuple[<class 'C'>, @Todo(Inference of subscript on special form), <class 'object'>]
```
### Not parameterized
@@ -88,5 +88,5 @@ from typing_extensions import Annotated
# error: [invalid-base]
class C(Annotated): ...
reveal_type(C.__mro__) # revealed: tuple[Literal[C], Unknown, Literal[object]]
reveal_type(C.__mro__) # revealed: tuple[<class 'C'>, Unknown, <class 'object'>]
```

View File

@@ -59,7 +59,7 @@ from typing import Any
class SubclassOfAny(Any): ...
reveal_type(SubclassOfAny.__mro__) # revealed: tuple[Literal[SubclassOfAny], Any, Literal[object]]
reveal_type(SubclassOfAny.__mro__) # revealed: tuple[<class 'SubclassOfAny'>, Any, <class 'object'>]
x: SubclassOfAny = 1 # error: [invalid-assignment]
y: int = SubclassOfAny()

View File

@@ -0,0 +1,190 @@
# Self
`Self` is treated as if it were a `TypeVar` bound to the class it's being used on.
`typing.Self` is only available in Python 3.11 and later.
## Methods
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self
class Shape:
def set_scale(self: Self, scale: float) -> Self:
reveal_type(self) # revealed: Self
return self
def nested_type(self) -> list[Self]:
return [self]
def nested_func(self: Self) -> Self:
def inner() -> Self:
reveal_type(self) # revealed: Self
return self
return inner()
def implicit_self(self) -> Self:
# TODO: first argument in a method should be considered as "typing.Self"
reveal_type(self) # revealed: Unknown
return self
reveal_type(Shape().nested_type()) # revealed: @Todo(specialized non-generic class)
reveal_type(Shape().nested_func()) # revealed: Shape
class Circle(Shape):
def set_scale(self: Self, scale: float) -> Self:
reveal_type(self) # revealed: Self
return self
class Outer:
class Inner:
def foo(self: Self) -> Self:
reveal_type(self) # revealed: Self
return self
```
## Class Methods
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self, TypeVar
class Shape:
def foo(self: Self) -> Self:
return self
@classmethod
def bar(cls: type[Self]) -> Self:
# TODO: type[Shape]
reveal_type(cls) # revealed: @Todo(unsupported type[X] special form)
return cls()
class Circle(Shape): ...
reveal_type(Shape().foo()) # revealed: Shape
# TODO: Shape
reveal_type(Shape.bar()) # revealed: Unknown
```
## Attributes
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self
class LinkedList:
value: int
next_node: Self
def next(self: Self) -> Self:
reveal_type(self.value) # revealed: int
return self.next_node
reveal_type(LinkedList().next()) # revealed: LinkedList
```
## Generic Classes
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self, Generic, TypeVar
T = TypeVar("T")
class Container(Generic[T]):
value: T
def set_value(self: Self, value: T) -> Self:
return self
int_container: Container[int] = Container[int]()
reveal_type(int_container) # revealed: Container[int]
reveal_type(int_container.set_value(1)) # revealed: Container[int]
```
## Protocols
TODO: <https://typing.python.org/en/latest/spec/generics.html#use-in-protocols>
## Annotations
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self
class Shape:
def union(self: Self, other: Self | None):
reveal_type(other) # revealed: Self | None
return self
```
## Invalid Usage
`Self` cannot be used in the signature of a function or variable.
```toml
[environment]
python-version = "3.11"
```
```py
from typing import Self, Generic, TypeVar
T = TypeVar("T")
# error: [invalid-type-form]
def x(s: Self): ...
# error: [invalid-type-form]
b: Self
# TODO: "Self" cannot be used in a function with a `self` or `cls` parameter that has a type annotation other than "Self"
class Foo:
# TODO: rejected Self because self has a different type
def has_existing_self_annotation(self: T) -> Self:
return self # error: [invalid-return-type]
def return_concrete_type(self) -> Self:
# TODO: tell user to use "Foo" instead of "Self"
# error: [invalid-return-type]
return Foo()
@staticmethod
# TODO: reject because of staticmethod
def make() -> Self:
# error: [invalid-return-type]
return Foo()
class Bar(Generic[T]):
foo: T
def bar(self) -> T:
return self.foo
# error: [invalid-type-form]
class Baz(Bar[Self]): ...
class MyMetaclass(type):
# TODO: rejected
def __new__(cls) -> Self:
return super().__new__(cls)
```

View File

@@ -85,25 +85,25 @@ import typing
class ListSubclass(typing.List): ...
# TODO: generic protocols
# revealed: tuple[Literal[ListSubclass], Literal[list], Literal[MutableSequence], Literal[Sequence], Literal[Reversible], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, Literal[object]]
# revealed: tuple[<class 'ListSubclass'>, <class 'list'>, <class 'MutableSequence'>, <class 'Sequence'>, <class 'Reversible'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, <class 'object'>]
reveal_type(ListSubclass.__mro__)
class DictSubclass(typing.Dict): ...
# TODO: generic protocols
# revealed: tuple[Literal[DictSubclass], Literal[dict[Unknown, Unknown]], Literal[MutableMapping[_KT, _VT]], Literal[Mapping[_KT, _VT]], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], Literal[object]]
# revealed: tuple[<class 'DictSubclass'>, <class 'dict[Unknown, Unknown]'>, <class 'MutableMapping[Unknown, Unknown]'>, <class 'Mapping[Unknown, Unknown]'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], <class 'object'>]
reveal_type(DictSubclass.__mro__)
class SetSubclass(typing.Set): ...
# TODO: generic protocols
# revealed: tuple[Literal[SetSubclass], Literal[set], Literal[MutableSet], Literal[AbstractSet], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, Literal[object]]
# revealed: tuple[<class 'SetSubclass'>, <class 'set'>, <class 'MutableSet'>, <class 'AbstractSet'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, <class 'object'>]
reveal_type(SetSubclass.__mro__)
class FrozenSetSubclass(typing.FrozenSet): ...
# TODO: should have `Generic`, should not have `Unknown`
# revealed: tuple[Literal[FrozenSetSubclass], Literal[frozenset], Unknown, Literal[object]]
# TODO: generic protocols
# revealed: tuple[<class 'FrozenSetSubclass'>, <class 'frozenset'>, <class 'AbstractSet'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, <class 'object'>]
reveal_type(FrozenSetSubclass.__mro__)
####################
@@ -113,30 +113,30 @@ reveal_type(FrozenSetSubclass.__mro__)
class ChainMapSubclass(typing.ChainMap): ...
# TODO: generic protocols
# revealed: tuple[Literal[ChainMapSubclass], Literal[ChainMap[Unknown, Unknown]], Literal[MutableMapping[_KT, _VT]], Literal[Mapping[_KT, _VT]], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], Literal[object]]
# revealed: tuple[<class 'ChainMapSubclass'>, <class 'ChainMap[Unknown, Unknown]'>, <class 'MutableMapping[Unknown, Unknown]'>, <class 'Mapping[Unknown, Unknown]'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], <class 'object'>]
reveal_type(ChainMapSubclass.__mro__)
class CounterSubclass(typing.Counter): ...
# TODO: Should be (CounterSubclass, Counter, dict, MutableMapping, Mapping, Collection, Sized, Iterable, Container, Generic, object)
# revealed: tuple[Literal[CounterSubclass], Literal[Counter[Unknown]], Literal[dict[_T, int]], Literal[MutableMapping[_KT, _VT]], Literal[Mapping[_KT, _VT]], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], typing.Generic[_T], Literal[object]]
# revealed: tuple[<class 'CounterSubclass'>, <class 'Counter[Unknown]'>, <class 'dict[Unknown, int]'>, <class 'MutableMapping[Unknown, int]'>, <class 'Mapping[Unknown, int]'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], typing.Generic[_T], <class 'object'>]
reveal_type(CounterSubclass.__mro__)
class DefaultDictSubclass(typing.DefaultDict): ...
# TODO: Should be (DefaultDictSubclass, defaultdict, dict, MutableMapping, Mapping, Collection, Sized, Iterable, Container, Generic, object)
# revealed: tuple[Literal[DefaultDictSubclass], Literal[defaultdict[Unknown, Unknown]], Literal[dict[_KT, _VT]], Literal[MutableMapping[_KT, _VT]], Literal[Mapping[_KT, _VT]], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], Literal[object]]
# revealed: tuple[<class 'DefaultDictSubclass'>, <class 'defaultdict[Unknown, Unknown]'>, <class 'dict[Unknown, Unknown]'>, <class 'MutableMapping[Unknown, Unknown]'>, <class 'Mapping[Unknown, Unknown]'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], <class 'object'>]
reveal_type(DefaultDictSubclass.__mro__)
class DequeSubclass(typing.Deque): ...
# TODO: generic protocols
# revealed: tuple[Literal[DequeSubclass], Literal[deque], Literal[MutableSequence], Literal[Sequence], Literal[Reversible], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, Literal[object]]
# revealed: tuple[<class 'DequeSubclass'>, <class 'deque'>, <class 'MutableSequence'>, <class 'Sequence'>, <class 'Reversible'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, <class 'object'>]
reveal_type(DequeSubclass.__mro__)
class OrderedDictSubclass(typing.OrderedDict): ...
# TODO: Should be (OrderedDictSubclass, OrderedDict, dict, MutableMapping, Mapping, Collection, Sized, Iterable, Container, Generic, object)
# revealed: tuple[Literal[OrderedDictSubclass], Literal[OrderedDict[Unknown, Unknown]], Literal[dict[_KT, _VT]], Literal[MutableMapping[_KT, _VT]], Literal[Mapping[_KT, _VT]], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], Literal[object]]
# revealed: tuple[<class 'OrderedDictSubclass'>, <class 'OrderedDict[Unknown, Unknown]'>, <class 'dict[Unknown, Unknown]'>, <class 'MutableMapping[Unknown, Unknown]'>, <class 'Mapping[Unknown, Unknown]'>, <class 'Collection'>, <class 'Iterable'>, <class 'Container'>, @Todo(`Protocol[]` subscript), typing.Generic, typing.Generic[_KT, _VT_co], <class 'object'>]
reveal_type(OrderedDictSubclass.__mro__)
```

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