Compare commits

..

38 Commits

Author SHA1 Message Date
Alex Waygood
742a63d340 use a smallvec inside IntersectionType itself 2026-01-03 17:41:12 +00:00
Alex Waygood
3feb3dfb6d fix is_empty impl and add comments 2026-01-03 10:49:10 +00:00
Alex Waygood
5c36ab23b3 improve insert impl 2026-01-03 09:57:41 +00:00
Alex Waygood
f4206d524a Revert "Use an inner `Option" 2026-01-02 19:49:41 +00:00
Alex Waygood
ff387ad74a Use an inner Option 2026-01-02 19:34:42 +00:00
Alex Waygood
4abaf61b50 [ty] Optimize IntersectionType for the common case of a single negated element 2026-01-02 19:09:51 +00:00
Nikolas Hearp
0804030ee9 [pylint] Ignore identical members (PLR1714) (#22220)
## Summary

This PR closes #21692. `PLR1714` will no longer flag if all members are
identical. I iterate through the equality comparisons and if they are
all equal the rule does not flag.

## Test Plan

Additional tests were added with identical members.
2026-01-02 12:56:17 -05:00
Alex Waygood
26230b1ed3 [ty] Use IntersectionType::from_elements more (#22329) 2026-01-01 15:01:00 +00:00
github-actions[bot]
295ae836fd [ty] Sync vendored typeshed stubs (#22324)
Co-authored-by: typeshedbot <>
2026-01-01 02:59:55 +00:00
Alex Waygood
9677364847 Bump docstring-adder pin (#22323) 2026-01-01 02:44:24 +00:00
github-actions[bot]
8e45bac3c1 [ty] Sync vendored typeshed stubs (#22321)
Co-authored-by: typeshedbot <>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2026-01-01 01:29:12 +00:00
Alex Waygood
7366a9e951 Bump docstring-adder pin (#22319) 2025-12-31 22:37:53 +00:00
Brent Westbrook
15aa74206e [pylint] Improve diagnostic range for PLC0206 (#22312)
Summary
--

This PR fixes #14900 by:

- Restricting the diagnostic range from the whole `for` loop to only the
`target in iter` part
- Adding secondary annotations to each use of the `dict[key]` accesses
- Adding a `fix_title` suggesting to use `for key in dict.items()`

I thought this approach sounded slightly nicer than the alternative of
renaming the rule to focus on each indexing operation mentioned in
https://github.com/astral-sh/ruff/issues/14900#issuecomment-2543923625,
but I don't feel too strongly. This was easy to implement with our new
diagnostic infrastructure too.

This produces an example annotation like this:

```
PLC0206 Extracting value from dictionary without calling `.items()`
  --> dict_index_missing_items.py:59:5
   |
58 | # A case with multiple uses of the value to show off the secondary annotations
59 | for instrument in ORCHESTRA:
   |     ^^^^^^^^^^^^^^^^^^^^^^^
60 |     data = json.dumps(
61 |         {
62 |             "instrument": instrument,
63 |             "section": ORCHESTRA[instrument],
   |                        ---------------------
64 |         }
65 |     )
66 |
67 |     print(f"saving data for {instrument} in {ORCHESTRA[instrument]}")
   |                                              ---------------------
68 |
69 |     with open(f"{instrument}/{ORCHESTRA[instrument]}.txt", "w") as f:
   |                               ---------------------
70 |         f.write(data)
   |
help: Use `for instrument, value in ORCHESTRA.items()` instead
```

which I think is a big improvement over:

```
PLC0206 Extracting value from dictionary without calling `.items()`
  --> dict_index_missing_items.py:59:1
   |
58 |   # A case with multiple uses of the value to show off the secondary annotations
59 | / for instrument in ORCHESTRA:
60 | |     data = json.dumps(
61 | |         {
62 | |             "instrument": instrument,
63 | |             "section": ORCHESTRA[instrument],
64 | |         }
65 | |     )
66 | |
67 | |     print(f"saving data for {instrument} in {ORCHESTRA[instrument]}")
68 | |
69 | |     with open(f"{instrument}/{ORCHESTRA[instrument]}.txt", "w") as f:
70 | |         f.write(data)
   | |_____________________^
   |
```

The secondary annotation feels a bit bare without a message, but I
thought it
might be too busy to include one. Something like `value extracted here`
or
`indexed here` might work if we do want to include a brief message.

To avoid collecting a `Vec` of annotation ranges, I added a `&Checker`
to the
rule's visitor to emit diagnostics as we go instead of at the end.

Test Plan
--

Existing tests, plus a new case showing off multiple secondary
annotations
2025-12-31 13:54:58 -05:00
ValdonVitijaa
77c2f4c6cb [flake8-unused-arguments] Mark **kwargs in TypeVar as used (ARG001) (#22214)
## Summary

Fixes false positive in ARG001 when `**kwargs` is passed to
`typing.TypeVar`

Closes #22178

When `**kwargs` is used in a `typing.TypeVar` call, the checker was not
recognizing it as a usage, leading to false positive "unused function
argument" warnings.

### Root Cause

In the AST, keyword arguments are represented by the `Keyword` struct
with an `arg` field of type `Option<Identifier>`:
- Named keywords like `bound=int` have `arg = Some("bound")`
- Dictionary unpacking like `**kwargs` has `arg = None`

The existing code only handled the `Some(id)` case, never visiting the
expression when `arg` was `None`, so `**kwargs` was never marked as
used.

### Changes

Added an `else` branch to handle `**kwargs` unpacking by calling
`visit_non_type_definition(value)` when `arg` is `None`. This ensures
the `kwargs` variable is properly visited and marked as used by the
semantic model.

## Test Plan

Tested with the following code:

```python
import typing

def f(
    *args: object,
    default: object = None,
    **kwargs: object,
) -> None:
    typing.TypeVar(*args, **kwargs)
```

Before :

`ARG001 Unused function argument: kwargs
`

After : 

`All checks passed!`

Run the example with the following command(from the root of ruff and
please change the path to the module that contains the code example):

`cargo run -p ruff -- check /path/to/file.py --isolated --select=ARG
--no-cache`
2025-12-31 11:07:56 -05:00
Rob Hand
758926eecd [ty] Add blurb for newer crates to `ty/CONTIBUTING.md (#22309)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-31 07:58:33 +00:00
Matthew Mckee
6433b88ffa Clean up pre-commit config (#22311) 2025-12-31 08:36:20 +01:00
Charlie Marsh
f619783066 [ty] Treat __setattr__ as fallback-only (#22014)
## Summary

Closes https://github.com/astral-sh/ty/issues/1460.
2025-12-30 19:01:10 -05:00
Ibraheem Ahmed
ff05428ce6 [ty] Subtyping for bidirectional inference (#21930)
## Summary

Supersedes https://github.com/astral-sh/ruff/pull/21747. This version
uses the constraint solver directly, which means we should benefit from
constraint solver improvements for free.

Resolves https://github.com/astral-sh/ty/issues/1576.
2025-12-30 17:03:20 -05:00
Matthew Mckee
4f2529f353 [ty] Fix typo in cli docs for respect_ignore_files arg (#22308) 2025-12-30 21:38:50 +01:00
Rob Hand
6f9ea73ac9 [ty] Remove TY_MAX_PARALLELISM as conformance runs no longer panic (#22307) 2025-12-30 20:42:59 +01:00
Charlie Marsh
12dd27da52 [ty] Support narrowing for tuple matches with literal elements (#22303)
## Summary

See:
https://github.com/astral-sh/ruff/pull/22299#issuecomment-3699913849.
2025-12-30 13:45:07 -05:00
Alex Waygood
e0e1e9535e [ty] Convert several comments in ty_extensions.pyi to docstrings (#22305) 2025-12-30 17:41:43 +00:00
github-actions[bot]
7173c7ea3f [ty] Sync vendored typeshed stubs (#22302)
Co-authored-by: typeshedbot <>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-12-30 17:24:13 +00:00
Alex Waygood
5013752c6c Bump docstring-adder pin (#22301) 2025-12-30 17:03:54 +00:00
Kevin Yang
b2b9d91859 [airflow] Passing positional argument into airflow.lineage.hook.HookLineageCollector.create_asset is not allowed (AIR303) (#22046)
## Summary

This is a follow up PR to https://github.com/astral-sh/ruff/pull/21096

The new code AIR303 is added for checking function signature change in
Airflow 3.0. The new rule added to AIR303 will check if positional
argument is passed into
`airflow.lineage.hook.HookLineageCollector.create_asset`. Since this
method is updated to accept only keywords argument, passing positional
argument into it is not allowed, and will raise an error. The test is
done by checking whether positional argument with 0 index can be found.

## Test Plan

A new test file is added to the fixtures for the code AIR303. Snapshot
test is updated accordingly.

<img width="1444" height="513" alt="Screenshot from 2025-12-17 20-54-48"
src="https://github.com/user-attachments/assets/bc235195-e986-4743-9bf7-bba65805fb87"
/>

<img width="981" height="433" alt="Screenshot from 2025-12-17 21-34-29"
src="https://github.com/user-attachments/assets/492db71f-58f2-40ba-ad2f-f74852fa5a6b"
/>
2025-12-30 11:39:08 -05:00
Brent Westbrook
c483b59ddd [ruff] Add non-empty-init-module (RUF067) (#22143)
Summary
--

This PR adds a new rule, `non-empty-init-module`, which restricts the
kind of
code that can be included in an `__init__.py` file. By default,
docstrings,
imports, and assignments to `__all__` are allowed. When the new
configuration
option `lint.ruff.strictly-empty-init-modules` is enabled, no code at
all is
allowed.

This closes #9848, where these two variants correspond to different
rules in the

[`flake8-empty-init-modules`](https://github.com/samueljsb/flake8-empty-init-modules/)
linter. The upstream rules are EIM001, which bans all code, and EIM002,
which
bans non-import/docstring/`__all__` code. Since we discussed folding
these into
one rule on [Discord], I just added the rule to the `RUF` group instead
of
adding a new `EIM` plugin.

I'm not really sure we need to flag docstrings even when the strict
setting is
enabled, but I just followed upstream for now. Similarly, as I noted in
a TODO
comment, we could also allow more statements involving `__all__`, such
as
`__all__.append(...)` or `__all__.extend(...)`. The current version only
allows
assignments, like upstream, as well as annotated and augmented
assignments,
unlike upstream.

I think when we discussed this previously, we considered flagging the
module
itself as containing code, but for now I followed the upstream
implementation of
flagging each statement in the module that breaks the rule (actually the
upstream linter flags each _line_, including comments). This will
obviously be a
bit noisier, emitting many diagnostics for the same module. But this
also seems
preferable because it flags every statement that needs to be fixed up
front
instead of only emitting one diagnostic for the whole file that persists
as you
keep removing more lines. It was also easy to implement in
`analyze::statement`
without a separate visitor.

The first commit adds the rule and baseline tests, the second commit
adds the
option and a diff test showing the additional diagnostics when the
setting is
enabled.

I noticed a small (~2%) performance regression on our largest benchmark,
so I also added a cached `Checker::in_init_module` field and method
instead of the `Checker::path` method. This was almost the only reason
for the `Checker::path` field at all, but there's one remaining
reference in a `warn_user!`
[call](https://github.com/astral-sh/ruff/blob/main/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs#L188).

Test Plan
--

New tests adapted from the upstream linter

## Ecosystem Report

I've spot-checked the ecosystem report, and the results look "correct."
This is obviously a very noisy rule if you do include code in
`__init__.py` files. We could make it less noisy by adding more
exceptions (e.g. allowing `if TYPE_CHECKING` blocks, allowing
`__getattr__` functions, allowing imports from `importlib` assignments),
but I'm sort of inclined just to start simple and see what users need.

[Discord]:
https://discord.com/channels/1039017663004942429/1082324250112823306/1440086001035771985

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-30 11:32:10 -05:00
Charlie Marsh
57218753be [ty] Narrow TypedDict literal access in match statements (#22299)
## Summary

Closes https://github.com/astral-sh/ty/issues/2279.
2025-12-30 11:29:09 -05:00
Brent Westbrook
2ada8b6634 Document options for more rules (#22295)
Summary
--

This is a follow up to #22198 documenting more rule options I found
while going
through all of our rules.

The second commit renames the internal
`flake8_gettext::Settings::functions_names` field to `function_names` to
match
the external configuration option. I guess this is technically breaking
because
it's exposed to users via `--show-settings`, but I don't think we
consider that
part of our stable API. I can definitely revert that if needed, though.

The other changes are just like #22198, adding new `## Options` sections
to
rules to document the settings they use. I missed these in the previous
PR
because they were used outside the rule implementations themselves. Most
of
these settings are checked where the rules' implementation functions are
called
instead.

Oh, the last commit also updates the removal date for
`typing.ByteString`, which
got pushed back in the 3.14 release. I snuck that in today since I never
opened
this PR last week.

I also fixed one reference link in RUF041.

Test Plan
--

Docs checks in CI
2025-12-30 08:44:11 -05:00
RasmusNygren
0edd97dd41 [ty] Add autocomplete suggestions for class arguments (#22110) 2025-12-30 13:10:56 +00:00
renovate[bot]
f8f4ca8fbc Update dependency react-resizable-panels to v4 (#22279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-30 11:04:25 +01:00
renovate[bot]
3d35dbd334 Update actions/upload-artifact digest to b7c566a (#22250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-30 09:07:58 +00:00
renovate[bot]
a652b411b8 Update NPM Development dependencies (#22289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-30 10:04:24 +01:00
RasmusNygren
4dac3d105d [ty] Add skip_dunders option to CompletionTestBuilder (#22293)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-12-30 08:10:14 +00:00
Shunsuke Shibayama
77ad107617 [ty] increase the max number of diagnostics for sympy in ty_walltime benchmark (#22296) 2025-12-30 13:20:19 +09:00
Charlie Marsh
b925ae5061 [ty] Avoid including property in subclasses properties (#22088)
## Summary

As-is, the following rejects `return self.value` in `def other` in the
subclass
([link](https://play.ty.dev/f55b47b2-313e-45d1-ba45-fde410bed32e))
because `self.value` is resolving to `Unknown | int | float | property`:

```python
class Base:
    _value: float = 0.0

    @property
    def value(self) -> float:
        return self._value

    @value.setter
    def value(self, v: float) -> None:
        self._value = v

    @property
    def other(self) -> float:
        return self.value

    @other.setter
    def other(self, v: float) -> None:
        self.value = v

class Derived(Base):
    @property
    def other(self) -> float:
        return self.value

    @other.setter
    def other(self, v: float) -> None:
        reveal_type(self.value)  # revealed: int | float
        self.value = v
```

I believe the root cause is that we're not excluding properties when
searching for class methods, so we're treating the `other` setter as a
classmethod. I don't fully understand how that ends up materializing as
`| property` on the union though.
2025-12-30 03:28:03 +00:00
Charlie Marsh
9333f15433 [ty] Fix match exhaustiveness for enum | None unions (#22290)
## Summary

If we match on an `TestEnum | None`, then when adding a case like
`~Literal[TestEnum.FOO]` (i.e., after `if value == TestEnum.FOO:
return`), we'd distribute `Literal[TestEnum.BAR]` on the entire builder,
creating `None & Literal[TestEnum.BAR]` which simplified to `Never`.
Instead, we should only expand to the remaining members for pieces of
the intersection that contain the enum.

Now, `(TestEnum | None) & ~Literal[TestEnum.FOO] &
~Literal[TestEnum.BAR]` correctly simplifies to `None` instead of
`Never`.

Closes https://github.com/astral-sh/ty/issues/2260.
2025-12-29 22:19:28 -05:00
Shunsuke Shibayama
c429ef8407 [ty] don't expand type aliases via type mappings unless necessary (#22241)
## Summary

`apply_type_mapping` always expands type aliases and operates on the
resulting types, which can lead to cluttered results due to excessive
type alias expansion in places where it is not actually needed.

Specifically, type aliases are expanded when displaying method
signatures, because we use `TypeMapping::BindSelf` to get the method
signature.

```python
type Scalar = int | float
type Array1d = list[Scalar] | tuple[Scalar]

def f(x: Scalar | Array1d) -> None: pass
reveal_type(f)  # revealed: def f(x: Scalar | Array1d) -> None

class Foo:
    def f(self, x: Scalar | Array1d) -> None: pass
# should be `bound method Foo.f(x: Scalar | Array1d) -> None`
reveal_type(Foo().f)  # revealed: bound method Foo.f(x: int | float | list[int | float] | tuple[int | float]) -> None
```

In this PR, when type mapping is performed on a type alias, the
expansion result without type mapping is compared with the expansion
result after type mapping, and if the two are equivalent, the expansion
is deemed redundant and canceled.

## Test Plan

mdtest updated
2025-12-29 19:02:56 -08:00
Eric Mark Martin
8716b4e230 [ty] implement typing.TypeGuard (#20974)
## Summary

Resolve(s) astral-sh/ty#117, astral-sh/ty#1569

Implement `typing.TypeGuard`. Due to the fact that it [overrides
anything previously known about the checked
value](https://typing.python.org/en/latest/spec/narrowing.html#typeguard)---

> When a conditional statement includes a call to a user-defined type
guard function, and that function returns true, the expression passed as
the first positional argument to the type guard function should be
assumed by a static type checker to take on the type specified in the
TypeGuard return type, unless and until it is further narrowed within
the conditional code block.

---we have to substantially rework the constraints system. In
particular, we make constraints represented as a disjunctive normal form
(DNF) where each term includes a regular constraint, and one or more
disjuncts with a typeguard constraint. Some test cases (including some
with more complex boolean logic) are added to `type_guards.md`.


## Test Plan

- update existing tests
- add new tests for more complex boolean logic with `TypeGuard`
- add new tests for `TypeGuard` variance

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
2025-12-29 17:54:17 -08:00
196 changed files with 8101 additions and 2816 deletions

View File

@@ -794,7 +794,7 @@ jobs:
echo '```console' > "$GITHUB_STEP_SUMMARY"
# Enable color output for pre-commit and remove it for the summary
# Use --hook-stage=manual to enable slower pre-commit hooks that are skipped by default
SKIP=cargo-fmt,clippy,dev-generate-all uvx --python="${PYTHON_VERSION}" pre-commit run --all-files --show-diff-on-failure --color=always --hook-stage=manual | \
SKIP=cargo-fmt uvx --python="${PYTHON_VERSION}" pre-commit run --all-files --show-diff-on-failure --color=always --hook-stage=manual | \
tee >(sed -E 's/\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[mGK]//g' >> "$GITHUB_STEP_SUMMARY") >&1
exit_code="${PIPESTATUS[0]}"
echo '```' >> "$GITHUB_STEP_SUMMARY"

View File

@@ -70,7 +70,7 @@ jobs:
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.2/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@@ -86,7 +86,7 @@ jobs:
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@@ -153,7 +153,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: artifacts-build-global
path: |
@@ -200,7 +200,7 @@ jobs:
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
# Overwrite the previous copy
name: artifacts-dist-manifest

View File

@@ -59,9 +59,6 @@ jobs:
- name: Compute diagnostic diff
shell: bash
env:
# TODO: Remove this once we fixed the remaining panics in the conformance suite.
TY_MAX_PARALLELISM: 1
run: |
RUFF_DIR="$GITHUB_WORKSPACE/ruff"

View File

@@ -135,6 +135,3 @@ repos:
rev: v0.11.0.1
hooks:
- id: shellcheck
ci:
skip: [cargo-fmt, dev-generate-all]

View File

@@ -125,7 +125,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -261,6 +261,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -127,7 +127,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -263,6 +263,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -129,7 +129,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -265,6 +265,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -129,7 +129,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -265,6 +265,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -126,7 +126,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -262,6 +262,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -126,7 +126,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -262,6 +262,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -125,7 +125,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -261,6 +261,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -125,7 +125,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -261,6 +261,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -125,7 +125,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -261,6 +261,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -238,7 +238,7 @@ linter.flake8_copyright.notice_rgx = (?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|
linter.flake8_copyright.author = none
linter.flake8_copyright.min_file_size = 0
linter.flake8_errmsg.max_string_length = 0
linter.flake8_gettext.functions_names = [
linter.flake8_gettext.function_names = [
_,
gettext,
ngettext,
@@ -374,6 +374,7 @@ linter.pylint.max_locals = 15
linter.pylint.max_nested_blocks = 5
linter.pyupgrade.keep_runtime_typing = false
linter.ruff.parenthesize_tuple_in_subscript = false
linter.ruff.strictly_empty_init_modules = false
# Formatter Settings
formatter.exclude = []

View File

@@ -194,7 +194,7 @@ static SYMPY: Benchmark = Benchmark::new(
max_dep_date: "2025-06-17",
python_version: PythonVersion::PY312,
},
13109,
13116,
);
static TANJUN: Benchmark = Benchmark::new(

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from airflow.lineage.hook import HookLineageCollector
# airflow.lineage.hook
hlc = HookLineageCollector()
hlc.create_asset("there")
hlc.create_asset("should", "be", "no", "posarg")
hlc.create_asset(name="but", uri="kwargs are ok")
hlc.create_asset()
HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
HookLineageCollector().create_asset("there")
HookLineageCollector().create_asset("should", "be", "no", "posarg")
args = ["uri_value"]
hlc.create_asset(*args)
HookLineageCollector().create_asset(*args)
# Literal unpacking
hlc.create_asset(*["literal_uri"])
HookLineageCollector().create_asset(*["literal_uri"])
# starred args with keyword args
hlc.create_asset(*args, extra="value")
HookLineageCollector().create_asset(*args, extra="value")
# Double-starred keyword arguments
kwargs = {"uri": "value", "name": "test"}
hlc.create_asset(**kwargs)
HookLineageCollector().create_asset(**kwargs)

View File

@@ -1,5 +1,5 @@
from abc import abstractmethod
from typing import overload, cast
from typing import overload, cast, TypeVar
from typing_extensions import override
@@ -256,3 +256,15 @@ class C:
"""Docstring."""
msg = t"{x}..."
raise NotImplementedError(msg)
###
# Unused arguments with `**kwargs`.
###
def f(
default: object = None, # noqa: ARG001
**kwargs: object,
) -> None:
TypeVar(**kwargs)

View File

@@ -53,3 +53,25 @@ for i in items:
items = [1, 2, 3, 4]
for i in items:
items[i]
# A case with multiple uses of the value to show off the secondary annotations
for instrument in ORCHESTRA:
data = json.dumps(
{
"instrument": instrument,
"section": ORCHESTRA[instrument],
}
)
print(f"saving data for {instrument} in {ORCHESTRA[instrument]}")
with open(f"{instrument}/{ORCHESTRA[instrument]}.txt", "w") as f:
f.write(data)
# This should still suppress the error
for ( # noqa: PLC0206
instrument
) in ORCHESTRA:
print(f"{instrument}: {ORCHESTRA[instrument]}")

View File

@@ -73,3 +73,7 @@ foo == 1 or foo == 1.0 # Different types, same hashed value
foo == False or foo == 0 # Different types, same hashed value
foo == 0.0 or foo == 0j # Different types, same hashed value
foo == "bar" or foo == "bar" # All members identical
foo == "bar" or foo == "bar" or foo == "buzz" # All but one members identical

View File

@@ -0,0 +1,51 @@
"""This is the module docstring."""
# convenience imports:
import os
from pathlib import Path
__all__ = ["MY_CONSTANT"]
__all__ += ["foo"]
__all__: list[str] = __all__
__all__ = __all__ = __all__
MY_CONSTANT = 5
"""This is an important constant."""
os.environ["FOO"] = 1
def foo():
return Path("foo.py")
def __getattr__(name): # ok
return name
__path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
if os.environ["FOO"] != "1": # RUF067
MY_CONSTANT = 4 # ok, don't flag nested statements
if TYPE_CHECKING: # ok
MY_CONSTANT = 3
import typing
if typing.TYPE_CHECKING: # ok
MY_CONSTANT = 2
__version__ = "1.2.3" # ok
def __dir__(): # ok
return ["foo"]
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__) # ok
__path__ = unknown.extend_path(__path__, __name__) # also ok
# non-`extend_path` assignments are not allowed
__path__ = 5 # RUF067
# also allow `__author__`
__author__ = "The Author" # ok

View File

@@ -0,0 +1,54 @@
"""
The code here is not in an `__init__.py` file and should not trigger the
lint.
"""
# convenience imports:
import os
from pathlib import Path
__all__ = ["MY_CONSTANT"]
__all__ += ["foo"]
__all__: list[str] = __all__
__all__ = __all__ = __all__
MY_CONSTANT = 5
"""This is an important constant."""
os.environ["FOO"] = 1
def foo():
return Path("foo.py")
def __getattr__(name): # ok
return name
__path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
if os.environ["FOO"] != "1": # RUF067
MY_CONSTANT = 4 # ok, don't flag nested statements
if TYPE_CHECKING: # ok
MY_CONSTANT = 3
import typing
if typing.TYPE_CHECKING: # ok
MY_CONSTANT = 2
__version__ = "1.2.3" # ok
def __dir__(): # ok
return ["foo"]
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__) # ok
__path__ = unknown.extend_path(__path__, __name__) # also ok
# non-`extend_path` assignments are not allowed
__path__ = 5 # RUF067
# also allow `__author__`
__author__ = "The Author" # ok

View File

@@ -1043,7 +1043,7 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
]) && flake8_gettext::is_gettext_func_call(
checker,
func,
&checker.settings().flake8_gettext.functions_names,
&checker.settings().flake8_gettext.function_names,
) {
if checker.is_rule_enabled(Rule::FStringInGetTextFuncCall) {
flake8_gettext::rules::f_string_in_gettext_func_call(checker, args);
@@ -1278,6 +1278,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::Airflow3SuggestedUpdate) {
airflow::rules::airflow_3_0_suggested_update_expr(checker, expr);
}
if checker.is_rule_enabled(Rule::Airflow3IncompatibleFunctionSignature) {
airflow::rules::airflow_3_incompatible_function_signature(checker, expr);
}
if checker.is_rule_enabled(Rule::UnnecessaryCastToInt) {
ruff::rules::unnecessary_cast_to_int(checker, call);
}

View File

@@ -1630,4 +1630,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
}
_ => {}
}
if checker.is_rule_enabled(Rule::NonEmptyInitModule) {
ruff::rules::non_empty_init_module(checker, stmt);
}
}

View File

@@ -33,7 +33,7 @@ pub(crate) fn unresolved_references(checker: &Checker) {
}
// Allow __path__.
if checker.path.ends_with("__init__.py") {
if checker.in_init_module() {
if reference.name(checker.source()) == "__path__" {
continue;
}

View File

@@ -21,7 +21,7 @@
//! represents the lint-rule analysis phase. In the future, these steps may be separated into
//! distinct passes over the AST.
use std::cell::RefCell;
use std::cell::{OnceCell, RefCell};
use std::path::Path;
use itertools::Itertools;
@@ -198,6 +198,8 @@ pub(crate) struct Checker<'a> {
parsed_type_annotation: Option<&'a ParsedAnnotation>,
/// The [`Path`] to the file under analysis.
path: &'a Path,
/// Whether `path` points to an `__init__.py` file.
in_init_module: OnceCell<bool>,
/// The [`Path`] to the package containing the current file.
package: Option<PackageRoot<'a>>,
/// The module representation of the current file (e.g., `foo.bar`).
@@ -274,6 +276,7 @@ impl<'a> Checker<'a> {
noqa_line_for,
noqa,
path,
in_init_module: OnceCell::new(),
package,
module,
source_type,
@@ -482,9 +485,11 @@ impl<'a> Checker<'a> {
self.context.settings
}
/// The [`Path`] to the file under analysis.
pub(crate) const fn path(&self) -> &'a Path {
self.path
/// Returns whether the file under analysis is an `__init__.py` file.
pub(crate) fn in_init_module(&self) -> bool {
*self
.in_init_module
.get_or_init(|| self.path.ends_with("__init__.py"))
}
/// The [`Path`] to the package containing the current file.
@@ -1873,6 +1878,9 @@ impl<'a> Visitor<'a> for Checker<'a> {
} else {
self.visit_non_type_definition(value);
}
} else {
// Ex: typing.TypeVar(**kwargs)
self.visit_non_type_definition(value);
}
}
}
@@ -3171,7 +3179,7 @@ impl<'a> Checker<'a> {
// F822
if self.is_rule_enabled(Rule::UndefinedExport) {
if is_undefined_export_in_dunder_init_enabled(self.settings())
|| !self.path.ends_with("__init__.py")
|| !self.in_init_module()
{
self.report_diagnostic(
pyflakes::rules::UndefinedExport {

View File

@@ -1060,6 +1060,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "064") => rules::ruff::rules::NonOctalPermissions,
(Ruff, "065") => rules::ruff::rules::LoggingEagerConversion,
(Ruff, "066") => rules::ruff::rules::PropertyWithoutReturn,
(Ruff, "067") => rules::ruff::rules::NonEmptyInitModule,
(Ruff, "100") => rules::ruff::rules::UnusedNOQA,
(Ruff, "101") => rules::ruff::rules::RedirectedNOQA,
@@ -1123,6 +1124,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Airflow, "002") => rules::airflow::rules::AirflowDagNoScheduleArgument,
(Airflow, "301") => rules::airflow::rules::Airflow3Removal,
(Airflow, "302") => rules::airflow::rules::Airflow3MovedToProvider,
(Airflow, "303") => rules::airflow::rules::Airflow3IncompatibleFunctionSignature,
(Airflow, "311") => rules::airflow::rules::Airflow3SuggestedUpdate,
(Airflow, "312") => rules::airflow::rules::Airflow3SuggestedToMoveToProvider,

View File

@@ -47,6 +47,7 @@ mod tests {
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_zendesk.py"))]
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_standard.py"))]
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_try.py"))]
#[test_case(Rule::Airflow3IncompatibleFunctionSignature, Path::new("AIR303.py"))]
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_args.py"))]
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_names.py"))]
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_try.py"))]

View File

@@ -0,0 +1,128 @@
use crate::checkers::ast::Checker;
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::{Arguments, Expr, ExprAttribute, ExprCall, Identifier};
use ruff_python_semantic::Modules;
use ruff_python_semantic::analyze::typing;
use ruff_text_size::Ranged;
/// ## What it does
/// Checks for Airflow function calls that will raise a runtime error in Airflow 3.0
/// due to function signature changes, such as functions that changed to accept only
/// keyword arguments, parameter reordering, or parameter type changes.
///
/// ## Why is this bad?
/// Airflow 3.0 introduces changes to function signatures. Code that
/// worked in Airflow 2.x will raise a runtime error if not updated in Airflow
/// 3.0.
///
/// ## Example
/// ```python
/// from airflow.lineage.hook import HookLineageCollector
///
/// collector = HookLineageCollector()
/// # Passing positional arguments will raise a runtime error in Airflow 3.0
/// collector.create_asset("s3://bucket/key")
/// ```
///
/// Use instead:
/// ```python
/// from airflow.lineage.hook import HookLineageCollector
///
/// collector = HookLineageCollector()
/// # Passing arguments as keyword arguments instead of positional arguments
/// collector.create_asset(uri="s3://bucket/key")
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.11")]
pub(crate) struct Airflow3IncompatibleFunctionSignature {
function_name: String,
change_type: FunctionSignatureChangeType,
}
impl Violation for Airflow3IncompatibleFunctionSignature {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
#[derive_message_formats]
fn message(&self) -> String {
let Airflow3IncompatibleFunctionSignature {
function_name,
change_type,
} = self;
match change_type {
FunctionSignatureChangeType::KeywordOnly { .. } => {
format!("`{function_name}` signature is changed in Airflow 3.0")
}
}
}
fn fix_title(&self) -> Option<String> {
let Airflow3IncompatibleFunctionSignature { change_type, .. } = self;
match change_type {
FunctionSignatureChangeType::KeywordOnly { message } => Some(message.to_string()),
}
}
}
/// AIR303
pub(crate) fn airflow_3_incompatible_function_signature(checker: &Checker, expr: &Expr) {
if !checker.semantic().seen_module(Modules::AIRFLOW) {
return;
}
let Expr::Call(ExprCall {
func, arguments, ..
}) = expr
else {
return;
};
let Expr::Attribute(ExprAttribute { attr, value, .. }) = func.as_ref() else {
return;
};
// Resolve the qualified name: try variable assignments first, then fall back to direct
// constructor calls.
let qualified_name = typing::resolve_assignment(value, checker.semantic()).or_else(|| {
value
.as_call_expr()
.and_then(|call| checker.semantic().resolve_qualified_name(&call.func))
});
let Some(qualified_name) = qualified_name else {
return;
};
check_keyword_only_method(checker, &qualified_name, attr, arguments);
}
fn check_keyword_only_method(
checker: &Checker,
qualified_name: &QualifiedName,
attr: &Identifier,
arguments: &Arguments,
) {
let has_positional_args =
arguments.find_positional(0).is_some() || arguments.args.iter().any(Expr::is_starred_expr);
if let ["airflow", "lineage", "hook", "HookLineageCollector"] = qualified_name.segments() {
if attr.as_str() == "create_asset" && has_positional_args {
checker.report_diagnostic(
Airflow3IncompatibleFunctionSignature {
function_name: attr.to_string(),
change_type: FunctionSignatureChangeType::KeywordOnly {
message: "Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)",
},
},
attr.range(),
);
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum FunctionSignatureChangeType {
/// Function signature changed to only accept keyword arguments.
KeywordOnly { message: &'static str },
}

View File

@@ -1,4 +1,5 @@
pub(crate) use dag_schedule_argument::*;
pub(crate) use function_signature_change_in_3::*;
pub(crate) use moved_to_provider_in_3::*;
pub(crate) use removal_in_3::*;
pub(crate) use suggested_to_move_to_provider_in_3::*;
@@ -6,6 +7,7 @@ pub(crate) use suggested_to_update_3_0::*;
pub(crate) use task_variable_name::*;
mod dag_schedule_argument;
mod function_signature_change_in_3;
mod moved_to_provider_in_3;
mod removal_in_3;
mod suggested_to_move_to_provider_in_3;

View File

@@ -0,0 +1,114 @@
---
source: crates/ruff_linter/src/rules/airflow/mod.rs
---
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:7:5
|
5 | # airflow.lineage.hook
6 | hlc = HookLineageCollector()
7 | hlc.create_asset("there")
| ^^^^^^^^^^^^
8 | hlc.create_asset("should", "be", "no", "posarg")
9 | hlc.create_asset(name="but", uri="kwargs are ok")
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:8:5
|
6 | hlc = HookLineageCollector()
7 | hlc.create_asset("there")
8 | hlc.create_asset("should", "be", "no", "posarg")
| ^^^^^^^^^^^^
9 | hlc.create_asset(name="but", uri="kwargs are ok")
10 | hlc.create_asset()
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:13:24
|
12 | HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
13 | HookLineageCollector().create_asset("there")
| ^^^^^^^^^^^^
14 | HookLineageCollector().create_asset("should", "be", "no", "posarg")
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:14:24
|
12 | HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
13 | HookLineageCollector().create_asset("there")
14 | HookLineageCollector().create_asset("should", "be", "no", "posarg")
| ^^^^^^^^^^^^
15 |
16 | args = ["uri_value"]
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:17:5
|
16 | args = ["uri_value"]
17 | hlc.create_asset(*args)
| ^^^^^^^^^^^^
18 | HookLineageCollector().create_asset(*args)
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:18:24
|
16 | args = ["uri_value"]
17 | hlc.create_asset(*args)
18 | HookLineageCollector().create_asset(*args)
| ^^^^^^^^^^^^
19 |
20 | # Literal unpacking
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:21:5
|
20 | # Literal unpacking
21 | hlc.create_asset(*["literal_uri"])
| ^^^^^^^^^^^^
22 | HookLineageCollector().create_asset(*["literal_uri"])
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:22:24
|
20 | # Literal unpacking
21 | hlc.create_asset(*["literal_uri"])
22 | HookLineageCollector().create_asset(*["literal_uri"])
| ^^^^^^^^^^^^
23 |
24 | # starred args with keyword args
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:25:5
|
24 | # starred args with keyword args
25 | hlc.create_asset(*args, extra="value")
| ^^^^^^^^^^^^
26 | HookLineageCollector().create_asset(*args, extra="value")
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
AIR303 `create_asset` signature is changed in Airflow 3.0
--> AIR303.py:26:24
|
24 | # starred args with keyword args
25 | hlc.create_asset(*args, extra="value")
26 | HookLineageCollector().create_asset(*args, extra="value")
| ^^^^^^^^^^^^
27 |
28 | # Double-starred keyword arguments
|
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)

View File

@@ -38,6 +38,10 @@ use crate::checkers::ast::Checker;
/// _("Hello, %s!") % name # Looks for "Hello, %s!".
/// ```
///
/// ## Options
///
/// - `lint.flake8-gettext.function-names`
///
/// ## References
/// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html)
#[derive(ViolationMetadata)]

View File

@@ -38,6 +38,10 @@ use crate::checkers::ast::Checker;
/// _("Hello, %s!") % name # Looks for "Hello, %s!".
/// ```
///
/// ## Options
///
/// - `lint.flake8-gettext.function-names`
///
/// ## References
/// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html)
#[derive(ViolationMetadata)]

View File

@@ -37,6 +37,10 @@ use crate::checkers::ast::Checker;
/// _("Hello, %s!") % name # Looks for "Hello, %s!".
/// ```
///
/// ## Options
///
/// - `lint.flake8-gettext.function-names`
///
/// ## References
/// - [Python documentation: `gettext` — Multilingual internationalization services](https://docs.python.org/3/library/gettext.html)
#[derive(ViolationMetadata)]

View File

@@ -5,7 +5,7 @@ use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, CacheKey)]
pub struct Settings {
pub functions_names: Vec<Name>,
pub function_names: Vec<Name>,
}
pub fn default_func_names() -> Vec<Name> {
@@ -19,7 +19,7 @@ pub fn default_func_names() -> Vec<Name> {
impl Default for Settings {
fn default() -> Self {
Self {
functions_names: default_func_names(),
function_names: default_func_names(),
}
}
}
@@ -30,7 +30,7 @@ impl Display for Settings {
formatter = f,
namespace = "linter.flake8_gettext",
fields = [
self.functions_names | array
self.function_names | array
]
}
Ok(())

View File

@@ -32,6 +32,13 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// "dog"
/// )
/// ```
///
/// ## Options
///
/// Setting `lint.flake8-implicit-str-concat.allow-multiline = false` will disable this rule because
/// it would leave no allowed way to write a multi-line string.
///
/// - `lint.flake8-implicit-str-concat.allow-multiline`
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "v0.0.201")]
pub(crate) struct ExplicitStringConcatenation;

View File

@@ -11,7 +11,7 @@ use crate::{FixAvailability, Violation};
///
/// ## Why is this bad?
/// `ByteString` has been deprecated since Python 3.9 and will be removed in
/// Python 3.14. The Python documentation recommends using either
/// Python 3.17. The Python documentation recommends using either
/// `collections.abc.Buffer` (or the `typing_extensions` backport
/// on Python <3.12) or a union like `bytes | bytearray | memoryview` instead.
///

View File

@@ -36,6 +36,10 @@ use crate::rules::pep8_naming::settings::IgnoreNames;
/// - Instead of `example-module-name` or `example module name`, use `example_module_name`.
/// - Instead of `ExampleModule`, use `example_module`.
///
/// ## Options
///
/// - `lint.pep8-naming.ignore-names`
///
/// [PEP 8]: https://peps.python.org/pep-0008/#package-and-module-names
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "v0.0.248")]

View File

@@ -58,6 +58,11 @@ use crate::rules::pydocstyle::settings::Convention;
/// """
/// return distance / time
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.1")]
pub(crate) struct DocstringExtraneousParameter {
@@ -113,6 +118,12 @@ impl Violation for DocstringExtraneousParameter {
/// """
/// return distance / time
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
/// - `lint.pydocstyle.property-decorators`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.6")]
pub(crate) struct DocstringMissingReturns;
@@ -165,6 +176,11 @@ impl Violation for DocstringMissingReturns {
/// for _ in range(n):
/// print("Hello!")
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.6")]
pub(crate) struct DocstringExtraneousReturns;
@@ -218,6 +234,11 @@ impl Violation for DocstringExtraneousReturns {
/// for i in range(1, n + 1):
/// yield i
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.7")]
pub(crate) struct DocstringMissingYields;
@@ -270,6 +291,11 @@ impl Violation for DocstringMissingYields {
/// for _ in range(n):
/// print("Hello!")
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.7")]
pub(crate) struct DocstringExtraneousYields;
@@ -342,6 +368,11 @@ impl Violation for DocstringExtraneousYields {
/// except ZeroDivisionError as exc:
/// raise FasterThanLightError from exc
/// ```
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.5")]
pub(crate) struct DocstringMissingException {
@@ -410,6 +441,11 @@ impl Violation for DocstringMissingException {
/// It may often be desirable to document *all* exceptions that a function
/// could possibly raise, even those which are not explicitly raised using
/// `raise` statements in the function body.
///
/// ## Options
///
/// - `lint.pydoclint.ignore-one-line-docstrings`
/// - `lint.pydocstyle.convention`
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.5.5")]
pub(crate) struct DocstringExtraneousException {

View File

@@ -38,6 +38,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// foobar.__doc__ # "Docstring for foo\bar."
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [Python documentation: String and Bytes literals](https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals)

View File

@@ -34,6 +34,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// """
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -33,6 +33,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// """Return the mean of the given values."""
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)
@@ -80,6 +84,10 @@ impl Violation for BlankLineBeforeFunction {
/// return sum(values) / len(values)
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -25,6 +25,10 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
/// """Return the mean of the given values."""
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -63,6 +63,10 @@ use crate::docstrings::Docstring;
/// factorial.__doc__ # "Return the factorial of n."
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [Python documentation: `typing.overload`](https://docs.python.org/3/library/typing.html#typing.overload)

View File

@@ -44,6 +44,10 @@ use crate::{Edit, Fix};
/// The rule is also incompatible with the [formatter] when using
/// `format.indent-style="tab"`.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)
@@ -93,6 +97,10 @@ impl Violation for DocstringTabIndentation {
/// We recommend against using this rule alongside the [formatter]. The
/// formatter enforces consistent indentation, making the rule redundant.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)
@@ -146,6 +154,10 @@ impl AlwaysFixableViolation for UnderIndentation {
/// We recommend against using this rule alongside the [formatter]. The
/// formatter enforces consistent indentation, making the rule redundant.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -37,6 +37,10 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
/// """
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -27,6 +27,10 @@ use crate::rules::pydocstyle::helpers::ends_with_backslash;
/// """Return the factorial of n."""
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -24,6 +24,10 @@ use crate::docstrings::Docstring;
/// """Return the mean of the given values."""
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -55,6 +55,10 @@ use crate::checkers::ast::Checker;
/// ## Notebook behavior
/// This rule is ignored for Jupyter Notebooks.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)
@@ -139,6 +143,10 @@ impl Violation for UndocumentedPublicModule {
/// self.points += points
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)
@@ -366,6 +374,10 @@ impl Violation for UndocumentedPublicFunction {
/// __all__ = ["player", "game"]
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)
@@ -480,6 +492,10 @@ impl Violation for UndocumentedMagicMethod {
/// bar.__doc__ # "Class Bar."
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)

View File

@@ -32,6 +32,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// documentation generators, or custom introspection utilities that rely on
/// specific docstring formatting.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
///

View File

@@ -1066,6 +1066,10 @@ impl AlwaysFixableViolation for MissingBlankLineAfterLastSection {
/// raise FasterThanLightError from exc
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)
@@ -1317,6 +1321,10 @@ impl Violation for UndocumentedParam {
/// raise FasterThanLightError from exc
/// ```
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [PEP 287 reStructuredText Docstring Format](https://peps.python.org/pep-0287/)

View File

@@ -31,6 +31,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// We recommend against using this rule alongside the [formatter]. The
/// formatter enforces consistent quotes, making the rule redundant.
///
/// ## Options
///
/// - `lint.pydocstyle.ignore-decorators`
///
/// ## References
/// - [PEP 257 Docstring Conventions](https://peps.python.org/pep-0257/)
/// - [NumPy Style Guide](https://numpydoc.readthedocs.io/en/latest/format.html)

View File

@@ -389,7 +389,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope) {
}
}
let in_init = checker.path().ends_with("__init__.py");
let in_init = checker.in_init_module();
let fix_init = !checker.settings().ignore_init_module_imports;
let preview_mode = is_dunder_init_fix_unused_import_enabled(checker.settings());
let dunder_all_exprs = find_dunder_all_exprs(checker.semantic());

View File

@@ -1,16 +1,17 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::comparable::ComparableExpr;
use ruff_python_ast::{
self as ast, Expr, ExprContext,
self as ast, Expr, ExprContext, StmtFor,
token::parenthesized_range,
visitor::{self, Visitor},
};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::type_inference::{PythonType, ResolvedPythonType};
use ruff_python_semantic::analyze::typing::is_dict;
use ruff_text_size::Ranged;
use ruff_text_size::{Ranged, TextRange};
use crate::Violation;
use crate::checkers::ast::Checker;
use crate::checkers::ast::{Checker, DiagnosticGuard};
/// ## What it does
/// Checks for dictionary iterations that extract the dictionary value
@@ -47,20 +48,26 @@ use crate::checkers::ast::Checker;
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "0.8.0")]
pub(crate) struct DictIndexMissingItems;
pub(crate) struct DictIndexMissingItems<'a> {
key: &'a str,
dict: &'a str,
}
impl Violation for DictIndexMissingItems {
impl Violation for DictIndexMissingItems<'_> {
#[derive_message_formats]
fn message(&self) -> String {
"Extracting value from dictionary without calling `.items()`".to_string()
}
fn fix_title(&self) -> Option<String> {
let Self { key, dict } = self;
Some(format!("Use `for {key}, value in {dict}.items()` instead"))
}
}
/// PLC0206
pub(crate) fn dict_index_missing_items(checker: &Checker, stmt_for: &ast::StmtFor) {
let ast::StmtFor {
target, iter, body, ..
} = stmt_for;
pub(crate) fn dict_index_missing_items(checker: &Checker, stmt_for: &StmtFor) {
let StmtFor { iter, body, .. } = stmt_for;
// Extract the name of the iteration object (e.g., `obj` in `for key in obj:`).
let Some(dict_name) = extract_dict_name(iter) else {
@@ -77,40 +84,46 @@ pub(crate) fn dict_index_missing_items(checker: &Checker, stmt_for: &ast::StmtFo
return;
}
let has_violation = {
let mut visitor = SubscriptVisitor::new(target, dict_name);
for stmt in body {
visitor.visit_stmt(stmt);
}
visitor.has_violation
};
if has_violation {
checker.report_diagnostic(DictIndexMissingItems, stmt_for.range());
}
SubscriptVisitor::new(stmt_for, dict_name, checker).visit_body(body);
}
/// A visitor to detect subscript operations on a target dictionary.
struct SubscriptVisitor<'a> {
struct SubscriptVisitor<'a, 'b> {
/// The target of the for loop (e.g., `key` in `for key in obj:`).
target: &'a Expr,
/// The name of the iterated object (e.g., `obj` in `for key in obj:`).
dict_name: &'a ast::ExprName,
/// Whether a violation has been detected.
has_violation: bool,
/// The range to use for the primary diagnostic.
range: TextRange,
/// The [`Checker`] used to emit diagnostics.
checker: &'a Checker<'b>,
/// The [`DiagnosticGuard`] used to attach additional annotations for each subscript.
///
/// The guard is initially `None` and then set to `Some` when the first subscript is found.
guard: Option<DiagnosticGuard<'a, 'b>>,
}
impl<'a> SubscriptVisitor<'a> {
fn new(target: &'a Expr, dict_name: &'a ast::ExprName) -> Self {
impl<'a, 'b> SubscriptVisitor<'a, 'b> {
fn new(stmt_for: &'a StmtFor, dict_name: &'a ast::ExprName, checker: &'a Checker<'b>) -> Self {
let StmtFor { target, iter, .. } = stmt_for;
let range = {
let target_start =
parenthesized_range(target.into(), stmt_for.into(), checker.tokens())
.map_or(target.start(), TextRange::start);
TextRange::new(target_start, iter.end())
};
Self {
target,
dict_name,
has_violation: false,
range,
checker,
guard: None,
}
}
}
impl<'a> Visitor<'a> for SubscriptVisitor<'a> {
impl<'a> Visitor<'a> for SubscriptVisitor<'a, '_> {
fn visit_expr(&mut self, expr: &'a Expr) {
// Given `obj[key]`, `value` must be `obj` and `slice` must be `key`.
if let Expr::Subscript(ast::ExprSubscript {
@@ -134,7 +147,17 @@ impl<'a> Visitor<'a> for SubscriptVisitor<'a> {
return;
}
self.has_violation = true;
let guard = self.guard.get_or_insert_with(|| {
self.checker.report_diagnostic(
DictIndexMissingItems {
key: self.checker.locator().slice(self.target),
dict: self.checker.locator().slice(self.dict_name),
},
self.range,
)
});
guard.secondary_annotation("", expr);
} else {
visitor::walk_expr(self, expr);
}

View File

@@ -140,6 +140,18 @@ pub(crate) fn repeated_equality_comparison(checker: &Checker, bool_op: &ast::Exp
continue;
}
if let Some((&first, rest)) = comparators.split_first() {
let first_comparable = ComparableExpr::from(first);
if rest
.iter()
.all(|&c| ComparableExpr::from(c) == first_comparable)
{
// Do not flag if all members are identical
continue;
}
}
// if we can determine that all the values are hashable, we can use a set
// TODO: improve with type inference
let all_hashable = comparators

View File

@@ -77,7 +77,7 @@ pub(crate) fn useless_import_alias(checker: &Checker, alias: &Alias) {
}
// A re-export in __init__.py is probably intentional.
if checker.path().ends_with("__init__.py") {
if checker.in_init_module() {
return;
}
@@ -116,7 +116,7 @@ pub(crate) fn useless_import_from_alias(
}
// A re-export in __init__.py is probably intentional.
if checker.path().ends_with("__init__.py") {
if checker.in_init_module() {
return;
}

View File

@@ -2,72 +2,107 @@
source: crates/ruff_linter/src/rules/pylint/mod.rs
---
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:9:1
--> dict_index_missing_items.py:9:5
|
8 | # Errors
9 | / for instrument in ORCHESTRA:
10 | | print(f"{instrument}: {ORCHESTRA[instrument]}")
| |___________________________________________________^
8 | # Errors
9 | for instrument in ORCHESTRA:
| ^^^^^^^^^^^^^^^^^^^^^^^
10 | print(f"{instrument}: {ORCHESTRA[instrument]}")
| ---------------------
11 |
12 | for instrument in ORCHESTRA:
12 | for instrument in ORCHESTRA:
|
help: Use `for instrument, value in ORCHESTRA.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:12:1
--> dict_index_missing_items.py:12:5
|
10 | print(f"{instrument}: {ORCHESTRA[instrument]}")
10 | print(f"{instrument}: {ORCHESTRA[instrument]}")
11 |
12 | / for instrument in ORCHESTRA:
13 | | ORCHESTRA[instrument]
| |_________________________^
12 | for instrument in ORCHESTRA:
| ^^^^^^^^^^^^^^^^^^^^^^^
13 | ORCHESTRA[instrument]
| ---------------------
14 |
15 | for instrument in ORCHESTRA.keys():
15 | for instrument in ORCHESTRA.keys():
|
help: Use `for instrument, value in ORCHESTRA.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:15:1
--> dict_index_missing_items.py:15:5
|
13 | ORCHESTRA[instrument]
13 | ORCHESTRA[instrument]
14 |
15 | / for instrument in ORCHESTRA.keys():
16 | | print(f"{instrument}: {ORCHESTRA[instrument]}")
| |___________________________________________________^
15 | for instrument in ORCHESTRA.keys():
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
16 | print(f"{instrument}: {ORCHESTRA[instrument]}")
| ---------------------
17 |
18 | for instrument in ORCHESTRA.keys():
18 | for instrument in ORCHESTRA.keys():
|
help: Use `for instrument, value in ORCHESTRA.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:18:1
--> dict_index_missing_items.py:18:5
|
16 | print(f"{instrument}: {ORCHESTRA[instrument]}")
16 | print(f"{instrument}: {ORCHESTRA[instrument]}")
17 |
18 | / for instrument in ORCHESTRA.keys():
19 | | ORCHESTRA[instrument]
| |_________________________^
18 | for instrument in ORCHESTRA.keys():
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
19 | ORCHESTRA[instrument]
| ---------------------
20 |
21 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
21 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
|
help: Use `for instrument, value in ORCHESTRA.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:21:1
--> dict_index_missing_items.py:21:5
|
19 | ORCHESTRA[instrument]
19 | ORCHESTRA[instrument]
20 |
21 | / for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
22 | | print(f"{instrument}: {temp_orchestra[instrument]}")
| |________________________________________________________^
21 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
22 | print(f"{instrument}: {temp_orchestra[instrument]}")
| --------------------------
23 |
24 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
24 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
|
help: Use `for instrument, value in temp_orchestra.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:24:1
--> dict_index_missing_items.py:24:5
|
22 | print(f"{instrument}: {temp_orchestra[instrument]}")
22 | print(f"{instrument}: {temp_orchestra[instrument]}")
23 |
24 | / for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
25 | | temp_orchestra[instrument]
| |______________________________^
24 | for instrument in (temp_orchestra := {"violin": "strings", "oboe": "woodwind"}):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
25 | temp_orchestra[instrument]
| --------------------------
26 |
27 | # # OK
27 | # # OK
|
help: Use `for instrument, value in temp_orchestra.items()` instead
PLC0206 Extracting value from dictionary without calling `.items()`
--> dict_index_missing_items.py:59:5
|
58 | # A case with multiple uses of the value to show off the secondary annotations
59 | for instrument in ORCHESTRA:
| ^^^^^^^^^^^^^^^^^^^^^^^
60 | data = json.dumps(
61 | {
62 | "instrument": instrument,
63 | "section": ORCHESTRA[instrument],
| ---------------------
64 | }
65 | )
66 |
67 | print(f"saving data for {instrument} in {ORCHESTRA[instrument]}")
| ---------------------
68 |
69 | with open(f"{instrument}/{ORCHESTRA[instrument]}.txt", "w") as f:
| ---------------------
70 | f.write(data)
|
help: Use `for instrument, value in ORCHESTRA.items()` instead

View File

@@ -436,6 +436,7 @@ help: Merge multiple comparisons
73 + foo in {False, 0} # Different types, same hashed value
74 |
75 | foo == 0.0 or foo == 0j # Different types, same hashed value
76 |
note: This is an unsafe fix and may change runtime behavior
PLR1714 [*] Consider merging multiple comparisons: `foo in {0.0, 0j}`.
@@ -445,6 +446,8 @@ PLR1714 [*] Consider merging multiple comparisons: `foo in {0.0, 0j}`.
74 |
75 | foo == 0.0 or foo == 0j # Different types, same hashed value
| ^^^^^^^^^^^^^^^^^^^^^^^
76 |
77 | foo == "bar" or foo == "bar" # All members identical
|
help: Merge multiple comparisons
72 |
@@ -452,4 +455,23 @@ help: Merge multiple comparisons
74 |
- foo == 0.0 or foo == 0j # Different types, same hashed value
75 + foo in {0.0, 0j} # Different types, same hashed value
76 |
77 | foo == "bar" or foo == "bar" # All members identical
78 |
note: This is an unsafe fix and may change runtime behavior
PLR1714 [*] Consider merging multiple comparisons: `foo in {"bar", "bar", "buzz"}`.
--> repeated_equality_comparison.py:79:1
|
77 | foo == "bar" or foo == "bar" # All members identical
78 |
79 | foo == "bar" or foo == "bar" or foo == "buzz" # All but one members identical
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Merge multiple comparisons
76 |
77 | foo == "bar" or foo == "bar" # All members identical
78 |
- foo == "bar" or foo == "bar" or foo == "buzz" # All but one members identical
79 + foo in {"bar", "bar", "buzz"} # All but one members identical
note: This is an unsafe fix and may change runtime behavior

View File

@@ -34,6 +34,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// different values when introspecting types at runtime. However, in most cases,
/// the fix should be safe to apply.
///
/// ## Options
///
/// - `target-version`
///
/// [PEP 646]: https://peps.python.org/pep-0646/
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "0.10.0")]

View File

@@ -78,6 +78,10 @@ use super::{
/// This rule only applies to generic classes and does not include generic functions. See
/// [`non-pep695-generic-function`][UP047] for the function version.
///
/// ## Options
///
/// - `target-version`
///
/// [PEP 695]: https://peps.python.org/pep-0695/
/// [PEP 696]: https://peps.python.org/pep-0696/
/// [PYI018]: https://docs.astral.sh/ruff/rules/unused-private-type-var/

View File

@@ -71,6 +71,10 @@ use super::{DisplayTypeVars, TypeVarReferenceVisitor, check_type_vars, in_nested
/// This rule only applies to generic functions and does not include generic classes. See
/// [`non-pep695-generic-class`][UP046] for the class version.
///
/// ## Options
///
/// - `target-version`
///
/// [PEP 695]: https://peps.python.org/pep-0695/
/// [PEP 696]: https://peps.python.org/pep-0696/
/// [PYI018]: https://docs.astral.sh/ruff/rules/unused-private-type-var/

View File

@@ -78,6 +78,10 @@ use super::{
/// new type parameters are restricted in scope to their associated aliases. See
/// [`private-type-parameter`][UP049] for a rule to update these names.
///
/// ## Options
///
/// - `target-version`
///
/// [PEP 695]: https://peps.python.org/pep-0695/
/// [PYI018]: https://docs.astral.sh/ruff/rules/unused-private-type-var/
/// [UP046]: https://docs.astral.sh/ruff/rules/non-pep695-generic-class/

View File

@@ -72,6 +72,10 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// As such, migrating to `enum.StrEnum` will introduce a behavior change for
/// code that relies on the Python 3.11 behavior.
///
/// ## Options
///
/// - `target-version`
///
/// ## References
/// - [enum.StrEnum](https://docs.python.org/3/library/enum.html#enum.StrEnum)
///

View File

@@ -119,6 +119,8 @@ mod tests {
#[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"))]
#[test_case(Rule::NonEmptyInitModule, Path::new("RUF067/modules/__init__.py"))]
#[test_case(Rule::NonEmptyInitModule, Path::new("RUF067/modules/okay.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
let diagnostics = test_path(
@@ -136,6 +138,7 @@ mod tests {
&LinterSettings {
ruff: super::settings::Settings {
parenthesize_tuple_in_subscript: true,
..super::settings::Settings::default()
},
..LinterSettings::for_rule(Rule::IncorrectlyParenthesizedTupleInSubscript)
},
@@ -151,6 +154,7 @@ mod tests {
&LinterSettings {
ruff: super::settings::Settings {
parenthesize_tuple_in_subscript: false,
..super::settings::Settings::default()
},
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(Rule::IncorrectlyParenthesizedTupleInSubscript)
@@ -714,4 +718,26 @@ mod tests {
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test]
fn strictly_empty_init_modules_ruf067() -> Result<()> {
assert_diagnostics_diff!(
Path::new("ruff/RUF067/modules/__init__.py"),
&LinterSettings {
ruff: super::settings::Settings {
strictly_empty_init_modules: false,
..super::settings::Settings::default()
},
..LinterSettings::for_rule(Rule::NonEmptyInitModule)
},
&LinterSettings {
ruff: super::settings::Settings {
strictly_empty_init_modules: true,
..super::settings::Settings::default()
},
..LinterSettings::for_rule(Rule::NonEmptyInitModule)
},
);
Ok(())
}
}

View File

@@ -32,6 +32,7 @@ pub(crate) use mutable_dataclass_default::*;
pub(crate) use mutable_fromkeys_value::*;
pub(crate) use needless_else::*;
pub(crate) use never_union::*;
pub(crate) use non_empty_init_module::*;
pub(crate) use non_octal_permissions::*;
pub(crate) use none_not_at_end_of_union::*;
pub(crate) use parenthesize_chained_operators::*;
@@ -99,6 +100,7 @@ mod mutable_dataclass_default;
mod mutable_fromkeys_value;
mod needless_else;
mod never_union;
mod non_empty_init_module;
mod non_octal_permissions;
mod none_not_at_end_of_union;
mod parenthesize_chained_operators;

View File

@@ -0,0 +1,259 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_python_semantic::analyze::typing::is_type_checking_block;
use ruff_text_size::Ranged;
use crate::{Violation, checkers::ast::Checker};
/// ## What it does
///
/// Detects the presence of code in `__init__.py` files.
///
/// ## Why is this bad?
///
/// `__init__.py` files are often empty or only contain simple code to modify a module's API. As
/// such, it's easy to overlook them and their possible side effects when debugging.
///
/// ## Example
///
/// Instead of defining `MyClass` directly in `__init__.py`:
///
/// ```python
/// """My module docstring."""
///
///
/// class MyClass:
/// def my_method(self): ...
/// ```
///
/// move the definition to another file, import it, and include it in `__all__`:
///
/// ```python
/// """My module docstring."""
///
/// from submodule import MyClass
///
/// __all__ = ["MyClass"]
/// ```
///
/// Code in `__init__.py` files is also run at import time and can cause surprising slowdowns. To
/// disallow any code in `__init__.py` files, you can enable the
/// [`lint.ruff.strictly-empty-init-modules`] setting. In this case:
///
/// ```python
/// from submodule import MyClass
///
/// __all__ = ["MyClass"]
/// ```
///
/// the only fix is entirely emptying the file:
///
/// ```python
/// ```
///
/// ## Details
///
/// In non-strict mode, this rule allows several common patterns in `__init__.py` files:
///
/// - Imports
/// - Assignments to `__all__`, `__path__`, `__version__`, and `__author__`
/// - Module-level and attribute docstrings
/// - `if TYPE_CHECKING` blocks
/// - [PEP-562] module-level `__getattr__` and `__dir__` functions
///
/// ## Options
///
/// - [`lint.ruff.strictly-empty-init-modules`]
///
/// ## References
///
/// - [`flake8-empty-init-modules`](https://github.com/samueljsb/flake8-empty-init-modules/)
///
/// [PEP-562]: https://peps.python.org/pep-0562/
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "0.14.11")]
pub(crate) struct NonEmptyInitModule {
strictly_empty_init_modules: bool,
}
impl Violation for NonEmptyInitModule {
#[derive_message_formats]
fn message(&self) -> String {
if self.strictly_empty_init_modules {
"`__init__` module should not contain any code".to_string()
} else {
"`__init__` module should only contain docstrings and re-exports".to_string()
}
}
}
/// RUF067
pub(crate) fn non_empty_init_module(checker: &Checker, stmt: &Stmt) {
if !checker.in_init_module() {
return;
}
let semantic = checker.semantic();
// Only flag top-level statements
if !semantic.at_top_level() {
return;
}
let strictly_empty_init_modules = checker.settings().ruff.strictly_empty_init_modules;
if !strictly_empty_init_modules {
// Even though module-level attributes are disallowed, we still allow attribute docstrings
// to avoid needing two `noqa` comments in a case like:
//
// ```py
// MY_CONSTANT = 1 # noqa: RUF067
// "A very important constant"
// ```
if semantic.in_pep_257_docstring() || semantic.in_attribute_docstring() {
return;
}
match stmt {
// Allow imports
Stmt::Import(_) | Stmt::ImportFrom(_) => return,
// Allow PEP-562 module `__getattr__` and `__dir__`
Stmt::FunctionDef(func) if matches!(&*func.name, "__getattr__" | "__dir__") => return,
// Allow `TYPE_CHECKING` blocks
Stmt::If(stmt_if) if is_type_checking_block(stmt_if, semantic) => return,
_ => {}
}
if let Some(assignment) = Assignment::from_stmt(stmt) {
// Allow assignments to `__all__`.
//
// TODO(brent) should we allow additional cases here? Beyond simple assignments, you could
// also append or extend `__all__`.
//
// This is actually going slightly beyond the upstream rule already, which only checks for
// `Stmt::Assign`.
if assignment.is_assignment_to("__all__") {
return;
}
// Allow legacy namespace packages with assignments like:
//
// ```py
// __path__ = __import__('pkgutil').extend_path(__path__, __name__)
// ```
if assignment.is_assignment_to("__path__") && assignment.is_pkgutil_extend_path() {
return;
}
// Allow assignments to `__version__`.
if assignment.is_assignment_to("__version__") {
return;
}
// Allow assignments to `__author__`.
if assignment.is_assignment_to("__author__") {
return;
}
}
}
checker.report_diagnostic(
NonEmptyInitModule {
strictly_empty_init_modules,
},
stmt.range(),
);
}
/// Any assignment statement, including plain assignment, annotated assignments, and augmented
/// assignments.
struct Assignment<'a> {
targets: &'a [Expr],
value: Option<&'a Expr>,
}
impl<'a> Assignment<'a> {
fn from_stmt(stmt: &'a Stmt) -> Option<Self> {
let (targets, value) = match stmt {
Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
(targets.as_slice(), Some(&**value))
}
Stmt::AnnAssign(ast::StmtAnnAssign { target, value, .. }) => {
(std::slice::from_ref(&**target), value.as_deref())
}
Stmt::AugAssign(ast::StmtAugAssign { target, value, .. }) => {
(std::slice::from_ref(&**target), Some(&**value))
}
_ => return None,
};
Some(Self { targets, value })
}
/// Returns whether all of the assignment targets match `name`.
///
/// For example, both of the following would be allowed for a `name` of `__all__`:
///
/// ```py
/// __all__ = ["foo"]
/// __all__ = __all__ = ["foo"]
/// ```
///
/// but not:
///
/// ```py
/// __all__ = another_list = ["foo"]
/// ```
fn is_assignment_to(&self, name: &str) -> bool {
self.targets
.iter()
.all(|target| target.as_name_expr().is_some_and(|expr| expr.id == name))
}
/// Returns `true` if the value being assigned is a call to `pkgutil.extend_path`.
///
/// For example, both of the following would return true:
///
/// ```py
/// __path__ = __import__('pkgutil').extend_path(__path__, __name__)
/// __path__ = other.extend_path(__path__, __name__)
/// ```
///
/// We're intentionally a bit less strict here, not requiring that the receiver of the
/// `extend_path` call is the typical `__import__('pkgutil')` or `pkgutil`.
fn is_pkgutil_extend_path(&self) -> bool {
let Some(Expr::Call(ast::ExprCall {
func: extend_func,
arguments: extend_arguments,
..
})) = self.value
else {
return false;
};
let Expr::Attribute(ast::ExprAttribute {
attr: maybe_extend_path,
..
}) = &**extend_func
else {
return false;
};
// Test that this is an `extend_path(__path__, __name__)` call
if maybe_extend_path != "extend_path" {
return false;
}
let Some(Expr::Name(path)) = extend_arguments.find_argument_value("path", 0) else {
return false;
};
let Some(Expr::Name(name)) = extend_arguments.find_argument_value("name", 1) else {
return false;
};
path.id() == "__path__" && name.id() == "__name__"
}
}

View File

@@ -57,7 +57,7 @@ use crate::{Applicability, Edit, Fix, FixAvailability, Violation};
/// ## References
/// - [Typing documentation: Legal parameters for `Literal` at type check time](https://typing.python.org/en/latest/spec/literal.html#legal-parameters-for-literal-at-type-check-time)
///
/// [PEP 586](https://peps.python.org/pep-0586/)
/// [PEP 586]: https://peps.python.org/pep-0586/
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "0.10.0")]
pub(crate) struct UnnecessaryNestedLiteral;

View File

@@ -7,6 +7,7 @@ use std::fmt;
#[derive(Debug, Clone, CacheKey, Default)]
pub struct Settings {
pub parenthesize_tuple_in_subscript: bool,
pub strictly_empty_init_modules: bool,
}
impl fmt::Display for Settings {
@@ -16,6 +17,7 @@ impl fmt::Display for Settings {
namespace = "linter.ruff",
fields = [
self.parenthesize_tuple_in_subscript,
self.strictly_empty_init_modules,
]
}
Ok(())

View File

@@ -0,0 +1,53 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:12:1
|
10 | __all__ = __all__ = __all__
11 |
12 | MY_CONSTANT = 5
| ^^^^^^^^^^^^^^^
13 | """This is an important constant."""
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:15:1
|
13 | """This is an important constant."""
14 |
15 | os.environ["FOO"] = 1
| ^^^^^^^^^^^^^^^^^^^^^
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:18:1
|
18 | / def foo():
19 | | return Path("foo.py")
| |_________________________^
20 |
21 | def __getattr__(name): # ok
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:26:1
|
24 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
25 |
26 | / if os.environ["FOO"] != "1": # RUF067
27 | | MY_CONSTANT = 4 # ok, don't flag nested statements
| |___________________^
28 |
29 | if TYPE_CHECKING: # ok
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:48:1
|
47 | # non-`extend_path` assignments are not allowed
48 | __path__ = 5 # RUF067
| ^^^^^^^^^^^^
49 |
50 | # also allow `__author__`
|

View File

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

View File

@@ -0,0 +1,344 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
--- Linter settings ---
-linter.ruff.strictly_empty_init_modules = false
+linter.ruff.strictly_empty_init_modules = true
--- Summary ---
Removed: 5
Added: 24
--- Removed ---
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:12:1
|
10 | __all__ = __all__ = __all__
11 |
12 | MY_CONSTANT = 5
| ^^^^^^^^^^^^^^^
13 | """This is an important constant."""
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:15:1
|
13 | """This is an important constant."""
14 |
15 | os.environ["FOO"] = 1
| ^^^^^^^^^^^^^^^^^^^^^
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:18:1
|
18 | / def foo():
19 | | return Path("foo.py")
| |_________________________^
20 |
21 | def __getattr__(name): # ok
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:26:1
|
24 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
25 |
26 | / if os.environ["FOO"] != "1": # RUF067
27 | | MY_CONSTANT = 4 # ok, don't flag nested statements
| |___________________^
28 |
29 | if TYPE_CHECKING: # ok
|
RUF067 `__init__` module should only contain docstrings and re-exports
--> __init__.py:48:1
|
47 | # non-`extend_path` assignments are not allowed
48 | __path__ = 5 # RUF067
| ^^^^^^^^^^^^
49 |
50 | # also allow `__author__`
|
--- Added ---
RUF067 `__init__` module should not contain any code
--> __init__.py:1:1
|
1 | """This is the module docstring."""
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 |
3 | # convenience imports:
|
RUF067 `__init__` module should not contain any code
--> __init__.py:4:1
|
3 | # convenience imports:
4 | import os
| ^^^^^^^^^
5 | from pathlib import Path
|
RUF067 `__init__` module should not contain any code
--> __init__.py:5:1
|
3 | # convenience imports:
4 | import os
5 | from pathlib import Path
| ^^^^^^^^^^^^^^^^^^^^^^^^
6 |
7 | __all__ = ["MY_CONSTANT"]
|
RUF067 `__init__` module should not contain any code
--> __init__.py:7:1
|
5 | from pathlib import Path
6 |
7 | __all__ = ["MY_CONSTANT"]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
8 | __all__ += ["foo"]
9 | __all__: list[str] = __all__
|
RUF067 `__init__` module should not contain any code
--> __init__.py:8:1
|
7 | __all__ = ["MY_CONSTANT"]
8 | __all__ += ["foo"]
| ^^^^^^^^^^^^^^^^^^
9 | __all__: list[str] = __all__
10 | __all__ = __all__ = __all__
|
RUF067 `__init__` module should not contain any code
--> __init__.py:9:1
|
7 | __all__ = ["MY_CONSTANT"]
8 | __all__ += ["foo"]
9 | __all__: list[str] = __all__
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 | __all__ = __all__ = __all__
|
RUF067 `__init__` module should not contain any code
--> __init__.py:10:1
|
8 | __all__ += ["foo"]
9 | __all__: list[str] = __all__
10 | __all__ = __all__ = __all__
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
11 |
12 | MY_CONSTANT = 5
|
RUF067 `__init__` module should not contain any code
--> __init__.py:12:1
|
10 | __all__ = __all__ = __all__
11 |
12 | MY_CONSTANT = 5
| ^^^^^^^^^^^^^^^
13 | """This is an important constant."""
|
RUF067 `__init__` module should not contain any code
--> __init__.py:13:1
|
12 | MY_CONSTANT = 5
13 | """This is an important constant."""
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |
15 | os.environ["FOO"] = 1
|
RUF067 `__init__` module should not contain any code
--> __init__.py:15:1
|
13 | """This is an important constant."""
14 |
15 | os.environ["FOO"] = 1
| ^^^^^^^^^^^^^^^^^^^^^
|
RUF067 `__init__` module should not contain any code
--> __init__.py:18:1
|
18 | / def foo():
19 | | return Path("foo.py")
| |_________________________^
20 |
21 | def __getattr__(name): # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:21:1
|
19 | return Path("foo.py")
20 |
21 | / def __getattr__(name): # ok
22 | | return name
| |_______________^
23 |
24 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:24:1
|
22 | return name
23 |
24 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
25 |
26 | if os.environ["FOO"] != "1": # RUF067
|
RUF067 `__init__` module should not contain any code
--> __init__.py:26:1
|
24 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) # ok
25 |
26 | / if os.environ["FOO"] != "1": # RUF067
27 | | MY_CONSTANT = 4 # ok, don't flag nested statements
| |___________________^
28 |
29 | if TYPE_CHECKING: # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:29:1
|
27 | MY_CONSTANT = 4 # ok, don't flag nested statements
28 |
29 | / if TYPE_CHECKING: # ok
30 | | MY_CONSTANT = 3
| |___________________^
31 |
32 | import typing
|
RUF067 `__init__` module should not contain any code
--> __init__.py:32:1
|
30 | MY_CONSTANT = 3
31 |
32 | import typing
| ^^^^^^^^^^^^^
33 |
34 | if typing.TYPE_CHECKING: # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:34:1
|
32 | import typing
33 |
34 | / if typing.TYPE_CHECKING: # ok
35 | | MY_CONSTANT = 2
| |___________________^
36 |
37 | __version__ = "1.2.3" # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:37:1
|
35 | MY_CONSTANT = 2
36 |
37 | __version__ = "1.2.3" # ok
| ^^^^^^^^^^^^^^^^^^^^^
38 |
39 | def __dir__(): # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:39:1
|
37 | __version__ = "1.2.3" # ok
38 |
39 | / def __dir__(): # ok
40 | | return ["foo"]
| |__________________^
41 |
42 | import pkgutil
|
RUF067 `__init__` module should not contain any code
--> __init__.py:42:1
|
40 | return ["foo"]
41 |
42 | import pkgutil
| ^^^^^^^^^^^^^^
43 |
44 | __path__ = pkgutil.extend_path(__path__, __name__) # ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:44:1
|
42 | import pkgutil
43 |
44 | __path__ = pkgutil.extend_path(__path__, __name__) # ok
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
45 | __path__ = unknown.extend_path(__path__, __name__) # also ok
|
RUF067 `__init__` module should not contain any code
--> __init__.py:45:1
|
44 | __path__ = pkgutil.extend_path(__path__, __name__) # ok
45 | __path__ = unknown.extend_path(__path__, __name__) # also ok
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
46 |
47 | # non-`extend_path` assignments are not allowed
|
RUF067 `__init__` module should not contain any code
--> __init__.py:48:1
|
47 | # non-`extend_path` assignments are not allowed
48 | __path__ = 5 # RUF067
| ^^^^^^^^^^^^
49 |
50 | # also allow `__author__`
|
RUF067 `__init__` module should not contain any code
--> __init__.py:51:1
|
50 | # also allow `__author__`
51 | __author__ = "The Author" # ok
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|

View File

@@ -1479,7 +1479,7 @@ pub struct Flake8GetTextOptions {
impl Flake8GetTextOptions {
pub fn into_settings(self) -> flake8_gettext::settings::Settings {
flake8_gettext::settings::Settings {
functions_names: self
function_names: self
.function_names
.unwrap_or_else(flake8_gettext::settings::default_func_names)
.into_iter()
@@ -3497,6 +3497,17 @@ pub struct RuffOptions {
note = "The `allowed-markup-names` option has been moved to the `flake8-bandit` section of the configuration."
)]
pub allowed_markup_calls: Option<Vec<String>>,
/// Whether to require `__init__.py` files to contain no code at all, including imports and
/// docstrings (see `RUF067`).
#[option(
default = r#"false"#,
value_type = "bool",
example = r#"
# Make it a violation to include any code, including imports and docstrings in `__init__.py`
strictly-empty-init-modules = true
"#
)]
pub strictly_empty_init_modules: Option<bool>,
}
impl RuffOptions {
@@ -3505,6 +3516,7 @@ impl RuffOptions {
parenthesize_tuple_in_subscript: self
.parenthesize_tuple_in_subscript
.unwrap_or_default(),
strictly_empty_init_modules: self.strictly_empty_init_modules.unwrap_or_default(),
}
}
}

View File

@@ -102,6 +102,10 @@ crates shared with Ruff, such as `ruff_db`, `ruff_python_ast`, and `ruff_python_
annotations for the Python standard library.
- `ty_wasm`: library crate for exposing ty as a WebAssembly module. Powers the
[ty Playground](https://play.ty.dev/).
- `ty_completion_eval`: Framework for evaluating completion suggestions returned by the ty LSP.
- `ty_module_resolver`: The module resolver, which allows resolving imports to their modules.
- `ty_static`: Lists the known environment variables used by `ty`.
- `ty_combine`: Utility crate containing the `Combine` trait, which is used to combine `Options`.
## Writing tests

2
crates/ty/docs/cli.md generated
View File

@@ -91,7 +91,7 @@ over all configuration files.</p>
<li><code>3.13</code></li>
<li><code>3.14</code></li>
</ul></dd><dt id="ty-check--quiet"><a href="#ty-check--quiet"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output (or <code>-qq</code> for silent output)</p>
</dd><dt id="ty-check--respect-ignore-files"><a href="#ty-check--respect-ignore-files"><code>--respect-ignore-files</code></a></dt><dd><p>Respect file exclusions via <code>.gitignore</code> and other standard ignore files. Use <code>--no-respect-gitignore</code> to disable</p>
</dd><dt id="ty-check--respect-ignore-files"><a href="#ty-check--respect-ignore-files"><code>--respect-ignore-files</code></a></dt><dd><p>Respect file exclusions via <code>.gitignore</code> and other standard ignore files. Use <code>--no-respect-ignore-files</code> to disable</p>
</dd><dt id="ty-check--typeshed"><a href="#ty-check--typeshed"><code>--typeshed</code></a>, <code>--custom-typeshed-dir</code> <i>path</i></dt><dd><p>Custom directory to use for stdlib typeshed stubs</p>
</dd><dt id="ty-check--verbose"><a href="#ty-check--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output (or <code>-vv</code> and <code>-vvv</code> for more verbose output)</p>
</dd><dt id="ty-check--warn"><a href="#ty-check--warn"><code>--warn</code></a> <i>rule</i></dt><dd><p>Treat the given rule as having severity 'warn'. Can be specified multiple times.</p>

View File

@@ -142,7 +142,7 @@ pub(crate) struct CheckCommand {
pub(crate) watch: bool,
/// Respect file exclusions via `.gitignore` and other standard ignore files.
/// Use `--no-respect-gitignore` to disable.
/// Use `--no-respect-ignore-files` to disable.
#[arg(
long,
overrides_with("no_respect_ignore_files"),

View File

@@ -12,6 +12,7 @@ use ruff_python_codegen::Stylist;
use ruff_text_size::{Ranged, TextRange, TextSize};
use rustc_hash::FxHashSet;
use ty_module_resolver::{KnownModule, ModuleName};
use ty_python_semantic::HasType;
use ty_python_semantic::types::UnionType;
use ty_python_semantic::{
Completion as SemanticCompletion, NameKind, SemanticModel,
@@ -60,6 +61,7 @@ pub fn completion<'db>(
completions.extend(semantic_completions);
if scoped.is_some() {
add_keyword_completions(db, &mut completions);
add_argument_completions(db, &model, &context.cursor, &mut completions);
}
if settings.auto_import {
if let Some(scoped) = scoped {
@@ -75,8 +77,6 @@ pub fn completion<'db>(
);
}
}
add_function_arg_completions(db, file, &context.cursor, &mut completions);
}
}
@@ -356,6 +356,7 @@ impl<'db> Completion<'db> {
Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::TypeIs(_)
| Type::TypeGuard(_)
| Type::StringLiteral(_)
| Type::LiteralString
| Type::BytesLiteral(_) => CompletionKind::Value,
@@ -418,6 +419,25 @@ impl<'db> Completion<'db> {
}
}
fn argument(name: &str, ty: Option<Type<'db>>, documentation: Option<&str>) -> Self {
let insert = Some(format!("{name}=").into_boxed_str());
let documentation = documentation.map(|d| Docstring::new(d.to_owned()));
Completion {
name: name.into(),
qualified: None,
insert,
ty,
kind: Some(CompletionKind::Variable),
module_name: None,
import: None,
builtin: false,
is_type_check_only: false,
is_definitively_raisable: false,
documentation,
}
}
/// Returns true when this completion refers to the
/// `NotImplemented` builtin.
fn is_notimplemented(&self, db: &dyn Db) -> bool {
@@ -1062,7 +1082,77 @@ enum Sort {
Lower,
}
/// Detect and construct completions for unset function arguments.
/// Detect and add completions for unset arguments.
fn add_argument_completions<'db>(
db: &'db dyn Db,
model: &SemanticModel<'db>,
cursor: &ContextCursor<'_>,
completions: &mut Completions<'db>,
) {
for node in cursor.covering_node(cursor.range).ancestors() {
match node {
ast::AnyNodeRef::ExprCall(call) => {
if call.arguments.range().contains_range(cursor.range) {
add_function_arg_completions(db, model.file(), cursor, completions);
}
return;
}
ast::AnyNodeRef::StmtClassDef(class_def) => {
if let Some(arguments) = class_def.arguments.as_deref()
&& arguments.range().contains_range(cursor.range)
{
add_class_arg_completions(model, class_def, completions);
}
return;
}
node => {
if node.is_statement() {
return;
}
}
}
}
}
/// Detect and add completions for unset class arguments.
///
/// Some arguments we know are always valid and thus they are easy
/// to provide. The `metaclass` keyword is always valid.
/// For `typing.TypedDict` subclasses, we add
/// `TypedDict` specific keywords like `total`.
fn add_class_arg_completions<'db>(
model: &SemanticModel<'db>,
class_def: &ast::StmtClassDef,
completions: &mut Completions<'db>,
) {
let is_set = |name| {
class_def
.arguments
.as_ref()
.is_some_and(|args| args.find_keyword(name).is_some())
};
if !is_set("metaclass") {
let ty = Some(KnownClass::Type.to_subclass_of(model.db()));
completions.add(Completion::argument("metaclass", ty, None));
}
let is_typed_dict = class_def
.inferred_type(model)
.and_then(Type::as_class_literal)
.is_some_and(|t| t.is_typed_dict(model.db()));
// TODO: Handle PEP 728 that adds two extra keywords,
// closed and extra_items.
//
// See https://peps.python.org/pep-0728/
if is_typed_dict && !is_set("total") {
let ty = Some(KnownClass::Bool.to_instance(model.db()));
completions.add(Completion::argument("total", ty, None));
}
}
/// Detect and add completions for unset function arguments.
///
/// Suggestions are only provided if the cursor is currently inside a
/// function call and the function arguments have not 1) already been
@@ -1073,18 +1163,15 @@ fn add_function_arg_completions<'db>(
cursor: &ContextCursor<'_>,
completions: &mut Completions<'db>,
) {
// But be careful: this isn't as simple as just finding a call
// expression. We also have to make sure we are in the "arguments"
// portion of the call. Otherwise we risk incorrectly returning
// something for `(<CURSOR>)(arg1, arg2)`-style expressions.
if !cursor
.covering_node(TextRange::empty(cursor.offset))
.ancestors()
.take_while(|node| !node.is_statement())
.any(|node| node.is_arguments())
{
return;
}
debug_assert!(
cursor
.covering_node(cursor.range)
.ancestors()
.take_while(|node| !node.is_statement())
.any(|node| node.is_arguments()),
"Should only be called if we're already certain we're in an arguments node to avoid \
adding completions for something like `(<CURSOR>)(arg1, arg2)`-style expressions"
);
let Some(sig_help) = signature_help(db, file, cursor.offset) else {
return;
@@ -1097,25 +1184,11 @@ fn add_function_arg_completions<'db>(
continue;
}
let name = Name::new(&p.name);
let documentation = p
.documentation
.as_ref()
.map(|d| Docstring::new(d.to_owned()));
let insert = Some(format!("{name}=").into_boxed_str());
completions.add(Completion {
name,
qualified: None,
insert,
ty: p.ty,
kind: Some(CompletionKind::Variable),
module_name: None,
import: None,
builtin: false,
is_type_check_only: false,
is_definitively_raisable: false,
documentation,
});
completions.add(Completion::argument(
&p.name,
p.ty,
p.documentation.as_deref(),
));
}
}
}
@@ -2075,6 +2148,7 @@ fn token_suffix_by_kinds<const N: usize>(
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use ruff_python_ast::helpers::is_dunder;
use ruff_python_ast::token::{TokenKind, Tokens};
use ruff_python_parser::{Mode, ParseOptions};
use ty_module_resolver::ModuleName;
@@ -3030,6 +3104,7 @@ class Foo(<CURSOR>):
assert_snapshot!(builder.skip_keywords().skip_builtins().build().snapshot(), @r"
Bar
Foo
metaclass=
");
}
@@ -3047,6 +3122,7 @@ class Bar: ...
assert_snapshot!(builder.skip_keywords().skip_builtins().build().snapshot(), @r"
Bar
Foo
metaclass=
");
}
@@ -3064,6 +3140,7 @@ class Bar: ...
assert_snapshot!(builder.skip_keywords().skip_builtins().build().snapshot(), @r"
Bar
Foo
metaclass=
");
}
@@ -3079,9 +3156,159 @@ class Foo(<CURSOR>",
assert_snapshot!(builder.skip_keywords().skip_builtins().build().snapshot(), @r"
Bar
Foo
metaclass=
");
}
#[test]
fn class_metaclass() {
let builder = completion_test_builder(
"\
class Foo(meta<CURSOR>",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("metaclass");
}
#[test]
fn class_metaclass_set() {
let builder = completion_test_builder(
"\
class Foo(metaclass=x, meta<CURSOR>",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.not_contains("metaclass");
}
#[test]
fn class_metaclass_generic() {
let builder = completion_test_builder(
"\
class Foo[T](meta<CURSOR>",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("metaclass");
}
#[test]
fn class_typed_dict_total() {
let builder = completion_test_builder(
"\
from typing import TypedDict
class Foo(TypedDict, tot<CURSOR>
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("total");
}
#[test]
fn class_typed_dict_total_alias() {
let builder = completion_test_builder(
"\
from typing import TypedDict as TD
class Foo(TD, tot<CURSOR>
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("total");
}
#[test]
fn class_typed_dict_total_set() {
let builder = completion_test_builder(
"\
from typing import TypedDict
class Foo(TypedDict, total=False, tot<CURSOR>
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.not_contains("total");
}
#[test]
fn class_typed_dict_total_subclass() {
let builder = completion_test_builder(
"\
from typing import TypedDict
class Foo(TypedDict):
x: int
class Bar(Foo, to<CURSOR>)
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("total");
}
#[test]
fn class_typed_dict_total_pep695_generic() {
let builder = completion_test_builder(
"\
from typing import TypedDict
class Foo[T](TypedDict, to<CURSOR>)
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("total");
}
#[test]
fn class_typed_dict_total_typevar_generic() {
let builder = completion_test_builder(
"\
from typing import Generic, TypeVar, TypedDict
T = TypeVar('T')
class Foo(TypedDict, Generic[T], to<CURSOR>)
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("total");
}
#[test]
fn class_init1() {
let builder = completion_test_builder(
@@ -3754,6 +3981,28 @@ bar(<CURSOR>
");
}
#[test]
fn call_attribute_argument_no_arg_completions() {
let builder = completion_test_builder(
"\
class A:
class B:
class C: ...
def f(aaaa): ...
f(A.B.<CURSOR>)
",
);
builder
.skip_keywords()
.skip_builtins()
.build()
.contains("C")
.not_contains("aaaa");
}
#[test]
fn duplicate1() {
let builder = completion_test_builder(
@@ -4026,7 +4275,10 @@ b.a.<CURSOR>
",
);
builder.build().not_contains("a").contains("x");
assert_snapshot!(builder.skip_dunders().build().snapshot(),
@r"
x
");
}
#[test]
@@ -7537,6 +7789,7 @@ TypedDi<CURSOR>
settings: CompletionSettings,
skip_builtins: bool,
skip_keywords: bool,
skip_dunders: bool,
type_signatures: bool,
imports: bool,
module_names: bool,
@@ -7558,6 +7811,7 @@ TypedDi<CURSOR>
.iter()
.filter(|c| !self.skip_builtins || !c.builtin)
.filter(|c| !self.skip_keywords || c.kind != Some(CompletionKind::Keyword))
.filter(|c| !self.skip_dunders || !is_dunder(&c.name))
.filter(|c| {
self.predicate
.as_ref()
@@ -7621,6 +7875,16 @@ TypedDi<CURSOR>
self
}
/// When set, dunder completions are skipped.
/// This is useful to reduce noise for snapshot tests
/// when filtering on methods and attributes.
///
/// Not enabled by default.
fn skip_dunders(mut self) -> CompletionTestBuilder {
self.skip_dunders = true;
self
}
/// When set, type signatures of each completion item are
/// included in the snapshot. This is useful when one wants
/// to specifically test types, but it usually best to leave
@@ -7769,6 +8033,7 @@ TypedDi<CURSOR>
settings: CompletionSettings::default(),
skip_builtins: false,
skip_keywords: false,
skip_dunders: false,
type_signatures: false,
imports: false,
module_names: false,

View File

@@ -72,7 +72,7 @@ mod tests {
"#,
);
assert_snapshot!(test.goto_type_definition(), @r"
assert_snapshot!(test.goto_type_definition(), @r#"
info[goto-type definition]: Go to type definition
--> main.py:4:1
|
@@ -82,15 +82,15 @@ mod tests {
| ^^ Clicking here
|
info: Found 1 type definition
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| -------
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
");
"#);
}
// this is a slightly different case to the one above,
@@ -137,7 +137,7 @@ mod tests {
"#,
);
assert_snapshot!(test.goto_type_definition(), @r"
assert_snapshot!(test.goto_type_definition(), @r#"
info[goto-type definition]: Go to type definition
--> main.py:4:1
|
@@ -147,16 +147,15 @@ mod tests {
| ^^ Clicking here
|
info: Found 1 type definition
--> stdlib/typing.pyi:781:1
|
779 | def __class_getitem__(cls, args: TypeVar | tuple[TypeVar, ...]) -> _Final: ...
780 |
781 | Generic: type[_Generic]
| -------
782 |
783 | class _ProtocolMeta(ABCMeta):
|
");
--> stdlib/typing.pyi:1268:1
|
1266 | def __class_getitem__(cls, args: TypeVar | tuple[TypeVar, ...]) -> _Final: ...
1267 |
1268 | Generic: type[_Generic]
| -------
1269 | """Abstract base class for generic types.
|
"#);
}
#[test]
@@ -797,13 +796,13 @@ mod tests {
| -------
5 | """some docs"""
|
::: stdlib/types.pyi:950:11
::: stdlib/types.pyi:974:11
|
948 | if sys.version_info >= (3, 10):
949 | @final
950 | class NoneType:
972 | if sys.version_info >= (3, 10):
973 | @final
974 | class NoneType:
| --------
951 | """The type of the None singleton."""
975 | """The type of the None singleton."""
|
"#);
}
@@ -851,13 +850,13 @@ mod tests {
| -------
5 | """some docs"""
|
::: stdlib/types.pyi:950:11
::: stdlib/types.pyi:974:11
|
948 | if sys.version_info >= (3, 10):
949 | @final
950 | class NoneType:
972 | if sys.version_info >= (3, 10):
973 | @final
974 | class NoneType:
| --------
951 | """The type of the None singleton."""
975 | """The type of the None singleton."""
|
"#);
}
@@ -1610,13 +1609,13 @@ def function():
916 | """str(object='') -> str
917 | str(bytes_or_buffer[, encoding[, errors]]) -> str
|
::: stdlib/types.pyi:950:11
::: stdlib/types.pyi:974:11
|
948 | if sys.version_info >= (3, 10):
949 | @final
950 | class NoneType:
972 | if sys.version_info >= (3, 10):
973 | @final
974 | class NoneType:
| --------
951 | """The type of the None singleton."""
975 | """The type of the None singleton."""
|
"#);
}

View File

@@ -723,13 +723,13 @@ mod tests {
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:6:5
@@ -801,13 +801,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:10:6
@@ -881,13 +881,13 @@ mod tests {
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:6
@@ -919,13 +919,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:24
@@ -1061,13 +1061,13 @@ mod tests {
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:6
@@ -1099,13 +1099,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:24
@@ -1259,13 +1259,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:11
@@ -1297,13 +1297,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:23
@@ -1486,13 +1486,13 @@ mod tests {
x4[: int], (y4[: str], z4[: int]) = (x3, (y3, z3))
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:6
@@ -1524,13 +1524,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:25
@@ -1562,13 +1562,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:8:47
@@ -1735,13 +1735,13 @@ mod tests {
w[: int] = z
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:6:5
@@ -2442,13 +2442,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:950:11
--> stdlib/types.pyi:974:11
|
948 | if sys.version_info >= (3, 10):
949 | @final
950 | class NoneType:
972 | if sys.version_info >= (3, 10):
973 | @final
974 | class NoneType:
| ^^^^^^^^
951 | """The type of the None singleton."""
975 | """The type of the None singleton."""
|
info: Source
--> main2.py:5:20
@@ -5525,13 +5525,13 @@ mod tests {
y[: Literal[1, 2, 3, "hello"] | None] = x
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:13:9
@@ -5615,13 +5615,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:950:11
--> stdlib/types.pyi:974:11
|
948 | if sys.version_info >= (3, 10):
949 | @final
950 | class NoneType:
972 | if sys.version_info >= (3, 10):
973 | @final
974 | class NoneType:
| ^^^^^^^^
951 | """The type of the None singleton."""
975 | """The type of the None singleton."""
|
info: Source
--> main2.py:13:37
@@ -6294,12 +6294,12 @@ mod tests {
a[: <module 'foo'>] = foo
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:423:7
--> stdlib/types.pyi:431:7
|
422 | @disjoint_base
423 | class ModuleType:
430 | @disjoint_base
431 | class ModuleType:
| ^^^^^^^^^^
424 | """Create a module object.
432 | """Create a module object.
|
info: Source
--> main2.py:4:6
@@ -6342,13 +6342,13 @@ mod tests {
a[: <special-form 'Literal["a", "b", "c"]'>] = Literal['a', 'b', 'c']
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:351:1
--> stdlib/typing.pyi:487:1
|
349 | Final: _SpecialForm
350 |
351 | Literal: _SpecialForm
485 | """
486 |
487 | Literal: _SpecialForm
| ^^^^^^^
352 | TypedDict: _SpecialForm
488 | """Special typing form to define literal types (a.k.a. value types).
|
info: Source
--> main2.py:4:20
@@ -6430,13 +6430,13 @@ mod tests {
a[: <wrapper-descriptor '__get__' of 'function' objects>] = FunctionType.__get__
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:670:7
--> stdlib/types.pyi:690:7
|
669 | @final
670 | class WrapperDescriptorType:
689 | @final
690 | class WrapperDescriptorType:
| ^^^^^^^^^^^^^^^^^^^^^
671 | @property
672 | def __name__(self) -> str: ...
691 | @property
692 | def __name__(self) -> str: ...
|
info: Source
--> main2.py:4:6
@@ -6482,13 +6482,13 @@ mod tests {
a[: <method-wrapper '__call__' of function 'f'>] = f.__call__
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:684:7
--> stdlib/types.pyi:704:7
|
683 | @final
684 | class MethodWrapperType:
703 | @final
704 | class MethodWrapperType:
| ^^^^^^^^^^^^^^^^^
685 | @property
686 | def __self__(self) -> object: ...
705 | @property
706 | def __self__(self) -> object: ...
|
info: Source
--> main2.py:4:6
@@ -6500,13 +6500,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/types.pyi:134:9
--> stdlib/types.pyi:139:9
|
132 | ) -> Self: ...
133 |
134 | def __call__(self, *args: Any, **kwargs: Any) -> Any:
137 | ) -> Self: ...
138 |
139 | def __call__(self, *args: Any, **kwargs: Any) -> Any:
| ^^^^^^^^
135 | """Call self as a function."""
140 | """Call self as a function."""
|
info: Source
--> main2.py:4:22
@@ -6573,14 +6573,14 @@ mod tests {
Y[: <NewType pseudo-class 'N'>] = N
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:615:11
|
613 | TypeGuard: _SpecialForm
614 |
615 | class NewType:
| ^^^^^^^
616 | """NewType creates simple unique types with almost zero runtime overhead.
|
--> stdlib/typing.pyi:1040:11
|
1038 | """
1039 |
1040 | class NewType:
| ^^^^^^^
1041 | """NewType creates simple unique types with almost zero runtime overhead.
|
info: Source
--> main2.py:4:6
|
@@ -6614,15 +6614,15 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:637:28
|
635 | """
636 |
637 | def __init__(self, name: str, tp: Any) -> None: ... # AnnotationForm
| ^^^^
638 | if sys.version_info >= (3, 11):
639 | @staticmethod
|
--> stdlib/typing.pyi:1062:28
|
1060 | """
1061 |
1062 | def __init__(self, name: str, tp: Any) -> None: ... # AnnotationForm
| ^^^^
1063 | if sys.version_info >= (3, 11):
1064 | @staticmethod
|
info: Source
--> main2.py:4:44
|
@@ -6635,15 +6635,15 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:637:39
|
635 | """
636 |
637 | def __init__(self, name: str, tp: Any) -> None: ... # AnnotationForm
| ^^
638 | if sys.version_info >= (3, 11):
639 | @staticmethod
|
--> stdlib/typing.pyi:1062:39
|
1060 | """
1061 |
1062 | def __init__(self, name: str, tp: Any) -> None: ... # AnnotationForm
| ^^
1063 | if sys.version_info >= (3, 11):
1064 | @staticmethod
|
info: Source
--> main2.py:4:56
|
@@ -6656,14 +6656,14 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:615:11
|
613 | TypeGuard: _SpecialForm
614 |
615 | class NewType:
| ^^^^^^^
616 | """NewType creates simple unique types with almost zero runtime overhead.
|
--> stdlib/typing.pyi:1040:11
|
1038 | """
1039 |
1040 | class NewType:
| ^^^^^^^
1041 | """NewType creates simple unique types with almost zero runtime overhead.
|
info: Source
--> main2.py:6:6
|
@@ -6749,7 +6749,7 @@ mod tests {
Strange = Protocol[T]",
);
assert_snapshot!(test.inlay_hints(), @r"
assert_snapshot!(test.inlay_hints(), @r#"
from typing import Protocol, TypeVar
T = TypeVar([name=]'T')
Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
@@ -6774,13 +6774,13 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:341:1
--> stdlib/typing.pyi:346:1
|
340 | Union: _SpecialForm
341 | Protocol: _SpecialForm
344 | """
345 |
346 | Protocol: _SpecialForm
| ^^^^^^^^
342 | Callable: _SpecialForm
343 | Type: _SpecialForm
347 | """Base class for protocol classes.
|
info: Source
--> main2.py:4:26
@@ -6807,7 +6807,7 @@ mod tests {
4 | Strange[: <special-form 'typing.Protocol[T]'>] = Protocol[T]
| ^
|
");
"#);
}
#[test]
@@ -6823,14 +6823,14 @@ mod tests {
P = ParamSpec([name=]'P')
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:552:17
--> stdlib/typing.pyi:901:17
|
550 | def __new__(
551 | cls,
552 | name: str,
899 | def __new__(
900 | cls,
901 | name: str,
| ^^^^
553 | *,
554 | bound: Any | None = None, # AnnotationForm
902 | *,
903 | bound: Any | None = None, # AnnotationForm
|
info: Source
--> main2.py:3:16
@@ -6855,14 +6855,14 @@ mod tests {
A = TypeAliasType([name=]'A', [value=]str)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:2037:26
--> stdlib/typing.pyi:2546:26
|
2035 | """
2036 |
2037 | def __new__(cls, name: str, value: Any, *, type_params: tuple[_TypeParameter, ...] = ()) -> Self: ...
2544 | """
2545 |
2546 | def __new__(cls, name: str, value: Any, *, type_params: tuple[_TypeParameter, ...] = ()) -> Self: ...
| ^^^^
2038 | @property
2039 | def __value__(self) -> Any: ... # AnnotationForm
2547 | @property
2548 | def __value__(self) -> Any: ... # AnnotationForm
|
info: Source
--> main2.py:3:20
@@ -6873,14 +6873,14 @@ mod tests {
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:2037:37
--> stdlib/typing.pyi:2546:37
|
2035 | """
2036 |
2037 | def __new__(cls, name: str, value: Any, *, type_params: tuple[_TypeParameter, ...] = ()) -> Self: ...
2544 | """
2545 |
2546 | def __new__(cls, name: str, value: Any, *, type_params: tuple[_TypeParameter, ...] = ()) -> Self: ...
| ^^^^^
2038 | @property
2039 | def __value__(self) -> Any: ... # AnnotationForm
2547 | @property
2548 | def __value__(self) -> Any: ... # AnnotationForm
|
info: Source
--> main2.py:3:32
@@ -6905,14 +6905,14 @@ mod tests {
Ts = TypeVarTuple([name=]'Ts')
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/typing.pyi:412:30
--> stdlib/typing.pyi:761:30
|
410 | def has_default(self) -> bool: ...
411 | if sys.version_info >= (3, 13):
412 | def __new__(cls, name: str, *, default: Any = ...) -> Self: ... # AnnotationForm
759 | def has_default(self) -> bool: ...
760 | if sys.version_info >= (3, 13):
761 | def __new__(cls, name: str, *, default: Any = ...) -> Self: ... # AnnotationForm
| ^^^^
413 | elif sys.version_info >= (3, 12):
414 | def __new__(cls, name: str) -> Self: ...
762 | elif sys.version_info >= (3, 12):
763 | def __new__(cls, name: str) -> Self: ...
|
info: Source
--> main2.py:3:20

View File

@@ -16,7 +16,6 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
reveal_type(args) # revealed: tuple[@Todo(`Unpack[]` special form), ...]
return args
def g() -> TypeGuard[int]: ...
def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.kwargs) -> R_co:
reveal_type(args) # revealed: P@i.args
reveal_type(kwargs) # revealed: P@i.kwargs

View File

@@ -402,40 +402,48 @@ python-version = "3.12"
`generic_list.py`:
```py
from typing import Literal
from typing import Literal, Sequence
def f[T](x: T) -> list[T]:
return [x]
a = f("a")
reveal_type(a) # revealed: list[str]
x1 = f("a")
reveal_type(x1) # revealed: list[str]
b: list[int | Literal["a"]] = f("a")
reveal_type(b) # revealed: list[int | Literal["a"]]
x2: list[int | Literal["a"]] = f("a")
reveal_type(x2) # revealed: list[int | Literal["a"]]
c: list[int | str] = f("a")
reveal_type(c) # revealed: list[int | str]
x3: list[int | str] = f("a")
reveal_type(x3) # revealed: list[int | str]
d: list[int | tuple[int, int]] = f((1, 2))
reveal_type(d) # revealed: list[int | tuple[int, int]]
x4: list[int | tuple[int, int]] = f((1, 2))
reveal_type(x4) # revealed: list[int | tuple[int, int]]
e: list[int] = f(True)
reveal_type(e) # revealed: list[int]
x5: list[int] = f(True)
reveal_type(x5) # revealed: list[int]
# error: [invalid-assignment] "Object of type `list[int | str]` is not assignable to `list[int]`"
g: list[int] = f("a")
x6: list[int] = f("a")
# error: [invalid-assignment] "Object of type `list[str]` is not assignable to `tuple[int]`"
h: tuple[int] = f("a")
x7: tuple[int] = f("a")
def f2[T: int](x: T) -> T:
return x
i: int = f2(True)
reveal_type(i) # revealed: Literal[True]
x8: int = f2(True)
reveal_type(x8) # revealed: Literal[True]
j: int | str = f2(True)
reveal_type(j) # revealed: Literal[True]
x9: int | str = f2(True)
reveal_type(x9) # revealed: Literal[True]
# TODO: We could choose a concrete type here.
x10: list[int | str] | list[int | None] = [1, 2, 3]
reveal_type(x10) # revealed: list[Unknown | int]
# TODO: And here similarly.
x11: Sequence[int | str] | Sequence[int | None] = [1, 2, 3]
reveal_type(x11) # revealed: list[Unknown | int]
```
A function's arguments are also inferred using the type context:
@@ -610,6 +618,73 @@ x1: X[int | None] = X()
reveal_type(x1) # revealed: X[None]
```
## Declared type preference sees through subtyping
```toml
[environment]
python-version = "3.12"
```
Similarly, if the inferred type is a subtype of the declared type, we prefer declared type
assignments that are in non-covariant position.
```py
from collections import defaultdict
from typing import Any, Iterable, Literal, MutableSequence, Sequence
x1: Sequence[Any] = [1, 2, 3]
reveal_type(x1) # revealed: list[int]
x2: MutableSequence[Any] = [1, 2, 3]
reveal_type(x2) # revealed: list[Any]
x3: Iterable[Any] = [1, 2, 3]
reveal_type(x3) # revealed: list[int]
x4: Iterable[Iterable[Any]] = [[1, 2, 3]]
reveal_type(x4) # revealed: list[list[int]]
x5: list[Iterable[Any]] = [[1, 2, 3]]
reveal_type(x5) # revealed: list[Iterable[Any]]
x6: Iterable[list[Any]] = [[1, 2, 3]]
reveal_type(x6) # revealed: list[list[Any]]
class X[T]:
value: T
def __init__(self, value: T): ...
class A[T](X[T]): ...
def a[T](value: T) -> A[T]:
return A(value)
x7: A[object] = A(1)
reveal_type(x7) # revealed: A[object]
x8: X[object] = A(1)
reveal_type(x8) # revealed: A[object]
x9: X[object] | None = A(1)
reveal_type(x9) # revealed: A[object]
x10: X[object] | None = a(1)
reveal_type(x10) # revealed: A[object]
def f[T](x: T) -> list[list[T]]:
return [[x]]
x11: Sequence[Sequence[Any]] = f(1)
reveal_type(x11) # revealed: list[list[int]]
x12: Sequence[list[Any]] = f(1)
reveal_type(x12) # revealed: list[list[Any]]
x13: dict[int, dict[str, int]] = defaultdict(dict)
reveal_type(x13) # revealed: defaultdict[int, dict[str, int]]
```
## Narrow generic unions
```toml

View File

@@ -2002,6 +2002,64 @@ def _(ns: argparse.Namespace):
ns.whatever = 42
```
### `__setattr__` is a fallback for explicitly defined attributes
When a class has both a custom `__setattr__` method and explicitly defined attributes, the
`__setattr__` method is treated as a fallback. The type of the explicit attribute takes precedence
over the `__setattr__` parameter type.
This matches the behavior of other type checkers and reflects the common pattern in libraries like
PyTorch, where `__setattr__` may have a narrow type signature but forwards to
`super().__setattr__()` for attributes that don't match.
```py
from typing import Union
class Tensor: ...
class Module:
def __setattr__(self, name: str, value: Union[Tensor, "Module"]) -> None:
super().__setattr__(name, value)
class MyModule(Module):
some_param: int # Explicit attribute with type `int`
def use_module(m: MyModule, param: int) -> None:
# This is allowed because `some_param` is explicitly defined with type `int`,
# even though `__setattr__` only accepts `Union[Tensor, Module]`.
m.some_param = param
# But assigning to an attribute that's not explicitly defined will still
# use `__setattr__` for validation.
# error: [unresolved-attribute] "Cannot assign object of type `int` to attribute `undefined_param` on type `MyModule` with custom `__setattr__` method."
m.undefined_param = param
```
### `__setattr__` returning `Never` blocks all assignments
When `__setattr__` returns `Never` (indicating an immutable class), all attribute assignments are
blocked, even if the value type doesn't match `__setattr__`'s parameter type.
```py
from typing import NoReturn
class Immutable:
x: float
def __setattr__(self, name: str, value: int) -> NoReturn:
raise AttributeError("Immutable")
def _(obj: Immutable) -> None:
# Even though `"foo"` doesn't match `__setattr__`'s `value: int` parameter,
# we still detect that `__setattr__` returns `Never` and block the assignment.
# error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`"
obj.x = "foo"
# Same for assignments that would match `__setattr__`'s parameter type.
# error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`"
obj.x = 42
```
## Objects of all types have a `__class__` method
The type of `x.__class__` is the same as `x`'s meta-type. `x.__class__` is always the same value as

View File

@@ -32,11 +32,11 @@ reveal_type(l3) # revealed: list[int | str]
def _(l: list[int] | None = None):
l1 = l or list()
reveal_type(l1) # revealed: list[int] | list[Unknown]
reveal_type(l1) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown]
l2: list[int] = l or list()
# it would be better if this were `list[int]`? (https://github.com/astral-sh/ty/issues/136)
reveal_type(l2) # revealed: list[int] | list[Unknown]
reveal_type(l2) # revealed: (list[int] & ~AlwaysFalsy) | list[Unknown]
def f[T](x: T, cond: bool) -> T | list[T]:
return x if cond else [x]
@@ -91,7 +91,7 @@ def get_data() -> dict | None:
def wrap_data() -> list[dict]:
if not (res := get_data()):
return list1({})
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown]]
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown] & ~AlwaysFalsy]
# `list[dict[Unknown, Unknown] & ~AlwaysFalsy]` and `list[dict[Unknown, Unknown]]` are incompatible,
# but the return type check passes here because the type of `list1(res)` is inferred
# by bidirectional type inference using the annotated return type, and the type of `res` is not used.
@@ -100,7 +100,7 @@ def wrap_data() -> list[dict]:
def wrap_data2() -> list[dict] | None:
if not (res := get_data()):
return None
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown]]
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown] & ~AlwaysFalsy]
return list1(res)
def deco[T](func: Callable[[], T]) -> Callable[[], T]:
@@ -111,7 +111,7 @@ def outer() -> Callable[[], list[dict]]:
def inner() -> list[dict]:
if not (res := get_data()):
return list1({})
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown]]
reveal_type(list1(res)) # revealed: list[dict[Unknown, Unknown] & ~AlwaysFalsy]
return list1(res)
return inner

View File

@@ -189,7 +189,7 @@ def _(
h: Literal[42] | Not[Literal[42]],
i: Not[Literal[42]] | Literal[42],
):
reveal_type(a) # revealed: object
reveal_type(a) # revealed: Literal[""] | ~AlwaysFalsy
reveal_type(b) # revealed: object
reveal_type(c) # revealed: object
reveal_type(d) # revealed: object

View File

@@ -13,7 +13,7 @@ reveal_type(1 is not 1) # revealed: bool
reveal_type(1 is 2) # revealed: Literal[False]
reveal_type(1 is not 7) # revealed: Literal[True]
# error: [unsupported-operator] "Operator `<=` is not supported between objects of type `Literal[1]` and `Literal[""]`"
reveal_type(1 <= "" and 0 < 1) # revealed: Unknown | Literal[True]
reveal_type(1 <= "" and 0 < 1) # revealed: (Unknown & ~AlwaysTruthy) | Literal[True]
```
## Integer instance

View File

@@ -37,7 +37,7 @@ class C:
return self
x = A() < B() < C()
reveal_type(x) # revealed: A | B
reveal_type(x) # revealed: (A & ~AlwaysTruthy) | B
y = 0 < 1 < A() < 3
reveal_type(y) # revealed: Literal[False] | A

View File

@@ -386,3 +386,52 @@ def _(target: int, flag: NotBoolable):
reveal_type(y) # revealed: Literal[1, 2, 3]
```
## Matching on enum | None without covering None
When matching on a union of an enum and None, code after the match should still be reachable if None
is not covered by any case, even when all enum members are covered.
```py
from enum import Enum
class Answer(Enum):
YES = 1
NO = 2
def _(answer: Answer | None):
y = 0
match answer:
case Answer.YES:
y = 1
case Answer.NO:
y = 2
# The match is not exhaustive because None is not covered,
# so y could still be 0
reveal_type(y) # revealed: Literal[0, 1, 2]
def _(answer: Answer | None):
match answer:
case Answer.YES:
return 1
case Answer.NO:
return 2
# Code here is reachable because None is not covered
reveal_type(answer) # revealed: None
return 3
class Foo: ...
def _(answer: Answer | None):
match answer:
case Answer.YES:
return
case Answer.NO:
return
# New assignments after the match should not be `Never`
x = Foo()
reveal_type(x) # revealed: Foo
```

View File

@@ -525,6 +525,42 @@ c.name = None
c.name = 42
```
### Overriding properties in subclasses
When a subclass overrides a property, accessing other inherited properties from within the
overriding property methods should still work correctly.
```py
class Base:
_value: float = 0.0
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float) -> None:
self._value = v
@property
def other(self) -> float:
return self.value
@other.setter
def other(self, v: float) -> None:
self.value = v
class Derived(Base):
@property
def other(self) -> float:
return self.value
@other.setter
def other(self, v: float) -> None:
reveal_type(self.value) # revealed: int | float
self.value = v
```
### Properties with no setters
<!-- snapshot-diagnostics -->

View File

@@ -10,8 +10,8 @@ def _(foo: str):
reveal_type(False or "z") # revealed: Literal["z"]
reveal_type(False or True) # revealed: Literal[True]
reveal_type(False or False) # revealed: Literal[False]
reveal_type(foo or False) # revealed: str | Literal[False]
reveal_type(foo or True) # revealed: str | Literal[True]
reveal_type(foo or False) # revealed: (str & ~AlwaysFalsy) | Literal[False]
reveal_type(foo or True) # revealed: (str & ~AlwaysFalsy) | Literal[True]
```
## AND
@@ -20,8 +20,8 @@ def _(foo: str):
def _(foo: str):
reveal_type(True and False) # revealed: Literal[False]
reveal_type(False and True) # revealed: Literal[False]
reveal_type(foo and False) # revealed: str | Literal[False]
reveal_type(foo and True) # revealed: str | Literal[True]
reveal_type(foo and False) # revealed: (str & ~AlwaysTruthy) | Literal[False]
reveal_type(foo and True) # revealed: (str & ~AlwaysTruthy) | Literal[True]
reveal_type("x" and "y" and "z") # revealed: Literal["z"]
reveal_type("x" and "y" and "") # revealed: Literal[""]
reveal_type("" and "y") # revealed: Literal[""]

View File

@@ -790,6 +790,44 @@ static_assert(not is_assignable_to(C[B], C[A]))
static_assert(not is_assignable_to(C[A], C[B]))
```
## TypeGuard
`TypeGuard[T]` is covariant in `T`. The typing spec doesn't explicitly call this out, but it follows
from similar logic to invariance of `TypeIs` except without the negative case.
Formally, suppose we have types `A` and `B` with `B < A`. Take `x: object` to be the value that all
subsequent `TypeGuard`s are narrowing.
We can assign `p: TypeGuard[A] = q` where `q: TypeGuard[B]` because
- if `q` is `False`, then no constraints were learned on `x` before and none are now learned, so
nothing changes
- if `q` is `True`, then we know `x: B`. From `B < A`, we conclude `x: A`.
We _cannot_ assign `p: TypeGuard[B] = q` where `q: TypeGuard[A]` because if `q` is `True`, we would
be concluding `x: B` from `x: A`, which is an unsafe downcast.
```py
from typing import TypeGuard
from ty_extensions import is_assignable_to, is_subtype_of, static_assert
class A:
pass
class B(A):
pass
class C[T]:
def check(x: object) -> TypeGuard[T]:
# this is a bad check, but we only care about it type-checking
return False
static_assert(is_subtype_of(C[B], C[A]))
static_assert(not is_subtype_of(C[A], C[B]))
static_assert(is_assignable_to(C[B], C[A]))
static_assert(not is_assignable_to(C[A], C[B]))
```
## Type aliases
The variance of the type alias matches the variance of the value type (RHS type).

View File

@@ -310,7 +310,7 @@ x11: list[Literal[1] | Literal[2] | Literal[3]] = [1, 2, 3]
reveal_type(x11) # revealed: list[Literal[1, 2, 3]]
x12: Y[Y[Literal[1]]] = [[1]]
reveal_type(x12) # revealed: list[list[Literal[1]]]
reveal_type(x12) # revealed: list[Y[Literal[1]]]
x13: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)]
reveal_type(x13) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]
@@ -347,3 +347,58 @@ reveal_type(x21) # revealed: X[Literal[1]]
x22: X[Literal[1]] | None = x(1)
reveal_type(x22) # revealed: X[Literal[1]]
```
## Literal annotations see through subtyping
```py
from typing import Any, Iterable, Literal, MutableSequence, Sequence
x1: Sequence[Literal[1, 2, 3]] = [1, 2, 3]
reveal_type(x1) # revealed: list[Literal[1, 2, 3]]
x2: MutableSequence[Literal[1, 2, 3]] = [1, 2, 3]
reveal_type(x2) # revealed: list[Literal[1, 2, 3]]
x3: Iterable[Literal[1, 2, 3]] = [1, 2, 3]
reveal_type(x3) # revealed: list[Literal[1, 2, 3]]
class Sup1[T]:
value: T
class Sub1[T](Sup1[T]): ...
def sub1[T](value: T) -> Sub1[T]:
return Sub1()
x4: Sub1[Literal[1]] = sub1(1)
reveal_type(x4) # revealed: Sub1[Literal[1]]
x5: Sup1[Literal[1]] = sub1(1)
reveal_type(x5) # revealed: Sub1[Literal[1]]
x6: Sup1[Literal[1]] | None = sub1(1)
reveal_type(x6) # revealed: Sub1[Literal[1]]
x7: Sup1[Literal[1]] | None = sub1(1)
reveal_type(x7) # revealed: Sub1[Literal[1]]
class Sup2A[T, U]:
value: tuple[T, U]
class Sup2B[T, U]:
value: tuple[T, U]
class Sub2[T, U](Sup2A[T, Any], Sup2B[Any, U]): ...
def sub2[T, U](x: T, y: U) -> Sub2[T, U]:
return Sub2()
x8 = sub2(1, 2)
reveal_type(x8) # revealed: Sub2[int, int]
x9: Sup2A[Literal[1], Literal[2]] = sub2(1, 2)
reveal_type(x9) # revealed: Sub2[Literal[1], int]
x10: Sup2B[Literal[1], Literal[2]] = sub2(1, 2)
reveal_type(x10) # revealed: Sub2[int, Literal[2]]
```

View File

@@ -105,8 +105,8 @@ reveal_type(y) # revealed: Unknown
```py
def one(x: int | None):
assert (y := x), reveal_type(y) # revealed: int | None
reveal_type(y) # revealed: int
assert (y := x), reveal_type(y) # revealed: (int & ~AlwaysTruthy) | None
reveal_type(y) # revealed: int & ~AlwaysFalsy
def two(x: int | None):
assert isinstance((y := x), int), reveal_type(y) # revealed: None

View File

@@ -104,14 +104,14 @@ class C:
value: str | None
def foo(c: C):
# The truthiness check `c.value` narrows to `str`.
# The truthiness check `c.value` narrows to `str & ~AlwaysFalsy`.
# The subsequent `len(c.value)` doesn't narrow further since `str` is not narrowable by len().
if c.value and len(c.value):
reveal_type(c.value) # revealed: str
reveal_type(c.value) # revealed: str & ~AlwaysFalsy
# error: [invalid-argument-type] "Argument to function `len` is incorrect: Expected `Sized`, found `str | None`"
if len(c.value) and c.value:
reveal_type(c.value) # revealed: str
reveal_type(c.value) # revealed: str & ~AlwaysFalsy
if c.value is None or not len(c.value):
reveal_type(c.value) # revealed: str | None
@@ -279,6 +279,101 @@ def _(t9: tuple[int | None, str] | tuple[str, int]):
reveal_type(t9) # revealed: tuple[int | None, str] | tuple[str, int]
```
### Tagged unions of tuples (equality narrowing)
Narrow unions of tuples based on literal tag elements using `==` comparison:
```py
from typing import Literal
class A: ...
class B: ...
class C: ...
def _(x: tuple[Literal["tag1"], A] | tuple[Literal["tag2"], B, C]):
if x[0] == "tag1":
reveal_type(x) # revealed: tuple[Literal["tag1"], A]
reveal_type(x[1]) # revealed: A
else:
reveal_type(x) # revealed: tuple[Literal["tag2"], B, C]
reveal_type(x[1]) # revealed: B
reveal_type(x[2]) # revealed: C
def _(x: tuple[Literal["tag1"], A] | tuple[Literal["tag2"], B, C]):
if x[0] != "tag1":
reveal_type(x) # revealed: tuple[Literal["tag2"], B, C]
else:
reveal_type(x) # revealed: tuple[Literal["tag1"], A]
# With int literals
def _(x: tuple[Literal[1], A] | tuple[Literal[2], B]):
if x[0] == 1:
reveal_type(x) # revealed: tuple[Literal[1], A]
else:
reveal_type(x) # revealed: tuple[Literal[2], B]
# With bytes literals
def _(x: tuple[Literal[b"a"], A] | tuple[Literal[b"b"], B]):
if x[0] == b"a":
reveal_type(x) # revealed: tuple[Literal[b"a"], A]
else:
reveal_type(x) # revealed: tuple[Literal[b"b"], B]
# Multiple tuple variants
def _(x: tuple[Literal["a"], A] | tuple[Literal["b"], B] | tuple[Literal["c"], C]):
if x[0] == "a":
reveal_type(x) # revealed: tuple[Literal["a"], A]
elif x[0] == "b":
reveal_type(x) # revealed: tuple[Literal["b"], B]
else:
reveal_type(x) # revealed: tuple[Literal["c"], C]
# Using index 1 instead of 0
def _(x: tuple[A, Literal["tag1"]] | tuple[B, Literal["tag2"]]):
if x[1] == "tag1":
reveal_type(x) # revealed: tuple[A, Literal["tag1"]]
else:
reveal_type(x) # revealed: tuple[B, Literal["tag2"]]
```
Narrowing is restricted to `Literal` tag elements. If any tuple has a non-literal type at the
discriminating index, we can't safely narrow with equality:
```py
def _(x: tuple[Literal["tag1"], A] | tuple[str, B]):
# Can't narrow because second tuple has `str` (not literal) at index 0
if x[0] == "tag1":
reveal_type(x) # revealed: tuple[Literal["tag1"], A] | tuple[str, B]
else:
# But we *can* narrow with inequality
reveal_type(x) # revealed: tuple[str, B]
```
If the index is out of bounds for any tuple in the union, we also skip narrowing (a diagnostic will
be emitted elsewhere for the out-of-bounds access):
```py
def _(x: tuple[A, Literal["a"]] | tuple[B]):
# error: [index-out-of-bounds]
if x[1] == "a":
# Can't narrow because index 1 is out of bounds for second tuple
reveal_type(x) # revealed: tuple[A, Literal["a"]] | tuple[B]
else:
reveal_type(x) # revealed: tuple[A, Literal["a"]] | tuple[B]
```
We can still narrow tuples when non-tuple types are present in the union:
```py
def _(x: tuple[Literal["tag1"], A] | tuple[Literal["tag2"], B] | list[int]):
if x[0] == "tag1":
# A list of ints could have int subclasses in it,
# and int subclasses could have custom `__eq__` methods such that they
# compare equal to `"tag1"`, so `list[int]` cannot be narrowed out of this
# union.
reveal_type(x) # revealed: tuple[Literal["tag1"], A] | list[int]
```
### String subscript
```py

View File

@@ -299,7 +299,7 @@ def f(l: list[str | None] | None):
def f(a: A):
if a:
def _():
reveal_type(a) # revealed: A
reveal_type(a) # revealed: A & ~AlwaysFalsy
a.x = None
```

View File

@@ -365,12 +365,12 @@ def f(
if isinstance(c, bool):
reveal_type(c) # revealed: Never
else:
reveal_type(c) # revealed: P
reveal_type(c) # revealed: P & ~AlwaysTruthy
if isinstance(d, bool):
reveal_type(d) # revealed: Never
else:
reveal_type(d) # revealed: P
reveal_type(d) # revealed: P & ~AlwaysFalsy
```
## Narrowing if an object of type `Any` or `Unknown` is used as the second argument

View File

@@ -58,9 +58,9 @@ and `tuple[()]` in the negative case (see <https://github.com/astral-sh/ty/issue
```py
def _(x: tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: tuple[int, ...]
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysFalsy
else:
reveal_type(x) # revealed: tuple[int, ...]
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysTruthy
```
## Unions of narrowable types
@@ -70,9 +70,9 @@ from typing import Literal
def _(x: Literal["foo", ""] | tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: Literal["foo"] | tuple[int, ...]
reveal_type(x) # revealed: Literal["foo"] | (tuple[int, ...] & ~AlwaysFalsy)
else:
reveal_type(x) # revealed: Literal[""] | tuple[int, ...]
reveal_type(x) # revealed: Literal[""] | (tuple[int, ...] & ~AlwaysTruthy)
```
## Types that are not narrowed
@@ -119,13 +119,13 @@ def _(lines: list[str]):
if not line:
continue
reveal_type(line) # revealed: str
reveal_type(line) # revealed: str & ~AlwaysFalsy
value = line if len(line) < 3 else ""
reveal_type(value) # revealed: str
reveal_type(value) # revealed: (str & ~AlwaysFalsy) | Literal[""]
if len(value):
# `Literal[""]` is removed, `str & ~AlwaysFalsy` is unchanged
reveal_type(value) # revealed: str
reveal_type(value) # revealed: str & ~AlwaysFalsy
# Accessing value[0] is safe here
_ = value[0]
```

View File

@@ -375,3 +375,70 @@ try:
except ValueError:
pass
```
## Narrowing tagged unions of tuples
Narrow unions of tuples based on literal tag elements in `match` statements:
```py
from typing import Literal
class A: ...
class B: ...
class C: ...
def _(x: tuple[Literal["tag1"], A] | tuple[Literal["tag2"], B, C]):
match x[0]:
case "tag1":
reveal_type(x) # revealed: tuple[Literal["tag1"], A]
reveal_type(x[1]) # revealed: A
case "tag2":
reveal_type(x) # revealed: tuple[Literal["tag2"], B, C]
reveal_type(x[1]) # revealed: B
reveal_type(x[2]) # revealed: C
case _:
reveal_type(x) # revealed: Never
# With int literals
def _(x: tuple[Literal[1], A] | tuple[Literal[2], B]):
match x[0]:
case 1:
reveal_type(x) # revealed: tuple[Literal[1], A]
case 2:
reveal_type(x) # revealed: tuple[Literal[2], B]
case _:
reveal_type(x) # revealed: Never
# With bytes literals
def _(x: tuple[Literal[b"a"], A] | tuple[Literal[b"b"], B]):
match x[0]:
case b"a":
reveal_type(x) # revealed: tuple[Literal[b"a"], A]
case b"b":
reveal_type(x) # revealed: tuple[Literal[b"b"], B]
case _:
reveal_type(x) # revealed: Never
# Using index 1 instead of 0
def _(x: tuple[A, Literal["tag1"]] | tuple[B, Literal["tag2"]]):
match x[1]:
case "tag1":
reveal_type(x) # revealed: tuple[A, Literal["tag1"]]
case "tag2":
reveal_type(x) # revealed: tuple[B, Literal["tag2"]]
case _:
reveal_type(x) # revealed: Never
```
Narrowing is restricted to `Literal` tag elements:
```py
def _(x: tuple[Literal["tag1"], A] | tuple[str, B]):
match x[0]:
case "tag1":
# Can't narrow because second tuple has `str` (not literal) at index 0
reveal_type(x) # revealed: tuple[Literal["tag1"], A] | tuple[str, B]
case _:
# But we *can* narrow with inequality
reveal_type(x) # revealed: tuple[str, B]
```

View File

@@ -82,19 +82,19 @@ class B: ...
def f(x: A | B):
if x:
reveal_type(x) # revealed: A | B
reveal_type(x) # revealed: (A & ~AlwaysFalsy) | (B & ~AlwaysFalsy)
else:
reveal_type(x) # revealed: A | B
reveal_type(x) # revealed: (A & ~AlwaysTruthy) | (B & ~AlwaysTruthy)
if x and not x:
reveal_type(x) # revealed: A | B
reveal_type(x) # revealed: (A & ~AlwaysFalsy & ~AlwaysTruthy) | (B & ~AlwaysFalsy & ~AlwaysTruthy)
else:
reveal_type(x) # revealed: A | B
if x or not x:
reveal_type(x) # revealed: A | B
else:
reveal_type(x) # revealed: A | B
reveal_type(x) # revealed: (A & ~AlwaysTruthy & ~AlwaysFalsy) | (B & ~AlwaysTruthy & ~AlwaysFalsy)
```
### Truthiness of Types
@@ -111,9 +111,9 @@ x = int if flag() else str
reveal_type(x) # revealed: <class 'int'> | <class 'str'>
if x:
reveal_type(x) # revealed: <class 'int'> | <class 'str'>
reveal_type(x) # revealed: (<class 'int'> & ~AlwaysFalsy) | (<class 'str'> & ~AlwaysFalsy)
else:
reveal_type(x) # revealed: <class 'int'> | <class 'str'>
reveal_type(x) # revealed: (<class 'int'> & ~AlwaysTruthy) | (<class 'str'> & ~AlwaysTruthy)
```
## Determined Truthiness
@@ -179,9 +179,9 @@ if isinstance(x, str) and not isinstance(x, B):
reveal_type(z) # revealed: (A & str & ~B) | Literal[0, 42, "", "hello"]
if z:
reveal_type(z) # revealed: (A & str & ~B) | Literal[42, "hello"]
reveal_type(z) # revealed: (A & str & ~B & ~AlwaysFalsy) | Literal[42, "hello"]
else:
reveal_type(z) # revealed: (A & str & ~B) | Literal[0, ""]
reveal_type(z) # revealed: (A & str & ~B & ~AlwaysTruthy) | Literal[0, ""]
```
## Narrowing Multiple Variables
@@ -219,7 +219,7 @@ x = A()
if x and not x:
y = x
reveal_type(y) # revealed: A
reveal_type(y) # revealed: A & ~AlwaysFalsy & ~AlwaysTruthy
else:
y = x
reveal_type(y) # revealed: A
@@ -264,16 +264,16 @@ def _(
):
reveal_type(ta) # revealed: type[TruthyClass] | type[AmbiguousClass]
if ta:
reveal_type(ta) # revealed: type[TruthyClass] | type[AmbiguousClass]
reveal_type(ta) # revealed: type[TruthyClass] | (type[AmbiguousClass] & ~AlwaysFalsy)
reveal_type(af) # revealed: type[AmbiguousClass] | type[FalsyClass]
if af:
reveal_type(af) # revealed: type[AmbiguousClass]
reveal_type(af) # revealed: type[AmbiguousClass] & ~AlwaysFalsy
# error: [unsupported-bool-conversion] "Boolean conversion is not supported for type `MetaDeferred`"
if d:
# TODO: Should be `Unknown`
reveal_type(d) # revealed: type[DeferredClass]
reveal_type(d) # revealed: type[DeferredClass] & ~AlwaysFalsy
tf = TruthyClass if flag else FalsyClass
reveal_type(tf) # revealed: <class 'TruthyClass'> | <class 'FalsyClass'>
@@ -296,12 +296,12 @@ def _(x: Literal[0, 1]):
reveal_type(x and A()) # revealed: Literal[0] | A
def _(x: str):
reveal_type(x or A()) # revealed: str | A
reveal_type(x and A()) # revealed: str | A
reveal_type(x or A()) # revealed: (str & ~AlwaysFalsy) | A
reveal_type(x and A()) # revealed: (str & ~AlwaysTruthy) | A
def _(x: bool | str):
reveal_type(x or A()) # revealed: Literal[True] | str | A
reveal_type(x and A()) # revealed: Literal[False] | str | A
reveal_type(x or A()) # revealed: Literal[True] | (str & ~AlwaysFalsy) | A
reveal_type(x and A()) # revealed: Literal[False] | (str & ~AlwaysTruthy) | A
class Falsy:
def __bool__(self) -> Literal[False]:

View File

@@ -12,21 +12,19 @@ from typing_extensions import TypeGuard, TypeIs
def _(
a: TypeGuard[str],
b: TypeIs[str | int],
c: TypeGuard[Intersection[complex, Not[int], Not[float]]],
c: TypeGuard[bool],
d: TypeIs[tuple[TypeOf[bytes]]],
e: TypeGuard, # error: [invalid-type-form]
f: TypeIs, # error: [invalid-type-form]
):
# TODO: Should be `TypeGuard[str]`
reveal_type(a) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(a) # revealed: TypeGuard[str]
reveal_type(b) # revealed: TypeIs[str | int]
# TODO: Should be `TypeGuard[complex & ~int & ~float]`
reveal_type(c) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(c) # revealed: TypeGuard[bool]
reveal_type(d) # revealed: TypeIs[tuple[<class 'bytes'>]]
reveal_type(e) # revealed: Unknown
reveal_type(f) # revealed: Unknown
# TODO: error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeGuard[str]`"
# error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeGuard[str]`"
def _(a) -> TypeGuard[str]: ...
# error: [invalid-return-type] "Function always implicitly returns `None`, which is not assignable to return type `TypeIs[str]`"
@@ -38,8 +36,7 @@ def g(a) -> TypeIs[str]:
return True
def _(a: object):
# TODO: Should be `TypeGuard[str @ a]`
reveal_type(f(a)) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(f(a)) # revealed: TypeGuard[str @ a]
reveal_type(g(a)) # revealed: TypeIs[str @ a]
```
@@ -96,6 +93,72 @@ def _(a: int) -> TypeIs[str]: ...
def _(a: bool | str) -> TypeIs[int]: ...
```
## Methods
Methods narrow the first positional argument after `self` or `cls`
```py
from typing import TypeGuard
class C:
def f(self, x: object) -> TypeGuard[str]:
return True
@classmethod
def g(cls, x: object) -> TypeGuard[int]:
return True
# TODO: this could error at definition time
def h(self) -> TypeGuard[str]:
return True
# TODO: this could error at definition time
@classmethod
def j(cls) -> TypeGuard[int]:
return True
def _(x: object):
if C().f(x):
reveal_type(x) # revealed: str
if C.f(C(), x):
# TODO: should be str
reveal_type(x) # revealed: object
if C.g(x):
reveal_type(x) # revealed: int
if C().g(x):
reveal_type(x) # revealed: int
if C().h(): # error: [invalid-type-guard-call] "Type guard call does not have a target"
pass
if C.j(): # error: [invalid-type-guard-call] "Type guard call does not have a target"
pass
```
```py
from typing_extensions import TypeIs
def is_int(val: object) -> TypeIs[int]:
return isinstance(val, int)
class A:
def is_int(self, val: object) -> TypeIs[int]:
return isinstance(val, int)
@classmethod
def is_int2(cls, val: object) -> TypeIs[int]:
return isinstance(val, int)
def _(x: object):
if is_int(x):
reveal_type(x) # revealed: int
if A().is_int(x):
reveal_type(x) # revealed: int
if A().is_int2(x):
reveal_type(x) # revealed: int
if A.is_int2(x):
reveal_type(x) # revealed: int
```
## Arguments to special forms
`TypeGuard` and `TypeIs` accept exactly one type argument.
@@ -105,15 +168,14 @@ from typing_extensions import TypeGuard, TypeIs
a = 123
# TODO: error: [invalid-type-form]
# error: [invalid-type-form] "Special form `typing.TypeGuard` expected exactly one type parameter"
def f(_) -> TypeGuard[int, str]: ...
# error: [invalid-type-form] "Special form `typing.TypeIs` expected exactly one type parameter"
# error: [invalid-type-form] "Variable of type `Literal[123]` is not allowed in a type expression"
def g(_) -> TypeIs[a, str]: ...
# TODO: Should be `Unknown`
reveal_type(f(0)) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(f(0)) # revealed: Unknown
reveal_type(g(0)) # revealed: Unknown
```
@@ -126,9 +188,10 @@ from typing_extensions import Literal, TypeGuard, TypeIs, assert_never
def _(a: object, flag: bool) -> TypeGuard[str]:
if flag:
# error: [invalid-return-type] "Return type does not match returned value: expected `TypeGuard[str]`, found `Literal[0]`"
return 0
# TODO: error: [invalid-return-type] "Return type does not match returned value: expected `TypeIs[str]`, found `Literal["foo"]`"
# error: [invalid-return-type] "Return type does not match returned value: expected `TypeGuard[str]`, found `Literal["foo"]`"
return "foo"
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `TypeIs[str]`"
@@ -193,8 +256,7 @@ def is_bar(a: object) -> TypeIs[Bar]:
def _(a: Foo | Bar):
if guard_foo(a):
# TODO: Should be `Foo`
reveal_type(a) # revealed: Foo | Bar
reveal_type(a) # revealed: Foo
else:
reveal_type(a) # revealed: Foo | Bar
@@ -204,6 +266,26 @@ def _(a: Foo | Bar):
reveal_type(a) # revealed: Foo & ~Bar
```
```py
from typing import TypeGuard, reveal_type
class P:
pass
class A:
pass
class B:
pass
def is_b(val: object) -> TypeGuard[B]:
return isinstance(val, B)
def _(x: P):
if isinstance(x, A) or is_b(x):
reveal_type(x) # revealed: B | (P & A)
```
Attribute and subscript narrowing is supported:
```py
@@ -215,23 +297,17 @@ class C(Generic[T]):
v: T
def _(a: tuple[Foo, Bar] | tuple[Bar, Foo], c: C[Any]):
# TODO: Should be `TypeGuard[Foo @ a[1]]`
if reveal_type(guard_foo(a[1])): # revealed: @Todo(`TypeGuard[]` special form)
# TODO: Should be `tuple[Bar, Foo]`
if reveal_type(guard_foo(a[1])): # revealed: TypeGuard[Foo @ a[1]]
reveal_type(a) # revealed: tuple[Foo, Bar] | tuple[Bar, Foo]
# TODO: Should be `Foo`
reveal_type(a[1]) # revealed: Bar | Foo
reveal_type(a[1]) # revealed: Foo
if reveal_type(is_bar(a[0])): # revealed: TypeIs[Bar @ a[0]]
# TODO: Should be `tuple[Bar, Bar & Foo]`
reveal_type(a) # revealed: tuple[Foo, Bar] | tuple[Bar, Foo]
reveal_type(a[0]) # revealed: Bar
# TODO: Should be `TypeGuard[Foo @ c.v]`
if reveal_type(guard_foo(c.v)): # revealed: @Todo(`TypeGuard[]` special form)
if reveal_type(guard_foo(c.v)): # revealed: TypeGuard[Foo @ c.v]
reveal_type(c) # revealed: C[Any]
# TODO: Should be `Foo`
reveal_type(c.v) # revealed: Any
reveal_type(c.v) # revealed: Foo
if reveal_type(is_bar(c.v)): # revealed: TypeIs[Bar @ c.v]
reveal_type(c) # revealed: C[Any]
@@ -246,8 +322,7 @@ def _(a: Foo | Bar):
c = is_bar(a)
reveal_type(a) # revealed: Foo | Bar
# TODO: Should be `TypeGuard[Foo @ a]`
reveal_type(b) # revealed: @Todo(`TypeGuard[]` special form)
reveal_type(b) # revealed: TypeGuard[Foo @ a]
reveal_type(c) # revealed: TypeIs[Bar @ a]
if b:
@@ -345,25 +420,82 @@ class Baz(Bar): ...
def guard_foo(a: object) -> TypeGuard[Foo]:
return True
def guard_bar(a: object) -> TypeGuard[Bar]:
return True
def is_bar(a: object) -> TypeIs[Bar]:
return True
def does_not_narrow_in_negative_case(a: Foo | Bar):
if not guard_foo(a):
# TODO: Should be `Bar`
reveal_type(a) # revealed: Foo | Bar
else:
reveal_type(a) # revealed: Foo | Bar
reveal_type(a) # revealed: Foo
def narrowed_type_must_be_exact(a: object, b: Baz):
if guard_foo(b):
# TODO: Should be `Foo`
reveal_type(b) # revealed: Baz
reveal_type(b) # revealed: Foo
if isinstance(a, Baz) and is_bar(a):
reveal_type(a) # revealed: Baz
if isinstance(a, Bar) and guard_foo(a):
# TODO: Should be `Foo`
reveal_type(a) # revealed: Bar
reveal_type(a) # revealed: Foo
if guard_bar(a):
reveal_type(a) # revealed: Bar
```
## TypeGuard overrides normal constraints
TypeGuard constraints override any previous narrowing, but additional "regular" constraints can be
added on to TypeGuard constraints.
```py
from typing_extensions import TypeGuard, TypeIs
class A: ...
class B: ...
class C: ...
def f(x: object) -> TypeGuard[A]:
return True
def g(x: object) -> TypeGuard[B]:
return True
def h(x: object) -> TypeIs[C]:
return True
def _(x: object):
if f(x) and g(x) and h(x):
reveal_type(x) # revealed: B & C
```
## Boolean logic with TypeGuard and TypeIs
TypeGuard constraints need to properly distribute through boolean operations.
```py
from typing_extensions import TypeGuard, TypeIs
class A: ...
class B: ...
class C: ...
def f(x: object) -> TypeIs[A]:
return True
def g(x: object) -> TypeGuard[B]:
return True
def h(x: object) -> TypeIs[C]:
return True
def _(x: object):
# g(x) or h(x) should give B | C
# Then f(x) and (...) should distribute: (f(x) and g(x)) or (f(x) and h(x))
# Which is (Regular(A) & TypeGuard(B)) | (Regular(A) & Regular(C))
# TypeGuard clobbers in the first branch, giving: B | (A & C)
if f(x) and (g(x) or h(x)):
reveal_type(x) # revealed: B | (A & C)
```

View File

@@ -140,7 +140,7 @@ type IntOrStr = int | str
def f(x: IntOrStr, y: str | bytes):
z = x or y
reveal_type(z) # revealed: int | str | bytes
reveal_type(z) # revealed: (int & ~AlwaysFalsy) | str | bytes
```
## Multiple layers of union aliases

View File

@@ -91,14 +91,14 @@ error[missing-argument]: No argument provided for required parameter `arg` of bo
7 | from typing_extensions import deprecated
|
info: Parameter declared here
--> stdlib/typing_extensions.pyi:1001:28
--> stdlib/typing_extensions.pyi:1301:28
|
999 | stacklevel: int
1000 | def __init__(self, message: LiteralString, /, *, category: type[Warning] | None = ..., stacklevel: int = 1) -> None: ...
1001 | def __call__(self, arg: _T, /) -> _T: ...
1299 | stacklevel: int
1300 | def __init__(self, message: LiteralString, /, *, category: type[Warning] | None = ..., stacklevel: int = 1) -> None: ...
1301 | def __call__(self, arg: _T, /) -> _T: ...
| ^^^^^^^
1002 |
1003 | @final
1302 |
1303 | @final
|
info: rule `missing-argument` is enabled by default

View File

@@ -57,6 +57,9 @@ reveal_type(tuple((1, 2))) # revealed: tuple[Literal[1], Literal[2]]
reveal_type(tuple([1])) # revealed: tuple[Unknown | int, ...]
x1: tuple[int, ...] = tuple([1])
reveal_type(x1) # revealed: tuple[int, ...]
# error: [invalid-argument-type]
reveal_type(tuple[int]([1])) # revealed: tuple[int]

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