Compare commits

..

62 Commits

Author SHA1 Message Date
David Peter
d440cad340 [red knot] support EllipsisType as a singleton 2024-10-15 14:38:28 +02:00
David Peter
9169e35175 Modules are singleton types as well 2024-10-15 14:26:25 +02:00
David Peter
79f809cd13 Handle non-singleton 'is not' case 2024-10-15 14:13:38 +02:00
David Peter
b219c6fc2c Add TODO comment 2024-10-15 14:11:09 +02:00
David Peter
edfaf896e4 Shorten comment regarding union types 2024-10-15 14:09:24 +02:00
David Peter
da842bbc38 Add TODO comment 2024-10-15 14:02:02 +02:00
David Peter
b425e6a34a Add explanation to 'is not' test 2024-10-15 13:23:10 +02:00
David Peter
a95b4d57c1 Use custom type instead of LiteralInt 2024-10-15 13:21:16 +02:00
David Peter
e069381a23 Fix behavior for tuple types 2024-10-15 13:18:16 +02:00
David Peter
749f176ae5 The empty tuple type is a singleton type 2024-10-15 11:11:17 +02:00
David Peter
e241c42212 Move Type::Todo to the false-branch 2024-10-15 11:02:03 +02:00
David Peter
09eeee99d4 [red knot] Fix narrowing for 'is not' conditionals 2024-10-15 10:33:46 +02:00
David Peter
04b636cba2 [red knot] Use memmem::find instead of custom version (#13750)
This is a follow-up on #13746:

- Use `memmem::find` instead of rolling our own inferior version.
- Avoid `x.as_ref()` calls using `&**x`
2024-10-14 15:17:19 +02:00
Alex Waygood
6048f331d9 [red-knot] Add a build.rs file to red_knot_python_semantic, and document pitfalls of using rstest in combination with mdtest (#13747) 2024-10-14 13:02:03 +01:00
David Peter
93097f1c53 [red-knot] feat: Inference for BytesLiteral comparisons (#13746)
Implements inference for `BytesLiteral` comparisons along the lines of
https://github.com/astral-sh/ruff/pull/13634.

closes #13687

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-14 14:01:23 +02:00
Sid
9bb4722ebf [flake8-todos] Allow words starting with todo (#13640)
Co-authored-by: Micha Reiser <micha@reiser.io>
2024-10-14 10:21:45 +00:00
Dhruv Manilawala
5caabe54b6 Allow ipytest cell magic (#13745)
## Summary

fixes: #13718 

## Test Plan

Using the notebook as mentioned in
https://github.com/astral-sh/ruff/issues/13718#issuecomment-2410631674,
this PR does not give the "F821 Undefined name `test_sorted`"
diagnostic.
2024-10-14 15:48:33 +05:30
renovate[bot]
814ab47582 Update dependency @miniflare/storage-memory to v2.14.4 (#13737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 07:52:28 +00:00
renovate[bot]
c3a3622e30 Update Rust crate libcst to v1.5.0 (#13739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 09:51:13 +02:00
renovate[bot]
4ef422d3b4 Update Rust crate clap to v4.5.20 (#13733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 09:44:55 +02:00
renovate[bot]
58bc981677 Update Rust crate pathdiff to v0.2.2 (#13734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 09:41:33 +02:00
renovate[bot]
dd5018ac55 Update dependency @miniflare/kv to v2.14.4 (#13736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 07:40:49 +00:00
renovate[bot]
63df94b521 Update Rust crate proc-macro2 to v1.0.87 (#13735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 07:39:46 +00:00
renovate[bot]
e4c0dd6f96 Update rust-wasm-bindgen monorepo (#13738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 07:38:45 +00:00
Micha Reiser
3111dce5b4 Fix mkdocs CI job (#13744) 2024-10-14 09:31:35 +02:00
Micha Reiser
8445e4725c Downgrade benchmarks CI job to ubuntu 22 (#13743) 2024-10-14 09:17:38 +02:00
Alex Waygood
defdc4dd8e [red-knot] Use colors to improve readability of mdtest output (#13725) 2024-10-13 14:20:35 +01:00
Steve C
46bc69d1d4 [flake8-pyi] - fix dropped exprs in PYI030 autofix (#13727) 2024-10-13 11:33:03 +01:00
Carl Meyer
3209953276 [red-knot] clarify mdtest README (#13720)
Address a potential point of confusion that bit a contributor in
https://github.com/astral-sh/ruff/pull/13719

Also remove a no-longer-accurate line about bare `error: ` assertions
(which are no longer allowed) and clarify another point about which
kinds of error assertions to use.
2024-10-11 12:36:48 -07:00
Carl Meyer
6ae833e0c7 [red-knot] mdtest usability improvements for reveal_type (#13709)
## Summary

Fixes #13708.

Silence `undefined-reveal` diagnostic on any line including a `#
revealed:` assertion.

Add more context to un-silenced `undefined-reveal` diagnostics in mdtest
test failures. This doesn't make the failure output less verbose, but it
hopefully clarifies the right fix for an `undefined-reveal` in mdtest,
while still making it clear what red-knot's normal diagnostic for this
looks like.

## Test Plan

Added and updated tests.
2024-10-10 17:33:53 -07:00
Carl Meyer
a3dc5c0529 [red-knot] document test framework (#13695)
This adds documentation for the new test framework.

I also added documentation for the planned design of features we haven't
built yet (clearly marked as such), so that this doc can become the sole
source of truth for the test framework design (we don't need to refer
back to the original internal design document.)

Also fixes a few issues in the test framework implementation that were
discovered in writing up the docs.

---------

Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Dhruv Manilawala <dhruvmanila@gmail.com>
2024-10-10 12:02:01 -07:00
Alex Waygood
d6b24b690a [pycodestyle] Fix whitespace-related false positives and false negatives inside type-parameter lists (#13704) 2024-10-10 17:24:17 +01:00
Alex Waygood
5b4afd30ca Harmonise methods for distinguishing different Python source types (#13682) 2024-10-09 13:18:52 +00:00
Micha Reiser
b9827a4122 Remove layout values from AnyStringPart (#13681) 2024-10-09 07:25:40 +01:00
Carl Meyer
93eff7f174 [red-knot] type inference/checking test framework (#13636)
## Summary

Adds a markdown-based test framework for writing tests of type inference
and type checking. Fixes #11664.

Implements the basic required features. A markdown test file is a suite
of tests, each test can contain one or more Python files, with
optionally specified path/name. The test writes all files to an
in-memory file system, runs red-knot, and matches the resulting
diagnostics against `Type: ` and `Error: ` assertions embedded in the
Python source as comments.

We will want to add features like incremental tests, setting custom
configuration for tests, writing non-Python files, testing syntax
errors, capturing full diagnostic output, etc. There's also plenty of
room for improved UX (colored output?).

## Test Plan

Lots of tests!

Sample of the current output when a test fails:

```
     Running tests/inference.rs (target/debug/deps/inference-7c96590aa84de2a4)

running 1 test
test inference::path_1_resources_inference_numbers_md ... FAILED

failures:

---- inference::path_1_resources_inference_numbers_md stdout ----
inference/numbers.md - Numbers - Floats
  /src/test.py
    line 2: unexpected error: [invalid-assignment] "Object of type `Literal["str"]` is not assignable to `int`"

thread 'inference::path_1_resources_inference_numbers_md' panicked at crates/red_knot_test/src/lib.rs:60:5:
Some tests failed.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    inference::path_1_resources_inference_numbers_md

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s

error: test failed, to rerun pass `-p red_knot_test --test inference`
```

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-08 12:33:19 -07:00
Micha Reiser
fc661e193a Normalize implicit concatenated f-string quotes per part (#13539) 2024-10-08 09:59:17 +00:00
Zanie Blue
42fcbef876 Fix typo in allow-unused-imports documentation (#13669) 2024-10-07 14:08:36 -05:00
Alex Waygood
71b52b83e4 [red-knot] Allow type[] to be subscripted (#13667)
Fixed a TODO by adding another TODO. It's the red-knot way!

## Summary

`builtins.type` can be subscripted at runtime on Python 3.9+, even
though it has no `__class_getitem__` method and its metaclass (which
is... itself) has no `__getitem__` method. The special case is
[hardcoded directly into `PyObject_GetItem` in
CPython](744caa8ef4/Objects/abstract.c (L181-L184)).
We just have to replicate the special case in our semantic model.

This will fail at runtime on Python <3.9. However, there's a bunch of
outstanding questions (detailed in the TODO comment I added) regarding
how we deal with subscriptions of other generic types on lower Python
versions. Since we want to avoid too many false positives for now, I
haven't tried to address this; I've just made `type` subscriptable on
all Python versions.

## Test Plan

`cargo test -p red_knot_python_semantic --lib`
2024-10-07 19:43:47 +01:00
Zanie Blue
fb90f5a13d Add known limitation to C416 with dictionaries (#13627)
Part of https://github.com/astral-sh/ruff/issues/13625

See also #13629
2024-10-07 16:20:45 +00:00
Alex Waygood
d7484e6942 [red-knot] Improve type inference for except handlers where a tuple of exception classes is caught (#13646) 2024-10-07 16:13:06 +01:00
Dylan
14ee5dbfde [refurb] Count codepoints not bytes for slice-to-remove-prefix-or-suffix (FURB188) (#13631) 2024-10-07 16:13:28 +02:00
Alex Waygood
27ac34d683 Rework S606 (start-process-with-no-shell) docs to make clear the security motivations (#13658)
Helps with #13614. This docs rewrite draws on the [documentation for the
original bandit
rule](https://bandit.readthedocs.io/en/latest/plugins/b606_start_process_with_no_shell.html).
2024-10-07 13:31:01 +01:00
Sid
31ca1c3064 [flake8-async] allow async generators (ASYNC100) (#13639)
<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

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

## Summary

Treat async generators as "await" in ASYNC100.

Fixes #13637

## Test Plan

Updated snapshot
2024-10-07 07:25:54 -05:00
qdegraaf
646e4136d7 [flake8-bugbear] Tweak B905 message to not suggest setting parameter strict= to False (#13656)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-07 11:56:17 +00:00
Alex Waygood
58a11b33da Fixup docs markup for RUF027 (#13659) 2024-10-07 11:49:45 +00:00
renovate[bot]
7856e90a2c Update pre-commit dependencies (#13650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 10:16:58 +01:00
renovate[bot]
98878c9bf2 Update dependency tomli to v2.0.2 (#13649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 10:16:43 +01:00
Aleksei Latyshev
73aa6ea417 [refurb] implement hardcoded-string-charset (FURB156) (#13530)
Co-authored-by: Micha Reiser <micha@reiser.io>
2024-10-07 07:35:14 +00:00
renovate[bot]
38d872ea4c Update Rust crate hashbrown to 0.15.0 (#13652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
2024-10-07 08:50:59 +02:00
renovate[bot]
824def2194 Update dependency ruff to v0.6.9 (#13648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 08:15:58 +02:00
renovate[bot]
2ab78dd6a5 Update NPM Development dependencies (#13651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 08:15:26 +02:00
renovate[bot]
03fa7f64dd Update Rust crate clap to v4.5.19 (#13647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 08:05:40 +02:00
renovate[bot]
43330225be Update Rust crate serde_with to v3.11.0 (#13655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 08:04:04 +02:00
renovate[bot]
383d9d9f6e Update Rust crate once_cell to v1.20.2 (#13653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-07 08:03:12 +02:00
Simon
8108f83810 [red-knot] feat: add StringLiteral and LiteralString comparison (#13634)
## Summary

Implements string literal comparisons and fallbacks to `str` instance
for `LiteralString`.
Completes an item in #13618

## Test Plan

- Adds a dedicated test with non exhaustive cases

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-05 12:22:30 -07:00
Simon
f1205177fd [red-knot] fix: when simplifying union, True & False -> instance(bool) (#13644) 2024-10-05 19:01:10 +01:00
Simon
1c2cafc101 [red-knot] more ergonomic and efficient handling of known builtin classes (#13615) 2024-10-05 18:03:46 +01:00
Alex Waygood
7c5a7d909c [red-knot] Improve tests relating to type inference for exception handlers (#13643) 2024-10-05 16:59:36 +00:00
Zanie Blue
2a365bb278 Mark PLE1141 fix as unsafe (#13629)
Closes https://github.com/astral-sh/ruff/issues/13343

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-04 14:22:26 -05:00
Zanie Blue
020f4d4a54 Add test cases for RUF006 with lambdas (#13628)
As discussed in https://github.com/astral-sh/ruff/issues/13619
2024-10-04 14:09:43 -05:00
Simon
888930b7d3 [red-knot] feat: implement integer comparison (#13571)
## Summary

Implements the comparison operator for `[Type::IntLiteral]` and
`[Type::BooleanLiteral]` (as an artifact of special handling of `True` and
`False` in python).
Sets the framework to implement more comparison for types known at
static time (e.g. `BooleanLiteral`, `StringLiteral`), allowing us to only
implement cases of the triplet `<left> Type`, `<right> Type`, `CmpOp`.
Contributes to #12701 (without checking off an item yet).

## Test Plan

- Added a test for the comparison of literals that should include most
cases of note.
- Added a test for the comparison of int instances

Please note that the cases do not cover 100% of the branches as there
are many and the current testing strategy with variables make this
fairly confusing once we have too many in one test.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-10-04 10:40:59 -07:00
Zanie Blue
d726f09cf0 Fix PTH123 false positive when open is passed a file descriptor (#13616)
Closes https://github.com/astral-sh/ruff/issues/12871

Includes some minor semantic type inference extensions changes to help
with reliably detecting integers
2024-10-04 08:48:47 -05:00
98 changed files with 6706 additions and 766 deletions

View File

@@ -148,7 +148,7 @@ jobs:
# sync, not just public items. Eventually we should do this for all
# crates; for now add crates here as they are warning-clean to prevent
# regression.
- run: cargo doc --no-deps -p red_knot_python_semantic -p red_knot -p ruff_db --document-private-items
- run: cargo doc --no-deps -p red_knot_python_semantic -p red_knot -p red_knot_test -p ruff_db --document-private-items
env:
# Setting RUSTDOCFLAGS because `cargo doc --check` isn't yet implemented (https://github.com/rust-lang/cargo/issues/10025).
RUSTDOCFLAGS: "-D warnings"
@@ -518,6 +518,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: "Add SSH key"
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
uses: webfactory/ssh-agent@v0.9.0
@@ -525,13 +527,15 @@ jobs:
ssh-private-key: ${{ secrets.MKDOCS_INSIDERS_SSH_KEY }}
- name: "Install Rust toolchain"
run: rustup show
- name: Install uv
uses: astral-sh/setup-uv@v3
- uses: Swatinem/rust-cache@v2
- name: "Install Insiders dependencies"
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
run: pip install -r docs/requirements-insiders.txt
run: uv pip install -r docs/requirements-insiders.txt --system
- name: "Install dependencies"
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS != 'true' }}
run: pip install -r docs/requirements.txt
run: uv pip install -r docs/requirements.txt --system
- name: "Update README File"
run: python scripts/transform_readme.py --target mkdocs
- name: "Generate docs"
@@ -608,7 +612,7 @@ jobs:
just test
benchmarks:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
needs: determine_changes
if: ${{ github.repository == 'astral-sh/ruff' && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
timeout-minutes: 20

View File

@@ -28,6 +28,7 @@ repos:
additional_dependencies:
- mdformat-mkdocs
- mdformat-admon
- mdformat-footnote
exclude: |
(?x)^(
docs/formatter/black\.md
@@ -45,7 +46,7 @@ repos:
)$
- repo: https://github.com/crate-ci/typos
rev: v1.24.6
rev: v1.25.0
hooks:
- id: typos
@@ -59,7 +60,7 @@ repos:
pass_filenames: false # This makes it a lot faster
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.6.9
hooks:
- id: ruff-format
- id: ruff

188
Cargo.lock generated
View File

@@ -36,12 +36,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5"
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -353,9 +347,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.18"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3"
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
dependencies = [
"clap_builder",
"clap_derive",
@@ -363,9 +357,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.18"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b"
checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
dependencies = [
"anstream",
"anstyle",
@@ -714,7 +708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
@@ -728,7 +722,7 @@ checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
@@ -1026,16 +1020,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
"hashbrown 0.14.5",
]
[[package]]
@@ -1127,7 +1126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc9da1a252bd44cd341657203722352efc9bc0c847d06ea6d2dc1cd1135e0a01"
dependencies = [
"ahash",
"hashbrown",
"hashbrown 0.14.5",
]
[[package]]
@@ -1147,7 +1146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.14.5",
"serde",
]
@@ -1312,9 +1311,9 @@ checksum = "8b23360e99b8717f20aaa4598f5a6541efbe30630039fbc7706cf954a87947ae"
[[package]]
name = "js-sys"
version = "0.3.70"
version = "0.3.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9"
dependencies = [
"wasm-bindgen",
]
@@ -1353,9 +1352,9 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
[[package]]
name = "libcst"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10293a04a48e8b0cb2cc825a93b83090e527bffd3c897a0255ad7bc96079e920"
checksum = "1586dd7a857d8a61a577afde1a24cc9573ff549eff092d5ce968b1ec93cc61b6"
dependencies = [
"chic",
"libcst_derive",
@@ -1626,9 +1625,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "once_cell"
version = "1.19.0"
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "oorandom"
@@ -1691,9 +1690,9 @@ dependencies = [
[[package]]
name = "paste"
version = "1.0.14"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "path-absolutize"
@@ -1721,15 +1720,15 @@ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
[[package]]
name = "pathdiff"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361"
[[package]]
name = "peg"
version = "0.8.2"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "400bcab7d219c38abf8bd7cc2054eb9bbbd4312d66f6a5557d572a203f646f61"
checksum = "295283b02df346d1ef66052a757869b2876ac29a6bb0ac3f5f7cd44aebe40e8f"
dependencies = [
"peg-macros",
"peg-runtime",
@@ -1737,9 +1736,9 @@ dependencies = [
[[package]]
name = "peg-macros"
version = "0.8.2"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e61cce859b76d19090f62da50a9fe92bab7c2a5f09e183763559a2ac392c90"
checksum = "bdad6a1d9cf116a059582ce415d5f5566aabcd4008646779dab7fdc2a9a9d426"
dependencies = [
"peg-runtime",
"proc-macro2",
@@ -1748,9 +1747,9 @@ dependencies = [
[[package]]
name = "peg-runtime"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36bae92c60fa2398ce4678b98b2c4b5a7c61099961ca1fa305aec04a9ad28922"
checksum = "e3aeb8f54c078314c2065ee649a7241f46b9d8e418e1a9581ba0546657d7aa3a"
[[package]]
name = "pep440_rs"
@@ -1944,9 +1943,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.86"
version = "1.0.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a"
dependencies = [
"unicode-ident",
]
@@ -2081,10 +2080,14 @@ dependencies = [
"camino",
"compact_str",
"countme",
"hashbrown",
"hashbrown 0.15.0",
"insta",
"itertools 0.13.0",
"memchr",
"ordermap",
"red_knot_test",
"red_knot_vendored",
"rstest",
"ruff_db",
"ruff_index",
"ruff_python_ast",
@@ -2127,6 +2130,26 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "red_knot_test"
version = "0.0.0"
dependencies = [
"anyhow",
"colored",
"once_cell",
"red_knot_python_semantic",
"red_knot_vendored",
"regex",
"ruff_db",
"ruff_index",
"ruff_python_trivia",
"ruff_source_file",
"ruff_text_size",
"rustc-hash 2.0.0",
"salsa",
"smallvec",
]
[[package]]
name = "red_knot_vendored"
version = "0.0.0"
@@ -2247,6 +2270,12 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "ring"
version = "0.17.8"
@@ -2262,6 +2291,33 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rstest"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936"
dependencies = [
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42"
dependencies = [
"cfg-if",
"glob",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn",
"unicode-ident",
]
[[package]]
name = "ruff"
version = "0.6.9"
@@ -2412,7 +2468,6 @@ dependencies = [
"ruff_python_codegen",
"ruff_python_formatter",
"ruff_python_parser",
"ruff_python_stdlib",
"ruff_python_trivia",
"ruff_workspace",
"schemars",
@@ -2885,6 +2940,15 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.37"
@@ -3030,6 +3094,12 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "semver"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
[[package]]
name = "serde"
version = "1.0.210"
@@ -3115,9 +3185,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.9.0"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857"
checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817"
dependencies = [
"serde",
"serde_derive",
@@ -3126,9 +3196,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.9.0"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350"
checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d"
dependencies = [
"darling",
"proc-macro2",
@@ -3281,12 +3351,12 @@ dependencies = [
[[package]]
name = "terminal_size"
version = "0.3.0"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef"
dependencies = [
"rustix",
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3788,9 +3858,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.93"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
dependencies = [
"cfg-if",
"once_cell",
@@ -3799,9 +3869,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.93"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
dependencies = [
"bumpalo",
"log",
@@ -3814,9 +3884,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.43"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed"
checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b"
dependencies = [
"cfg-if",
"js-sys",
@@ -3826,9 +3896,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.93"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3836,9 +3906,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.93"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
dependencies = [
"proc-macro2",
"quote",
@@ -3849,15 +3919,15 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.93"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
[[package]]
name = "wasm-bindgen-test"
version = "0.3.43"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9"
checksum = "d381749acb0943d357dcbd8f0b100640679883fcdeeef04def49daf8d33a5426"
dependencies = [
"console_error_panic_hook",
"js-sys",
@@ -3870,9 +3940,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.43"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021"
checksum = "c97b2ef2c8d627381e51c071c2ab328eac606d3f69dd82bcbca20a9e389d95f0"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -39,6 +39,7 @@ ruff_workspace = { path = "crates/ruff_workspace" }
red_knot_python_semantic = { path = "crates/red_knot_python_semantic" }
red_knot_server = { path = "crates/red_knot_server" }
red_knot_test = { path = "crates/red_knot_test" }
red_knot_workspace = { path = "crates/red_knot_workspace", default-features = false }
aho-corasick = { version = "1.1.3" }
@@ -72,7 +73,10 @@ filetime = { version = "0.2.23" }
glob = { version = "0.3.1" }
globset = { version = "0.4.14" }
globwalk = { version = "0.9.1" }
hashbrown = "0.14.3"
hashbrown = { version = "0.15.0", default-features = false, features = [
"raw-entry",
"inline-more",
] }
ignore = { version = "0.4.22" }
imara-diff = { version = "0.1.5" }
imperative = { version = "1.0.4" }
@@ -90,7 +94,7 @@ libcst = { version = "1.1.0", default-features = false }
log = { version = "0.4.17" }
lsp-server = { version = "0.7.6" }
lsp-types = { git = "https://github.com/astral-sh/lsp-types.git", rev = "3512a9f", features = [
"proposed",
"proposed",
] }
matchit = { version = "0.8.1" }
memchr = { version = "2.7.1" }
@@ -111,6 +115,7 @@ quote = { version = "1.0.23" }
rand = { version = "0.8.5" }
rayon = { version = "1.10.0" }
regex = { version = "1.10.2" }
rstest = { version = "0.22.0", default-features = false }
rustc-hash = { version = "2.0.0" }
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "4a7c955255e707e64e43f3ce5eabb771ae067768" }
schemars = { version = "0.8.16" }
@@ -120,7 +125,7 @@ serde-wasm-bindgen = { version = "0.6.4" }
serde_json = { version = "1.0.113" }
serde_test = { version = "1.0.152" }
serde_with = { version = "3.6.0", default-features = false, features = [
"macros",
"macros",
] }
shellexpand = { version = "3.0.0" }
similar = { version = "2.4.0", features = ["inline"] }
@@ -137,7 +142,10 @@ toml = { version = "0.8.11" }
tracing = { version = "0.1.40" }
tracing-flame = { version = "0.2.0" }
tracing-indicatif = { version = "0.3.6" }
tracing-subscriber = { version = "0.3.18", default-features = false, features = ["env-filter", "fmt"] }
tracing-subscriber = { version = "0.3.18", default-features = false, features = [
"env-filter",
"fmt",
] }
tracing-tree = { version = "0.4.0" }
typed-arena = { version = "2.0.2" }
unic-ucd-category = { version = "0.9" }
@@ -148,10 +156,10 @@ unicode-normalization = { version = "0.1.23" }
ureq = { version = "2.9.6" }
url = { version = "2.5.0" }
uuid = { version = "1.6.1", features = [
"v4",
"fast-rng",
"macro-diagnostics",
"js",
"v4",
"fast-rng",
"macro-diagnostics",
"js",
] }
walkdir = { version = "2.3.2" }
wasm-bindgen = { version = "0.2.92" }
@@ -162,7 +170,10 @@ zip = { version = "0.6.6", default-features = false }
[workspace.lints.rust]
unsafe_code = "warn"
unreachable_pub = "warn"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)", "cfg(codspeed)"] }
unexpected_cfgs = { level = "warn", check-cfg = [
"cfg(fuzzing)",
"cfg(codspeed)",
] }
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -2 }
@@ -245,23 +256,23 @@ windows-archive = ".zip"
unix-archive = ".tar.gz"
# Target platforms to build apps for (Rust target-triple syntax)
targets = [
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"arm-unknown-linux-musleabihf",
"armv7-unknown-linux-gnueabihf",
"armv7-unknown-linux-musleabihf",
"i686-pc-windows-msvc",
"i686-unknown-linux-gnu",
"i686-unknown-linux-musl",
"powerpc64-unknown-linux-gnu",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"arm-unknown-linux-musleabihf",
"armv7-unknown-linux-gnueabihf",
"armv7-unknown-linux-musleabihf",
"i686-pc-windows-msvc",
"i686-unknown-linux-gnu",
"i686-unknown-linux-musl",
"powerpc64-unknown-linux-gnu",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
]
# Whether to auto-include files like READMEs, LICENSEs, and CHANGELOGs (default true)
auto-includes = false
@@ -280,7 +291,11 @@ local-artifacts-jobs = ["./build-binaries", "./build-docker"]
# Publish jobs to run in CI
publish-jobs = ["./publish-pypi", "./publish-wasm"]
# Post-announce jobs to run in CI
post-announce-jobs = ["./notify-dependents", "./publish-docs", "./publish-playground"]
post-announce-jobs = [
"./notify-dependents",
"./publish-docs",
"./publish-playground",
]
# Custom permissions for GitHub Jobs
github-custom-job-permissions = { "build-docker" = { packages = "write", contents = "read" }, "publish-wasm" = { contents = "read", id-token = "write", packages = "write" } }
# Whether to install an updater program

View File

@@ -24,6 +24,7 @@ bitflags = { workspace = true }
camino = { workspace = true }
compact_str = { workspace = true }
countme = { workspace = true }
itertools = { workspace = true}
ordermap = { workspace = true }
salsa = { workspace = true }
thiserror = { workspace = true }
@@ -33,14 +34,17 @@ hashbrown = { workspace = true }
smallvec = { workspace = true }
static_assertions = { workspace = true }
test-case = { workspace = true }
memchr = { workspace = true }
[dev-dependencies]
ruff_db = { workspace = true, features = ["os", "testing"] }
ruff_python_parser = { workspace = true }
red_knot_test = { workspace = true }
red_knot_vendored = { workspace = true }
anyhow = { workspace = true }
insta = { workspace = true }
rstest = { workspace = true }
tempfile = { workspace = true }
[lints]

View File

@@ -0,0 +1,4 @@
/// Rebuild the crate if a test file is added or removed from
pub fn main() {
println!("cargo:rerun-if-changed=resources/mdtest");
}

View File

@@ -0,0 +1,4 @@
Markdown files within the `mdtest/` subdirectory are tests of type inference and type checking;
executed by the `tests/mdtest.rs` integration test.
See `crates/red_knot_test/README.md` for documentation of this test format.

View File

@@ -0,0 +1,43 @@
### Comparison: Byte literals
These tests assert that we infer precise `Literal` types for comparisons between objects
inferred as having `Literal` bytes types:
```py
reveal_type(b"abc" == b"abc") # revealed: Literal[True]
reveal_type(b"abc" == b"ab") # revealed: Literal[False]
reveal_type(b"abc" != b"abc") # revealed: Literal[False]
reveal_type(b"abc" != b"ab") # revealed: Literal[True]
reveal_type(b"abc" < b"abd") # revealed: Literal[True]
reveal_type(b"abc" < b"abb") # revealed: Literal[False]
reveal_type(b"abc" <= b"abc") # revealed: Literal[True]
reveal_type(b"abc" <= b"abb") # revealed: Literal[False]
reveal_type(b"abc" > b"abd") # revealed: Literal[False]
reveal_type(b"abc" > b"abb") # revealed: Literal[True]
reveal_type(b"abc" >= b"abc") # revealed: Literal[True]
reveal_type(b"abc" >= b"abd") # revealed: Literal[False]
reveal_type(b"" in b"") # revealed: Literal[True]
reveal_type(b"" in b"abc") # revealed: Literal[True]
reveal_type(b"abc" in b"") # revealed: Literal[False]
reveal_type(b"ab" in b"abc") # revealed: Literal[True]
reveal_type(b"abc" in b"abc") # revealed: Literal[True]
reveal_type(b"d" in b"abc") # revealed: Literal[False]
reveal_type(b"ac" in b"abc") # revealed: Literal[False]
reveal_type(b"\x81\x82" in b"\x80\x81\x82") # revealed: Literal[True]
reveal_type(b"\x82\x83" in b"\x80\x81\x82") # revealed: Literal[False]
reveal_type(b"ab" not in b"abc") # revealed: Literal[False]
reveal_type(b"ac" not in b"abc") # revealed: Literal[True]
reveal_type(b"abc" is b"abc") # revealed: bool
reveal_type(b"abc" is b"ab") # revealed: Literal[False]
reveal_type(b"abc" is not b"abc") # revealed: bool
reveal_type(b"abc" is not b"ab") # revealed: Literal[True]
```

View File

@@ -0,0 +1,29 @@
# Narrowing for `is` conditionals
## `is None`
```py
x = None if flag else 1
if x is None:
# TODO the following should be simplified to 'None'
reveal_type(x) # revealed: None | Literal[1] & None
reveal_type(x) # revealed: None | Literal[1]
```
## `is` for other types
```py
class A:
...
x = A()
y = x if flag else None
if y is x:
# TODO the following should be simplified to 'A'
reveal_type(y) # revealed: A | None & A
reveal_type(y) # revealed: A | None
```

View File

@@ -0,0 +1,40 @@
# Narrowing for `is not` conditionals
## `is not None`
The type guard removes `None` from the union type:
```py
x = None if flag else 1
if x is not None:
reveal_type(x) # revealed: Literal[1]
reveal_type(x) # revealed: None | Literal[1]
```
## `is not` for other singleton types
```py
x = True if flag else False
reveal_type(x) # revealed: bool
if x is not False:
# TODO the following should be `Literal[True]`
reveal_type(x) # revealed: bool & ~Literal[False]
```
## `is not` for non-singleton types
Non-singleton types should *not* narrow the type: two instances of a
non-singleton class may occupy different addresses in memory even if
they compare equal.
```py
x = [1]
y = [1]
if x is not y:
# TODO: should include type parameter: list[int]
reveal_type(x) # revealed: list
```

View File

@@ -0,0 +1,17 @@
# Narrowing for `match` statements
## Single `match` pattern
```py
x = None if flag else 1
reveal_type(x) # revealed: None | Literal[1]
y = 0
match x:
case None:
y = x
# TODO intersection simplification: should be just Literal[0] | None
reveal_type(y) # revealed: Literal[0] | None | Literal[1] & None
```

View File

@@ -0,0 +1,35 @@
# Numbers
## Integers
### Literals
We can infer an integer literal type:
```py
reveal_type(1) # revealed: Literal[1]
```
### Overflow
We only track integer literals within the range of an i64:
```py
reveal_type(9223372036854775808) # revealed: int
```
## Floats
There aren't literal float types, but we infer the general float type:
```py
reveal_type(1.0) # revealed: float
```
## Complex
Same for complex:
```py
reveal_type(2j) # revealed: complex
```

View File

@@ -1,7 +1,7 @@
use std::iter::FusedIterator;
use std::sync::Arc;
use rustc_hash::FxHashMap;
use rustc_hash::{FxBuildHasher, FxHashMap};
use salsa::plumbing::AsId;
use ruff_db::files::File;
@@ -31,7 +31,7 @@ pub(crate) use self::use_def::{
BindingWithConstraints, BindingWithConstraintsIterator, DeclarationsIterator,
};
type SymbolMap = hashbrown::HashMap<ScopedSymbolId, (), ()>;
type SymbolMap = hashbrown::HashMap<ScopedSymbolId, (), FxBuildHasher>;
/// Returns the semantic index for `file`.
///

View File

@@ -14,10 +14,10 @@ use crate::stdlib::{
builtins_symbol_ty, types_symbol_ty, typeshed_symbol_ty, typing_extensions_symbol_ty,
};
use crate::types::narrow::narrowing_constraint;
use crate::{Db, FxOrderSet};
use crate::{Db, FxOrderSet, Module};
pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder};
pub(crate) use self::diagnostic::TypeCheckDiagnostics;
pub use self::diagnostic::{TypeCheckDiagnostic, TypeCheckDiagnostics};
pub(crate) use self::display::TypeArrayDisplay;
pub(crate) use self::infer::{
infer_deferred_types, infer_definition_types, infer_expression_types, infer_scope_types,
@@ -385,14 +385,6 @@ impl<'db> Type<'db> {
}
}
pub fn builtin_str_instance(db: &'db dyn Db) -> Self {
builtins_symbol_ty(db, "str").to_instance(db)
}
pub fn builtin_int_instance(db: &'db dyn Db) -> Self {
builtins_symbol_ty(db, "int").to_instance(db)
}
pub fn is_stdlib_symbol(&self, db: &'db dyn Db, module_name: &str, name: &str) -> bool {
match self {
Type::Class(class) => class.is_stdlib_symbol(db, module_name, name),
@@ -423,19 +415,17 @@ impl<'db> Type<'db> {
(_, Type::Unknown | Type::Any | Type::Todo) => false,
(Type::Never, _) => true,
(_, Type::Never) => false,
(Type::IntLiteral(_), Type::Instance(class))
if class.is_stdlib_symbol(db, "builtins", "int") =>
{
(Type::IntLiteral(_), Type::Instance(class)) if class.is_known(db, KnownClass::Int) => {
true
}
(Type::StringLiteral(_), Type::LiteralString) => true,
(Type::StringLiteral(_) | Type::LiteralString, Type::Instance(class))
if class.is_stdlib_symbol(db, "builtins", "str") =>
if class.is_known(db, KnownClass::Str) =>
{
true
}
(Type::BytesLiteral(_), Type::Instance(class))
if class.is_stdlib_symbol(db, "builtins", "bytes") =>
if class.is_known(db, KnownClass::Bytes) =>
{
true
}
@@ -443,8 +433,8 @@ impl<'db> Type<'db> {
.elements(db)
.iter()
.any(|&elem_ty| ty.is_subtype_of(db, elem_ty)),
(_, Type::Instance(class)) if class.is_stdlib_symbol(db, "builtins", "object") => true,
(Type::Instance(class), _) if class.is_stdlib_symbol(db, "builtins", "object") => false,
(_, Type::Instance(class)) if class.is_known(db, KnownClass::Object) => true,
(Type::Instance(class), _) if class.is_known(db, KnownClass::Object) => false,
// TODO
_ => false,
}
@@ -473,6 +463,61 @@ impl<'db> Type<'db> {
self == other
}
/// Return true if there is just a single inhabitant for this type.
///
/// Note: This function aims to have no false positives, but might return `false`
/// for more complicated types that are actually singletons.
pub(crate) fn is_singleton(self, db: &'db dyn Db) -> bool {
match self {
Type::Any
| Type::Never
| Type::Unknown
| Type::Todo
| Type::Unbound
| Type::IntLiteral(..)
| Type::StringLiteral(..)
| Type::BytesLiteral(..)
| Type::LiteralString => {
// Note: The literal types included in this pattern are not true singletons.
// There can be multiple Python objects (at different memory locations) that
// are both of type Literal[345], for example.
false
}
Type::None
| Type::BooleanLiteral(_)
| Type::Function(..)
| Type::Class(..)
| Type::Module(..) => true,
Type::Instance(class) => class.is_known(db, KnownClass::EllipsisType),
Type::Tuple(tuple) => {
// We deliberately deviate from the language specification [1] here and claim
// that the empty tuple type is a singleton type. The reasoning is that `()`
// is often used as a sentinel value in user code. Declaring the empty tuple to
// be of singleton type allows us to narrow types in `is not ()` conditionals.
//
// [1] https://docs.python.org/3/reference/expressions.html#parenthesized-forms
tuple.elements(db).is_empty()
}
Type::Union(..) => {
// A single-element union, where the sole element was a singleton, would itself
// be a singleton type. However, unions with length < 2 should never appear in
// our model due to [`UnionBuilder::build`].
false
}
Type::Intersection(..) => {
// Intersection types are hard to analyze. The following types are technically
// all singleton types, but it is not straightforward to compute this. Again,
// we simply return false.
//
// bool & ~Literal[False]`
// None & (None | int)
// (A | B) & (B | C) with A, B, C disjunct and B a singleton
//
false
}
}
}
/// Resolve a member access of a type.
///
/// For example, if `foo` is `Type::Instance(<Bar>)`,
@@ -600,9 +645,9 @@ impl<'db> Type<'db> {
fn call(self, db: &'db dyn Db, arg_types: &[Type<'db>]) -> CallOutcome<'db> {
match self {
// TODO validate typed call arguments vs callable signature
Type::Function(function_type) => match function_type.kind(db) {
FunctionKind::Ordinary => CallOutcome::callable(function_type.return_type(db)),
FunctionKind::RevealType => CallOutcome::revealed(
Type::Function(function_type) => match function_type.known(db) {
None => CallOutcome::callable(function_type.return_type(db)),
Some(KnownFunction::RevealType) => CallOutcome::revealed(
function_type.return_type(db),
*arg_types.first().unwrap_or(&Type::Unknown),
),
@@ -610,16 +655,15 @@ impl<'db> Type<'db> {
// TODO annotated return type on `__new__` or metaclass `__call__`
Type::Class(class) => {
// If the class is the builtin-bool class (for example `bool(1)`), we try to return
// the specific truthiness value of the input arg, `Literal[True]` for the example above.
let is_bool = class.is_stdlib_symbol(db, "builtins", "bool");
CallOutcome::callable(if is_bool {
arg_types
CallOutcome::callable(match class.known(db) {
// If the class is the builtin-bool class (for example `bool(1)`), we try to
// return the specific truthiness value of the input arg, `Literal[True]` for
// the example above.
Some(KnownClass::Bool) => arg_types
.first()
.map(|arg| arg.bool(db).into_type(db))
.unwrap_or(Type::BooleanLiteral(false))
} else {
Type::Instance(class)
.unwrap_or(Type::BooleanLiteral(false)),
_ => Type::Instance(class),
})
}
@@ -714,7 +758,7 @@ impl<'db> Type<'db> {
let dunder_get_item_method = iterable_meta_type.member(db, "__getitem__");
dunder_get_item_method
.call(db, &[self, builtins_symbol_ty(db, "int").to_instance(db)])
.call(db, &[self, KnownClass::Int.to_instance(db)])
.return_ty(db)
.map(|element_ty| IterationOutcome::Iterable { element_ty })
.unwrap_or(IterationOutcome::NotIterable {
@@ -758,17 +802,17 @@ impl<'db> Type<'db> {
Type::Never => Type::Never,
Type::Instance(class) => Type::Class(*class),
Type::Union(union) => union.map(db, |ty| ty.to_meta_type(db)),
Type::BooleanLiteral(_) => builtins_symbol_ty(db, "bool"),
Type::BytesLiteral(_) => builtins_symbol_ty(db, "bytes"),
Type::IntLiteral(_) => builtins_symbol_ty(db, "int"),
Type::Function(_) => types_symbol_ty(db, "FunctionType"),
Type::Module(_) => types_symbol_ty(db, "ModuleType"),
Type::Tuple(_) => builtins_symbol_ty(db, "tuple"),
Type::None => typeshed_symbol_ty(db, "NoneType"),
Type::BooleanLiteral(_) => KnownClass::Bool.to_class(db),
Type::BytesLiteral(_) => KnownClass::Bytes.to_class(db),
Type::IntLiteral(_) => KnownClass::Int.to_class(db),
Type::Function(_) => KnownClass::FunctionType.to_class(db),
Type::Module(_) => KnownClass::ModuleType.to_class(db),
Type::Tuple(_) => KnownClass::Tuple.to_class(db),
Type::None => KnownClass::NoneType.to_class(db),
// TODO not accurate if there's a custom metaclass...
Type::Class(_) => builtins_symbol_ty(db, "type"),
Type::Class(_) => KnownClass::Type.to_class(db),
// TODO can we do better here? `type[LiteralString]`?
Type::StringLiteral(_) | Type::LiteralString => builtins_symbol_ty(db, "str"),
Type::StringLiteral(_) | Type::LiteralString => KnownClass::Str.to_class(db),
// TODO: `type[Any]`?
Type::Any => Type::Todo,
// TODO: `type[Unknown]`?
@@ -790,7 +834,7 @@ impl<'db> Type<'db> {
Type::IntLiteral(_) | Type::BooleanLiteral(_) => self.repr(db),
Type::StringLiteral(_) | Type::LiteralString => *self,
// TODO: handle more complex types
_ => Type::builtin_str_instance(db),
_ => KnownClass::Str.to_instance(db),
}
}
@@ -813,7 +857,7 @@ impl<'db> Type<'db> {
})),
Type::LiteralString => Type::LiteralString,
// TODO: handle more complex types
_ => Type::builtin_str_instance(db),
_ => KnownClass::Str.to_instance(db),
}
}
}
@@ -824,6 +868,143 @@ impl<'db> From<&Type<'db>> for Type<'db> {
}
}
/// Non-exhaustive enumeration of known classes (e.g. `builtins.int`, `typing.Any`, ...) to allow
/// for easier syntax when interacting with very common classes.
///
/// Feel free to expand this enum if you ever find yourself using the same class in multiple
/// places.
/// Note: good candidates are any classes in `[crate::stdlib::CoreStdlibModule]`
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KnownClass {
// To figure out where an stdlib symbol is defined, you can go into `crates/red_knot_vendored`
// and grep for the symbol name in any `.pyi` file.
// Builtins
Bool,
Object,
Bytes,
Type,
Int,
Float,
Str,
List,
Tuple,
Set,
Dict,
// Types
GenericAlias,
ModuleType,
FunctionType,
EllipsisType,
// Typeshed
NoneType, // Part of `types` for Python >= 3.10
}
impl<'db> KnownClass {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Bool => "bool",
Self::Object => "object",
Self::Bytes => "bytes",
Self::Type => "type",
Self::Int => "int",
Self::Float => "float",
Self::Str => "str",
Self::List => "list",
Self::Tuple => "tuple",
Self::Set => "set",
Self::Dict => "dict",
Self::GenericAlias => "GenericAlias",
Self::ModuleType => "ModuleType",
Self::FunctionType => "FunctionType",
Self::EllipsisType => "EllipsisType",
Self::NoneType => "NoneType",
}
}
pub fn to_instance(&self, db: &'db dyn Db) -> Type<'db> {
self.to_class(db).to_instance(db)
}
pub fn to_class(&self, db: &'db dyn Db) -> Type<'db> {
match self {
Self::Bool
| Self::Object
| Self::Bytes
| Self::Type
| Self::Int
| Self::Float
| Self::Str
| Self::List
| Self::Tuple
| Self::Set
| Self::Dict => builtins_symbol_ty(db, self.as_str()),
Self::GenericAlias | Self::ModuleType | Self::FunctionType | Self::EllipsisType => {
types_symbol_ty(db, self.as_str())
}
Self::NoneType => typeshed_symbol_ty(db, self.as_str()),
}
}
pub fn maybe_from_module(module: &Module, class_name: &str) -> Option<Self> {
let candidate = Self::from_name(class_name)?;
if candidate.check_module(module) {
Some(candidate)
} else {
None
}
}
fn from_name(name: &str) -> Option<Self> {
// Note: if this becomes hard to maintain (as rust can't ensure at compile time that all
// variants of `Self` are covered), we might use a macro (in-house or dependency)
// See: https://stackoverflow.com/q/39070244
match name {
"bool" => Some(Self::Bool),
"object" => Some(Self::Object),
"bytes" => Some(Self::Bytes),
"tuple" => Some(Self::Tuple),
"type" => Some(Self::Type),
"int" => Some(Self::Int),
"float" => Some(Self::Float),
"str" => Some(Self::Str),
"set" => Some(Self::Set),
"dict" => Some(Self::Dict),
"list" => Some(Self::List),
"GenericAlias" => Some(Self::GenericAlias),
"NoneType" => Some(Self::NoneType),
"ModuleType" => Some(Self::ModuleType),
"FunctionType" => Some(Self::FunctionType),
"EllipsisType" => Some(Self::EllipsisType),
_ => None,
}
}
/// Private method checking if known class can be defined in the given module.
fn check_module(self, module: &Module) -> bool {
if !module.search_path().is_standard_library() {
return false;
}
match self {
Self::Bool
| Self::Object
| Self::Bytes
| Self::Type
| Self::Int
| Self::Float
| Self::Str
| Self::List
| Self::Tuple
| Self::Set
| Self::Dict => module.name() == "builtins",
Self::GenericAlias | Self::ModuleType | Self::FunctionType | Self::EllipsisType => {
module.name() == "types"
}
Self::NoneType => matches!(module.name().as_str(), "_typeshed" | "types"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CallOutcome<'db> {
Callable {
@@ -1128,7 +1309,7 @@ impl Truthiness {
match self {
Self::AlwaysTrue => Type::BooleanLiteral(true),
Self::AlwaysFalse => Type::BooleanLiteral(false),
Self::Ambiguous => builtins_symbol_ty(db, "bool").to_instance(db),
Self::Ambiguous => KnownClass::Bool.to_instance(db),
}
}
}
@@ -1150,7 +1331,7 @@ pub struct FunctionType<'db> {
pub name: ast::name::Name,
/// Is this a function that we special-case somehow? If so, which one?
kind: FunctionKind,
known: Option<KnownFunction>,
definition: Definition<'db>,
@@ -1202,11 +1383,10 @@ impl<'db> FunctionType<'db> {
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Hash)]
pub enum FunctionKind {
/// Just a normal function for which we have no particular special casing
#[default]
Ordinary,
/// Non-exhaustive enumeration of known functions (e.g. `builtins.reveal_type`, ...) that might
/// have special behavior.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum KnownFunction {
/// `builtins.reveal_type`, `typing.reveal_type` or `typing_extensions.reveal_type`
RevealType,
}
@@ -1220,9 +1400,18 @@ pub struct ClassType<'db> {
definition: Definition<'db>,
body_scope: ScopeId<'db>,
known: Option<KnownClass>,
}
impl<'db> ClassType<'db> {
pub fn is_known(self, db: &'db dyn Db, known_class: KnownClass) -> bool {
match self.known(db) {
Some(known) => known == known_class,
None => false,
}
}
/// Return true if this class is a standard library type with given module name and name.
pub(crate) fn is_stdlib_symbol(self, db: &'db dyn Db, module_name: &str, name: &str) -> bool {
name == self.name(db)
@@ -1349,8 +1538,8 @@ pub struct TupleType<'db> {
#[cfg(test)]
mod tests {
use super::{
builtins_symbol_ty, BytesLiteralType, StringLiteralType, Truthiness, TupleType, Type,
UnionType,
builtins_symbol_ty, types_symbol_ty, BytesLiteralType, StringLiteralType, Truthiness,
TupleType, Type, UnionType,
};
use crate::db::tests::TestDb;
use crate::program::{Program, SearchPathSettings};
@@ -1385,6 +1574,7 @@ mod tests {
enum Ty {
Never,
Unknown,
None,
Any,
IntLiteral(i64),
BoolLiteral(bool),
@@ -1392,6 +1582,7 @@ mod tests {
LiteralString,
BytesLiteral(&'static str),
BuiltinInstance(&'static str),
TypesModuleInstance(&'static str),
Union(Vec<Ty>),
Tuple(Vec<Ty>),
}
@@ -1401,6 +1592,7 @@ mod tests {
match self {
Ty::Never => Type::Never,
Ty::Unknown => Type::Unknown,
Ty::None => Type::None,
Ty::Any => Type::Any,
Ty::IntLiteral(n) => Type::IntLiteral(n),
Ty::StringLiteral(s) => {
@@ -1412,6 +1604,7 @@ mod tests {
Type::BytesLiteral(BytesLiteralType::new(db, s.as_bytes().into()))
}
Ty::BuiltinInstance(s) => builtins_symbol_ty(db, s).to_instance(db),
Ty::TypesModuleInstance(s) => types_symbol_ty(db, s).to_instance(db),
Ty::Union(tys) => {
UnionType::from_elements(db, tys.into_iter().map(|ty| ty.into_type(db)))
}
@@ -1489,6 +1682,29 @@ mod tests {
assert!(from.into_type(&db).is_equivalent_to(&db, to.into_type(&db)));
}
#[test_case(Ty::None)]
#[test_case(Ty::BoolLiteral(true))]
#[test_case(Ty::BoolLiteral(false))]
#[test_case(Ty::Tuple(vec![]))]
#[test_case(Ty::TypesModuleInstance("EllipsisType"))]
fn is_singleton(from: Ty) {
let db = setup_db();
assert!(from.into_type(&db).is_singleton(&db));
}
#[test_case(Ty::Never)]
#[test_case(Ty::IntLiteral(345))]
#[test_case(Ty::BuiltinInstance("str"))]
#[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]))]
#[test_case(Ty::Tuple(vec![Ty::None]))]
#[test_case(Ty::Tuple(vec![Ty::None, Ty::BoolLiteral(true)]))]
fn is_not_singleton(from: Ty) {
let db = setup_db();
assert!(!from.into_type(&db).is_singleton(&db));
}
#[test_case(Ty::IntLiteral(1); "is_int_literal_truthy")]
#[test_case(Ty::IntLiteral(-1))]
#[test_case(Ty::StringLiteral("foo"))]

View File

@@ -25,10 +25,12 @@
//! * No type in an intersection can be a supertype of any other type in the intersection (just
//! eliminate the supertype from the intersection).
//! * An intersection containing two non-overlapping types should simplify to [`Type::Never`].
use crate::types::{builtins_symbol_ty, IntersectionType, Type, UnionType};
use crate::types::{IntersectionType, Type, UnionType};
use crate::{Db, FxOrderSet};
use smallvec::SmallVec;
use super::KnownClass;
pub(crate) struct UnionBuilder<'db> {
elements: Vec<Type<'db>>,
db: &'db dyn Db,
@@ -64,7 +66,7 @@ impl<'db> UnionBuilder<'db> {
let mut to_remove = SmallVec::<[usize; 2]>::new();
for (index, element) in self.elements.iter().enumerate() {
if Some(*element) == bool_pair {
to_add = builtins_symbol_ty(self.db, "bool");
to_add = KnownClass::Bool.to_instance(self.db);
to_remove.push(index);
// The type we are adding is a BooleanLiteral, which doesn't have any
// subtypes. And we just found that the union already contained our
@@ -300,7 +302,7 @@ mod tests {
use crate::db::tests::TestDb;
use crate::program::{Program, SearchPathSettings};
use crate::python_version::PythonVersion;
use crate::types::{builtins_symbol_ty, UnionBuilder};
use crate::types::{KnownClass, UnionBuilder};
use crate::ProgramSettings;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
@@ -360,7 +362,7 @@ mod tests {
#[test]
fn build_union_bool() {
let db = setup_db();
let bool_ty = builtins_symbol_ty(&db, "bool");
let bool_instance_ty = KnownClass::Bool.to_instance(&db);
let t0 = Type::BooleanLiteral(true);
let t1 = Type::BooleanLiteral(true);
@@ -371,7 +373,7 @@ mod tests {
assert_eq!(union.elements(&db), &[t0, t3]);
let union = UnionType::from_elements(&db, [t0, t1, t2, t3]).expect_union();
assert_eq!(union.elements(&db), &[bool_ty, t3]);
assert_eq!(union.elements(&db), &[bool_instance_ty, t3]);
}
#[test]
@@ -389,7 +391,7 @@ mod tests {
#[test]
fn build_union_simplify_subtype() {
let db = setup_db();
let t0 = Type::builtin_str_instance(&db);
let t0 = KnownClass::Str.to_instance(&db);
let t1 = Type::LiteralString;
let u0 = UnionType::from_elements(&db, [t0, t1]);
let u1 = UnionType::from_elements(&db, [t1, t0]);
@@ -401,7 +403,7 @@ mod tests {
#[test]
fn build_union_no_simplify_unknown() {
let db = setup_db();
let t0 = Type::builtin_str_instance(&db);
let t0 = KnownClass::Str.to_instance(&db);
let t1 = Type::Unknown;
let u0 = UnionType::from_elements(&db, [t0, t1]);
let u1 = UnionType::from_elements(&db, [t1, t0]);
@@ -413,9 +415,9 @@ mod tests {
#[test]
fn build_union_subsume_multiple() {
let db = setup_db();
let str_ty = Type::builtin_str_instance(&db);
let int_ty = Type::builtin_int_instance(&db);
let object_ty = builtins_symbol_ty(&db, "object").to_instance(&db);
let str_ty = KnownClass::Str.to_instance(&db);
let int_ty = KnownClass::Int.to_instance(&db);
let object_ty = KnownClass::Object.to_instance(&db);
let unknown_ty = Type::Unknown;
let u0 = UnionType::from_elements(&db, [str_ty, unknown_ty, int_ty, object_ty]);

File diff suppressed because it is too large Load Diff

View File

@@ -155,13 +155,24 @@ impl<'db> NarrowingConstraintsBuilder<'db> {
let inference = infer_expression_types(self.db, expression);
for (op, comparator) in std::iter::zip(&**ops, &**comparators) {
let comp_ty = inference.expression_ty(comparator.scoped_ast_id(self.db, scope));
if matches!(op, ast::CmpOp::IsNot) {
let ty = IntersectionBuilder::new(self.db)
.add_negative(comp_ty)
.build();
self.constraints.insert(symbol, ty);
};
// TODO other comparison types
match op {
ast::CmpOp::IsNot => {
if comp_ty.is_singleton(self.db) {
let ty = IntersectionBuilder::new(self.db)
.add_negative(comp_ty)
.build();
self.constraints.insert(symbol, ty);
} else {
// Non-singletons cannot be safely narrowed using `is not`
}
}
ast::CmpOp::Is => {
self.constraints.insert(symbol, comp_ty);
}
_ => {
// TODO other comparison types
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
use red_knot_test::run;
use std::path::PathBuf;
/// See `crates/red_knot_test/README.md` for documentation on these tests.
#[rstest::rstest]
fn mdtest(#[files("resources/mdtest/**/*.md")] path: PathBuf) {
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("mdtest")
.canonicalize()
.unwrap();
let title = path.strip_prefix(crate_dir).unwrap();
run(&path, title.as_os_str().to_str().unwrap());
}

View File

@@ -0,0 +1,33 @@
[package]
name = "red_knot_test"
version = "0.0.0"
publish = false
edition.workspace = true
rust-version.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
red_knot_python_semantic = { workspace = true }
red_knot_vendored = { workspace = true }
ruff_db = { workspace = true }
ruff_index = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
anyhow = { workspace = true }
colored = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
salsa = { workspace = true }
smallvec = { workspace = true }
[dev-dependencies]
[lints]
workspace = true

View File

@@ -0,0 +1,470 @@
# Writing type-checking / type-inference tests
Any Markdown file can be a test suite.
In order for it to be run as one, `red_knot_test::run` must be called with its path; see
`crates/red_knot_python_semantic/tests/mdtest.rs` for an example that treats all Markdown files
under a certain directory as test suites.
A Markdown test suite can contain any number of tests. A test consists of one or more embedded
"files", each defined by a triple-backticks fenced code block. The code block must have a tag string
specifying its language; currently only `py` (Python files) and `pyi` (type stub files) are
supported.
The simplest possible test suite consists of just a single test, with a single embedded file:
````markdown
```py
reveal_type(1) # revealed: Literal[1]
```
````
When running this test, the mdtest framework will write a file with these contents to the default
file path (`/src/test.py`) in its in-memory file system, run a type check on that file, and then
match the resulting diagnostics with the assertions in the test. Assertions are in the form of
Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise, it
fails.
<!---
(If you are reading this document in raw Markdown source rather than rendered Markdown, note that
the quadruple-backtick-fenced "markdown" language code block above is NOT itself part of the mdtest
syntax, it's just how this README embeds an example mdtest Markdown document.)
--->
See actual example mdtest suites in
[`crates/red_knot_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/resources/mdtest).
> Note: If you use `rstest` to generate a separate test for all Markdown files in a certain directory,
> as with the example in `crates/red_knot_python_semantic/tests/mdtest.rs`,
> you will likely want to also make sure that the crate the tests are in is rebuilt every time a
> Markdown file is added or removed from the directory. See
> [`crates/red_knot_python_semantic/build.rs`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/build.rs)
> for an example of how to do this.
>
> This is because `rstest` generates its tests at build time rather than at runtime.
> Without the `build.rs` file to force a rebuild when a Markdown file is added or removed,
> a new Markdown test suite might not be run unless some other change in the crate caused a rebuild
> following the addition of the new test file.
## Assertions
Two kinds of assertions are supported: `# revealed:` (shown above) and `# error:`.
### Assertion kinds
#### revealed
A `# revealed:` assertion should always be paired with a call to the `reveal_type` utility, which
reveals (via a diagnostic) the inferred type of its argument (which can be any expression). The text
after `# revealed:` must match exactly with the displayed form of the revealed type of that
expression.
The `reveal_type` function can be imported from the `typing` standard library module (or, for older
Python versions, from the `typing_extensions` pseudo-standard-library module[^extensions]):
```py
from typing import reveal_type
reveal_type("foo") # revealed: Literal["foo"]
```
For convenience, type checkers also pretend that `reveal_type` is a built-in, so that this import is
not required. Using `reveal_type` without importing it issues a diagnostic warning that it was used
without importing it, in addition to the diagnostic revealing the type of the expression.
The `# revealed:` assertion must always match a revealed-type diagnostic, and will also match the
undefined-reveal diagnostic, if present, so it's safe to use `reveal_type` in tests either with or
without importing it. (Style preference is to not import it in tests, unless specifically testing
something about the behavior of importing it.)
#### error
A comment beginning with `# error:` is an assertion that a type checker diagnostic will be emitted,
with text span starting on that line. The matching can be narrowed in three ways:
- `# error: [invalid-assignment]` requires that the matched diagnostic have the rule code
`invalid-assignment`. (The square brackets are required.)
- `# error: "Some text"` requires that the diagnostic's full message contain the text `Some text`.
(The double quotes are required in the assertion comment; they are not part of the matched text.)
- `# error: 8 [rule-code]` or `# error: 8 "Some text"` additionally requires that the matched
diagnostic's text span begins on column 8 (one-indexed) of this line.
Assertions must contain either a rule code or a contains-text, or both, and may optionally also
include a column number. They must come in order: first column, if present; then rule code, if
present; then contains-text, if present. For example, an assertion using all three would look like
`# error: 8 [invalid-assignment] "Some text"`.
Error assertions in tests intended to test type checker semantics should primarily use rule-code
assertions, with occasional contains-text assertions where needed to disambiguate or validate some
details of the diagnostic message.
### Assertion locations
An assertion comment may be a line-trailing comment, in which case it applies to the line it is on:
```py
x: str = 1 # error: [invalid-assignment]
```
Or it may be a comment on its own line, in which case it applies to the next line that does not
contain an assertion comment:
```py
# error: [invalid-assignment]
x: str = 1
```
Multiple assertions applying to the same line may be stacked:
```py
# error: [invalid-assignment]
# revealed: Literal[1]
x: str = reveal_type(1)
```
Intervening empty lines or non-assertion comments are not allowed; an assertion stack must be one
assertion per line, immediately following each other, with the line immediately following the last
assertion as the line of source code on which the matched diagnostics are emitted.
## Multi-file tests
Some tests require multiple files, with imports from one file into another. Multiple fenced code
blocks represent multiple embedded files. Since files must have unique names, at most one file can
use the default name of `/src/test.py`. Other files must explicitly specify their file name:
````markdown
```py
from b import C
reveal_type(C) # revealed: Literal[C]
```
```py path=b.py
class C: pass
```
````
Relative file names are always relative to the "workspace root", which is also an import root (that
is, the equivalent of a runtime entry on `sys.path`).
The default workspace root is `/src/`. Currently it is not possible to customize this in a test, but
this is a feature we will want to add in the future.
So the above test creates two files, `/src/test.py` and `/src/b.py`, and sets the workspace root to
`/src/`, allowing `test.py` to import from `b.py` using the module name `b`.
## Multi-test suites
A single test suite (Markdown file) can contain multiple tests, by demarcating them using Markdown
header lines:
````markdown
# Same-file invalid assignment
```py
x: int = "foo" # error: [invalid-assignment]
```
# Cross-file invalid assignment
```py
from b import y
x: int = y # error: [invalid-assignment]
```
```py path=b.py
y = "foo"
```
````
This test suite contains two tests, one named "Same-file invalid assignment" and the other named
"Cross-file invalid assignment". The first test involves only a single embedded file, and the second
test involves two embedded files.
The tests are run independently, in independent in-memory file systems and with new red-knot
[Salsa](https://github.com/salsa-rs/salsa) databases. This means that each is a from-scratch run of
the type checker, with no data persisting from any previous test.
Due to `cargo test` limitations, an entire test suite (Markdown file) is run as a single Rust test,
so it's not possible to select individual tests within it to run.
## Structured test suites
Markdown headers can also be used to group related tests within a suite:
````markdown
# Literals
## Numbers
### Integer
```py
reveal_type(1) # revealed: Literal[1]
```
### Float
```py
reveal_type(1.0) # revealed: float
```
## Strings
```py
reveal_type("foo") # revealed: Literal["foo"]
```
````
This test suite contains three tests, named "Literals - Numbers - Integer", "Literals - Numbers -
Float", and "Literals - Strings".
A header-demarcated section must either be a test or a grouping header; it cannot be both. That is,
a header section can either contain embedded files (making it a test), or it can contain more
deeply-nested headers (headers with more `#`), but it cannot contain both.
## Documentation of tests
Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by
the test framework) between fenced code blocks. This permits natural documentation of
why a test exists, and what it intends to assert:
````markdown
Assigning a string to a variable annotated as `int` is not permitted:
```py
x: int = "foo" # error: [invalid-assignment]
```
````
## Planned features
There are some designed features that we intend for the test framework to have, but have not yet
implemented:
### Multi-line diagnostic assertions
We may want to be able to assert that a diagnostic spans multiple lines, and to assert the columns it
begins and/or ends on. The planned syntax for this will use `<<<` and `>>>` to mark the start and end lines for
an assertion:
```py
(3 # error: 2 [unsupported-operands] <<<
+
"foo") # error: 6 >>>
```
The column assertion `6` on the ending line should be optional.
In cases of overlapping such assertions, resolve ambiguity using more angle brackets: `<<<<` begins
an assertion ended by `>>>>`, etc.
### Non-Python files
Some tests may need to specify non-Python embedded files: typeshed `stdlib/VERSIONS`, `pth` files,
`py.typed` files, `pyvenv.cfg` files...
We will allow specifying any of these using the `text` language in the code block tag string:
````markdown
```text path=/third-party/foo/py.typed
partial
```
````
We may want to also support testing Jupyter notebooks as embedded files; exact syntax for this is
yet to be determined.
Of course, red-knot is only run directly on `py` and `pyi` files, and assertion comments are only
possible in these files.
A fenced code block with no language will always be an error.
### Configuration
We will add the ability to specify non-default red-knot configurations to use in tests, by including
a TOML code block:
````markdown
```toml
[tool.knot]
warn-on-any = true
```
```py
from typing import Any
def f(x: Any): # error: [use-of-any]
pass
```
````
It should be possible to include a TOML code block in a single test (as shown), or in a grouping
section, in which case it applies to all nested tests within that grouping section. Configurations
at multiple level are allowed and merged, with the most-nested (closest to the test) taking
precedence.
### Running just a single test from a suite
Having each test in a suite always run as a distinct Rust test would require writing our own test
runner or code-generating tests in a build script; neither of these is planned.
We could still allow running just a single test from a suite, for debugging purposes, either via
some "focus" syntax that could be easily temporarily added to a test, or via an environment
variable.
### Configuring search paths and kinds
The red-knot TOML configuration format hasn't been designed yet, and we may want to implement
support in the test framework for configuring search paths before it is designed. If so, we can
define some configuration options for now under the `[tool.knot.tests]` namespace. In the future,
perhaps some of these can be replaced by real red-knot configuration options; some or all may also
be kept long-term as test-specific options.
Some configuration options we will want to provide:
- We should be able to configure the default workspace root to something other than `/src/` using a
`workspace-root` configuration option.
- We should be able to add a third-party root using the `third-party-root` configuration option.
- We may want to add additional configuration options for setting additional search path kinds.
Paths for `workspace-root` and `third-party-root` must be absolute.
Relative embedded-file paths are relative to the workspace root, even if it is explicitly set to a
non-default value using the `workspace-root` config.
### Specifying a custom typeshed
Some tests will need to override the default typeshed with custom files. The `[tool.knot.tests]`
configuration option `typeshed-root` should be usable for this:
````markdown
```toml
[tool.knot.tests]
typeshed-root = "/typeshed"
```
This file is importable as part of our custom typeshed, because it is within `/typeshed`, which we
configured above as our custom typeshed root:
```py path=/typeshed/stdlib/builtins.pyi
I_AM_THE_ONLY_BUILTIN = 1
```
This file is written to `/src/test.py`, because the default workspace root is `/src/ and the default
file path is `test.py`:
```py
reveal_type(I_AM_THE_ONLY_BUILTIN) # revealed: Literal[1]
```
````
A fenced code block with language `text` can be used to provide a `stdlib/VERSIONS` file in the
custom typeshed root. If no such file is created explicitly, one should be created implicitly
including entries enabling all specified `<typeshed-root>/stdlib` files for all supported Python
versions.
### I/O errors
We could use an `error=` configuration option in the tag string to make an embedded file cause an
I/O error on read.
### Asserting on full diagnostic output
The inline comment diagnostic assertions are useful for making quick, readable assertions about
diagnostics in a particular location. But sometimes we will want to assert on the full diagnostic
output of checking an embedded Python file. Or sometimes (see “incremental tests” below) we will
want to assert on diagnostics in a file, without impacting the contents of that file by changing a
comment in it. In these cases, a Python code block in a test could be followed by a fenced code
block with language `output`; this would contain the full diagnostic output for the preceding test
file:
````markdown
# full output
```py
x = 1
reveal_type(x)
```
This is just an example, not a proposal that red-knot would ever actually output diagnostics in
precisely this format:
```output
test.py, line 1, col 1: revealed type is 'Literal[1]'
```
````
We will want to build tooling to automatically capture and update these “full diagnostic output”
blocks, when tests are run in an update-output mode (probably specified by an environment variable.)
By default, an `output` block will specify diagnostic output for the file `<workspace-root>/test.py`.
An `output` block can have a `path=` option, to explicitly specify the Python file for which it
asserts diagnostic output, and a `stage=` option, to specify which stage of an incremental test it
specifies diagnostic output at. (See “incremental tests” below.)
It is an error for an `output` block to exist, if there is no `py` or `python` block in the same
test for the same file path.
### Incremental tests
Some tests should validate incremental checking, by initially creating some files, checking them,
and then modifying/adding/deleting files and checking again.
We should add the capability to create an incremental test by using the `stage=` option on some
fenced code blocks in the test:
````markdown
# Incremental
## modify a file
Initial version of `test.py` and `b.py`:
```py
from b import x
reveal_type(x)
```
```py path=b.py
x = 1
```
Initial expected output for `test.py`:
```output
/src/test.py, line 1, col 1: revealed type is 'Literal[1]'
```
Now in our first incremental stage, modify the contents of `b.py`:
```py path=b.py stage=1
# b.py
x = 2
```
And this is our updated expected output for `test.py` at stage 1:
```output stage=1
/src/test.py, line 1, col 1: revealed type is 'Literal[2]'
```
(One reason to use full-diagnostic-output blocks in this test is that updating
inline-comment diagnostic assertions for `test.py` would require specifying new
contents for `test.py` in stage 1, which we don't want to do in this test.)
````
It will be possible to provide any number of stages in an incremental test. If a stage re-specifies
a filename that was specified in a previous stage (or the initial stage), that file is modified. A
new filename appearing for the first time in a new stage will create a new file. To delete a
previously created file, specify that file with the tag `delete` in its tag string (in this case, it
is an error to provide non-empty contents). Any previously-created files that are not re-specified
in a later stage continue to exist with their previously-specified contents, and are not "touched".
All stages should be run in order, incrementally, and then the final state should also be re-checked
cold, to validate equivalence of cold and incremental check results.
[^extensions]: `typing-extensions` is a third-party module, but typeshed, and thus type checkers
also, treat it as part of the standard library.

View File

@@ -0,0 +1,621 @@
//! Parse type and type-error assertions in Python comment form.
//!
//! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`.
//! In the latter case, the `8` is a column number, and `"message text"` asserts that the full
//! diagnostic message contains the text `"message text"`; all three are optional (`# error:` will
//! match any error.)
//!
//! Assertion comments may be placed at end-of-line:
//!
//! ```py
//! x: int = "foo" # error: [invalid-assignment]
//! ```
//!
//! Or as a full-line comment on the preceding line:
//!
//! ```py
//! # error: [invalid-assignment]
//! x: int = "foo"
//! ```
//!
//! Multiple assertion comments may apply to the same line; in this case all (or all but the last)
//! must be full-line comments:
//!
//! ```py
//! # error: [unbound-name]
//! reveal_type(x) # revealed: Unbound
//! ```
//!
//! or
//!
//! ```py
//! # error: [unbound-name]
//! # revealed: Unbound
//! reveal_type(x)
//! ```
use crate::db::Db;
use once_cell::sync::Lazy;
use regex::Regex;
use ruff_db::files::File;
use ruff_db::parsed::parsed_module;
use ruff_db::source::{line_index, source_text, SourceText};
use ruff_python_trivia::CommentRanges;
use ruff_source_file::{LineIndex, Locator, OneIndexed};
use ruff_text_size::{Ranged, TextRange};
use smallvec::SmallVec;
use std::ops::Deref;
/// Diagnostic assertion comments in a single embedded file.
#[derive(Debug)]
pub(crate) struct InlineFileAssertions {
comment_ranges: CommentRanges,
source: SourceText,
lines: LineIndex,
}
impl InlineFileAssertions {
pub(crate) fn from_file(db: &Db, file: File) -> Self {
let source = source_text(db, file);
let lines = line_index(db, file);
let parsed = parsed_module(db, file);
let comment_ranges = CommentRanges::from(parsed.tokens());
Self {
comment_ranges,
source,
lines,
}
}
fn locator(&self) -> Locator {
Locator::with_index(&self.source, self.lines.clone())
}
fn line_number(&self, range: &impl Ranged) -> OneIndexed {
self.lines.line_index(range.start())
}
fn is_own_line_comment(&self, ranged_assertion: &AssertionWithRange) -> bool {
CommentRanges::is_own_line(ranged_assertion.start(), &self.locator())
}
}
impl<'a> IntoIterator for &'a InlineFileAssertions {
type Item = LineAssertions<'a>;
type IntoIter = LineAssertionsIterator<'a>;
fn into_iter(self) -> Self::IntoIter {
Self::IntoIter {
file_assertions: self,
inner: AssertionWithRangeIterator {
file_assertions: self,
inner: self.comment_ranges.into_iter(),
}
.peekable(),
}
}
}
/// An [`Assertion`] with the [`TextRange`] of its original inline comment.
#[derive(Debug)]
struct AssertionWithRange<'a>(Assertion<'a>, TextRange);
impl<'a> Deref for AssertionWithRange<'a> {
type Target = Assertion<'a>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Ranged for AssertionWithRange<'_> {
fn range(&self) -> TextRange {
self.1
}
}
impl<'a> From<AssertionWithRange<'a>> for Assertion<'a> {
fn from(value: AssertionWithRange<'a>) -> Self {
value.0
}
}
/// Iterator that yields all assertions within a single embedded Python file.
#[derive(Debug)]
struct AssertionWithRangeIterator<'a> {
file_assertions: &'a InlineFileAssertions,
inner: std::iter::Copied<std::slice::Iter<'a, TextRange>>,
}
impl<'a> Iterator for AssertionWithRangeIterator<'a> {
type Item = AssertionWithRange<'a>;
fn next(&mut self) -> Option<Self::Item> {
let locator = self.file_assertions.locator();
loop {
let inner_next = self.inner.next()?;
let comment = locator.slice(inner_next);
if let Some(assertion) = Assertion::from_comment(comment) {
return Some(AssertionWithRange(assertion, inner_next));
};
}
}
}
impl std::iter::FusedIterator for AssertionWithRangeIterator<'_> {}
/// A vector of [`Assertion`]s belonging to a single line.
///
/// Most lines will have zero or one assertion, so we use a [`SmallVec`] optimized for a single
/// element to avoid most heap vector allocations.
type AssertionVec<'a> = SmallVec<[Assertion<'a>; 1]>;
#[derive(Debug)]
pub(crate) struct LineAssertionsIterator<'a> {
file_assertions: &'a InlineFileAssertions,
inner: std::iter::Peekable<AssertionWithRangeIterator<'a>>,
}
impl<'a> Iterator for LineAssertionsIterator<'a> {
type Item = LineAssertions<'a>;
fn next(&mut self) -> Option<Self::Item> {
let file = self.file_assertions;
let ranged_assertion = self.inner.next()?;
let mut collector = AssertionVec::new();
let mut line_number = file.line_number(&ranged_assertion);
// Collect all own-line comments on consecutive lines; these all apply to the same line of
// code. For example:
//
// ```py
// # error: [unbound-name]
// # revealed: Unbound
// reveal_type(x)
// ```
//
if file.is_own_line_comment(&ranged_assertion) {
collector.push(ranged_assertion.into());
let mut only_own_line = true;
while let Some(ranged_assertion) = self.inner.peek() {
let next_line_number = line_number.saturating_add(1);
if file.line_number(ranged_assertion) == next_line_number {
if !file.is_own_line_comment(ranged_assertion) {
only_own_line = false;
}
line_number = next_line_number;
collector.push(self.inner.next().unwrap().into());
// If we see an end-of-line comment, it has to be the end of the stack,
// otherwise we'd botch this case, attributing all three errors to the `bar`
// line:
//
// ```py
// # error:
// foo # error:
// bar # error:
// ```
//
if !only_own_line {
break;
}
} else {
break;
}
}
if only_own_line {
// The collected comments apply to the _next_ line in the code.
line_number = line_number.saturating_add(1);
}
} else {
// We have a line-trailing comment; it applies to its own line, and is not grouped.
collector.push(ranged_assertion.into());
}
Some(LineAssertions {
line_number,
assertions: collector,
})
}
}
impl std::iter::FusedIterator for LineAssertionsIterator<'_> {}
/// One or more assertions referring to the same line of code.
#[derive(Debug)]
pub(crate) struct LineAssertions<'a> {
/// The line these assertions refer to.
///
/// Not necessarily the same line the assertion comment is located on; for an own-line comment,
/// it's the next non-assertion line.
pub(crate) line_number: OneIndexed,
/// The assertions referring to this line.
pub(crate) assertions: AssertionVec<'a>,
}
impl<'a> Deref for LineAssertions<'a> {
type Target = [Assertion<'a>];
fn deref(&self) -> &Self::Target {
&self.assertions
}
}
static TYPE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^#\s*revealed:\s*(?<ty_display>.+?)\s*$").unwrap());
static ERROR_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"^#\s*error:(\s*(?<column>\d+))?(\s*\[(?<rule>.+?)\])?(\s*"(?<message>.+?)")?\s*$"#,
)
.unwrap()
});
/// A single diagnostic assertion comment.
#[derive(Debug)]
pub(crate) enum Assertion<'a> {
/// A `revealed: ` assertion.
Revealed(&'a str),
/// An `error: ` assertion.
Error(ErrorAssertion<'a>),
}
impl<'a> Assertion<'a> {
fn from_comment(comment: &'a str) -> Option<Self> {
if let Some(caps) = TYPE_RE.captures(comment) {
Some(Self::Revealed(caps.name("ty_display").unwrap().as_str()))
} else {
ERROR_RE.captures(comment).map(|caps| {
Self::Error(ErrorAssertion {
rule: caps.name("rule").map(|m| m.as_str()),
column: caps.name("column").and_then(|m| m.as_str().parse().ok()),
message_contains: caps.name("message").map(|m| m.as_str()),
})
})
}
}
}
impl std::fmt::Display for Assertion<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"),
Self::Error(assertion) => assertion.fmt(f),
}
}
}
/// An `error: ` assertion comment.
#[derive(Debug)]
pub(crate) struct ErrorAssertion<'a> {
/// The diagnostic rule code we expect.
pub(crate) rule: Option<&'a str>,
/// The column we expect the diagnostic range to start at.
pub(crate) column: Option<OneIndexed>,
/// A string we expect to be contained in the diagnostic message.
pub(crate) message_contains: Option<&'a str>,
}
impl std::fmt::Display for ErrorAssertion<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("error:")?;
if let Some(column) = self.column {
write!(f, " {column}")?;
}
if let Some(rule) = self.rule {
write!(f, " [{rule}]")?;
}
if let Some(message) = self.message_contains {
write!(f, r#" "{message}""#)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{Assertion, InlineFileAssertions, LineAssertions};
use ruff_db::files::system_path_to_file;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use ruff_python_trivia::textwrap::dedent;
use ruff_source_file::OneIndexed;
fn get_assertions(source: &str) -> InlineFileAssertions {
let mut db = crate::db::Db::setup(SystemPathBuf::from("/src"));
db.write_file("/src/test.py", source).unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
InlineFileAssertions::from_file(&db, file)
}
fn as_vec(assertions: &InlineFileAssertions) -> Vec<LineAssertions> {
assertions.into_iter().collect()
}
#[test]
fn ty_display() {
let assertions = get_assertions(&dedent(
"
reveal_type(1) # revealed: Literal[1]
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "revealed: Literal[1]");
}
#[test]
fn error() {
let assertions = get_assertions(&dedent(
"
x # error:
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "error:");
}
#[test]
fn prior_line() {
let assertions = get_assertions(&dedent(
"
# revealed: Literal[1]
reveal_type(1)
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "revealed: Literal[1]");
}
#[test]
fn stacked_prior_line() {
let assertions = get_assertions(&dedent(
"
# revealed: Unbound
# error: [unbound-name]
reveal_type(x)
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(3));
let [assert1, assert2] = &line.assertions[..] else {
panic!("expected two assertions");
};
assert_eq!(format!("{assert1}"), "revealed: Unbound");
assert_eq!(format!("{assert2}"), "error: [unbound-name]");
}
#[test]
fn stacked_mixed() {
let assertions = get_assertions(&dedent(
"
# revealed: Unbound
reveal_type(x) # error: [unbound-name]
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert1, assert2] = &line.assertions[..] else {
panic!("expected two assertions");
};
assert_eq!(format!("{assert1}"), "revealed: Unbound");
assert_eq!(format!("{assert2}"), "error: [unbound-name]");
}
#[test]
fn multiple_lines() {
let assertions = get_assertions(&dedent(
r#"
# error: [invalid-assignment]
x: int = "foo"
y # error: [unbound-name]
"#,
));
let [line1, line2] = &as_vec(&assertions)[..] else {
panic!("expected two lines");
};
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2));
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3));
let [Assertion::Error(error1)] = &line1.assertions[..] else {
panic!("expected one error assertion");
};
assert_eq!(error1.rule, Some("invalid-assignment"));
let [Assertion::Error(error2)] = &line2.assertions[..] else {
panic!("expected one error assertion");
};
assert_eq!(error2.rule, Some("unbound-name"));
}
#[test]
fn multiple_lines_mixed_stack() {
let assertions = get_assertions(&dedent(
r#"
# error: [invalid-assignment]
x: int = reveal_type("foo") # revealed: str
y # error: [unbound-name]
"#,
));
let [line1, line2] = &as_vec(&assertions)[..] else {
panic!("expected two lines");
};
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2));
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3));
let [Assertion::Error(error1), Assertion::Revealed(expected_ty)] = &line1.assertions[..]
else {
panic!("expected one error assertion and one Revealed assertion");
};
assert_eq!(error1.rule, Some("invalid-assignment"));
assert_eq!(*expected_ty, "str");
let [Assertion::Error(error2)] = &line2.assertions[..] else {
panic!("expected one error assertion");
};
assert_eq!(error2.rule, Some("unbound-name"));
}
#[test]
fn error_with_rule() {
let assertions = get_assertions(&dedent(
"
x # error: [unbound-name]
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "error: [unbound-name]");
}
#[test]
fn error_with_rule_and_column() {
let assertions = get_assertions(&dedent(
"
x # error: 1 [unbound-name]
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "error: 1 [unbound-name]");
}
#[test]
fn error_with_rule_and_message() {
let assertions = get_assertions(&dedent(
r#"
# error: [unbound-name] "`x` is unbound"
x
"#,
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(
format!("{assert}"),
r#"error: [unbound-name] "`x` is unbound""#
);
}
#[test]
fn error_with_message_and_column() {
let assertions = get_assertions(&dedent(
r#"
# error: 1 "`x` is unbound"
x
"#,
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), r#"error: 1 "`x` is unbound""#);
}
#[test]
fn error_with_rule_and_message_and_column() {
let assertions = get_assertions(&dedent(
r#"
# error: 1 [unbound-name] "`x` is unbound"
x
"#,
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(
format!("{assert}"),
r#"error: 1 [unbound-name] "`x` is unbound""#
);
}
}

View File

@@ -0,0 +1,88 @@
use red_knot_python_semantic::{
Db as SemanticDb, Program, ProgramSettings, PythonVersion, SearchPathSettings,
};
use ruff_db::files::{File, Files};
use ruff_db::system::SystemPathBuf;
use ruff_db::system::{DbWithTestSystem, System, TestSystem};
use ruff_db::vendored::VendoredFileSystem;
use ruff_db::{Db as SourceDb, Upcast};
#[salsa::db]
pub(crate) struct Db {
storage: salsa::Storage<Self>,
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
}
impl Db {
pub(crate) fn setup(workspace_root: SystemPathBuf) -> Self {
let db = Self {
storage: salsa::Storage::default(),
system: TestSystem::default(),
vendored: red_knot_vendored::file_system().clone(),
files: Files::default(),
};
db.memory_file_system()
.create_directory_all(&workspace_root)
.unwrap();
Program::from_settings(
&db,
&ProgramSettings {
target_version: PythonVersion::default(),
search_paths: SearchPathSettings::new(workspace_root),
},
)
.expect("Invalid search path settings");
db
}
}
impl DbWithTestSystem for Db {
fn test_system(&self) -> &TestSystem {
&self.system
}
fn test_system_mut(&mut self) -> &mut TestSystem {
&mut self.system
}
}
#[salsa::db]
impl SourceDb for Db {
fn vendored(&self) -> &VendoredFileSystem {
&self.vendored
}
fn system(&self) -> &dyn System {
&self.system
}
fn files(&self) -> &Files {
&self.files
}
}
impl Upcast<dyn SourceDb> for Db {
fn upcast(&self) -> &(dyn SourceDb + 'static) {
self
}
fn upcast_mut(&mut self) -> &mut (dyn SourceDb + 'static) {
self
}
}
#[salsa::db]
impl SemanticDb for Db {
fn is_file_open(&self, file: File) -> bool {
!file.path(self).is_vendored_path()
}
}
#[salsa::db]
impl salsa::Database for Db {
fn salsa_event(&self, _event: &dyn Fn() -> salsa::Event) {}
}

View File

@@ -0,0 +1,173 @@
//! Sort and group diagnostics by line number, so they can be correlated with assertions.
//!
//! We don't assume that we will get the diagnostics in source order.
use ruff_source_file::{LineIndex, OneIndexed};
use ruff_text_size::Ranged;
use std::ops::{Deref, Range};
/// All diagnostics for one embedded Python file, sorted and grouped by start line number.
///
/// The diagnostics are kept in a flat vector, sorted by line number. A separate vector of
/// [`LineDiagnosticRange`] has one entry for each contiguous slice of the diagnostics vector
/// containing diagnostics which all start on the same line.
#[derive(Debug)]
pub(crate) struct SortedDiagnostics<T> {
diagnostics: Vec<T>,
line_ranges: Vec<LineDiagnosticRange>,
}
impl<T> SortedDiagnostics<T>
where
T: Ranged + Clone,
{
pub(crate) fn new(diagnostics: impl IntoIterator<Item = T>, line_index: &LineIndex) -> Self {
let mut diagnostics: Vec<_> = diagnostics
.into_iter()
.map(|diagnostic| DiagnosticWithLine {
line_number: line_index.line_index(diagnostic.start()),
diagnostic,
})
.collect();
diagnostics.sort_unstable_by_key(|diagnostic_with_line| diagnostic_with_line.line_number);
let mut diags = Self {
diagnostics: Vec::with_capacity(diagnostics.len()),
line_ranges: vec![],
};
let mut current_line_number = None;
let mut start = 0;
for DiagnosticWithLine {
line_number,
diagnostic,
} in diagnostics
{
match current_line_number {
None => {
current_line_number = Some(line_number);
}
Some(current) => {
if line_number != current {
let end = diags.diagnostics.len();
diags.line_ranges.push(LineDiagnosticRange {
line_number: current,
diagnostic_index_range: start..end,
});
start = end;
current_line_number = Some(line_number);
}
}
}
diags.diagnostics.push(diagnostic);
}
if let Some(line_number) = current_line_number {
diags.line_ranges.push(LineDiagnosticRange {
line_number,
diagnostic_index_range: start..diags.diagnostics.len(),
});
}
diags
}
pub(crate) fn iter_lines(&self) -> LineDiagnosticsIterator<T> {
LineDiagnosticsIterator {
diagnostics: self.diagnostics.as_slice(),
inner: self.line_ranges.iter(),
}
}
}
/// Range delineating diagnostics in [`SortedDiagnostics`] that begin on a single line.
#[derive(Debug)]
struct LineDiagnosticRange {
line_number: OneIndexed,
diagnostic_index_range: Range<usize>,
}
/// Iterator to group sorted diagnostics by line.
pub(crate) struct LineDiagnosticsIterator<'a, T> {
diagnostics: &'a [T],
inner: std::slice::Iter<'a, LineDiagnosticRange>,
}
impl<'a, T> Iterator for LineDiagnosticsIterator<'a, T>
where
T: Ranged + Clone,
{
type Item = LineDiagnostics<'a, T>;
fn next(&mut self) -> Option<Self::Item> {
let LineDiagnosticRange {
line_number,
diagnostic_index_range,
} = self.inner.next()?;
Some(LineDiagnostics {
line_number: *line_number,
diagnostics: &self.diagnostics[diagnostic_index_range.clone()],
})
}
}
impl<T> std::iter::FusedIterator for LineDiagnosticsIterator<'_, T> where T: Clone + Ranged {}
/// All diagnostics that start on a single line of source code in one embedded Python file.
#[derive(Debug)]
pub(crate) struct LineDiagnostics<'a, T> {
/// Line number on which these diagnostics start.
pub(crate) line_number: OneIndexed,
/// Diagnostics starting on this line.
pub(crate) diagnostics: &'a [T],
}
impl<T> Deref for LineDiagnostics<'_, T> {
type Target = [T];
fn deref(&self) -> &Self::Target {
self.diagnostics
}
}
#[derive(Debug)]
struct DiagnosticWithLine<T> {
line_number: OneIndexed,
diagnostic: T,
}
#[cfg(test)]
mod tests {
use crate::db::Db;
use ruff_db::files::system_path_to_file;
use ruff_db::source::line_index;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use ruff_source_file::OneIndexed;
use ruff_text_size::{TextRange, TextSize};
#[test]
fn sort_and_group() {
let mut db = Db::setup(SystemPathBuf::from("/src"));
db.write_file("/src/test.py", "one\ntwo\n").unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let lines = line_index(&db, file);
let ranges = vec![
TextRange::new(TextSize::new(0), TextSize::new(1)),
TextRange::new(TextSize::new(5), TextSize::new(10)),
TextRange::new(TextSize::new(1), TextSize::new(7)),
];
let sorted = super::SortedDiagnostics::new(&ranges, &lines);
let grouped = sorted.iter_lines().collect::<Vec<_>>();
let [line1, line2] = &grouped[..] else {
panic!("expected two lines");
};
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(0));
assert_eq!(line1.diagnostics.len(), 2);
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(1));
assert_eq!(line2.diagnostics.len(), 1);
}
}

View File

@@ -0,0 +1,95 @@
use colored::Colorize;
use parser as test_parser;
use red_knot_python_semantic::types::check_types;
use ruff_db::files::system_path_to_file;
use ruff_db::parsed::parsed_module;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use std::collections::BTreeMap;
use std::path::PathBuf;
type Failures = BTreeMap<SystemPathBuf, matcher::FailuresByLine>;
mod assertion;
mod db;
mod diagnostic;
mod matcher;
mod parser;
/// Run `path` as a markdown test suite with given `title`.
///
/// Panic on test failure, and print failure details.
#[allow(clippy::print_stdout)]
pub fn run(path: &PathBuf, title: &str) {
let source = std::fs::read_to_string(path).unwrap();
let suite = match test_parser::parse(title, &source) {
Ok(suite) => suite,
Err(err) => {
panic!("Error parsing `{}`: {err}", path.to_str().unwrap())
}
};
let mut any_failures = false;
for test in suite.tests() {
if let Err(failures) = run_test(&test) {
any_failures = true;
println!("\n{}\n", test.name().bold().underline());
for (path, by_line) in failures {
println!("{}", path.as_str().bold());
for (line_number, failures) in by_line.iter() {
for failure in failures {
let line_info = format!("line {line_number}:").cyan();
println!(" {line_info} {failure}");
}
}
println!();
}
}
}
println!("{}\n", "-".repeat(50));
assert!(!any_failures, "Some tests failed.");
}
fn run_test(test: &parser::MarkdownTest) -> Result<(), Failures> {
let workspace_root = SystemPathBuf::from("/src");
let mut db = db::Db::setup(workspace_root.clone());
let mut system_paths = vec![];
for file in test.files() {
assert!(
matches!(file.lang, "py" | "pyi"),
"Non-Python files not supported yet."
);
let full_path = workspace_root.join(file.path);
db.write_file(&full_path, file.code).unwrap();
system_paths.push(full_path);
}
let mut failures = BTreeMap::default();
for path in system_paths {
let file = system_path_to_file(&db, path.clone()).unwrap();
let parsed = parsed_module(&db, file);
// TODO allow testing against code with syntax errors
assert!(
parsed.errors().is_empty(),
"Python syntax errors in {}, {:?}: {:?}",
test.name(),
path,
parsed.errors()
);
matcher::match_file(&db, file, check_types(&db, file)).unwrap_or_else(|line_failures| {
failures.insert(path, line_failures);
});
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}

View File

@@ -0,0 +1,929 @@
//! Match [`TypeCheckDiagnostic`]s against [`Assertion`]s and produce test failure messages for any
//! mismatches.
use crate::assertion::{Assertion, ErrorAssertion, InlineFileAssertions};
use crate::db::Db;
use crate::diagnostic::SortedDiagnostics;
use colored::Colorize;
use red_knot_python_semantic::types::TypeCheckDiagnostic;
use ruff_db::files::File;
use ruff_db::source::{line_index, source_text, SourceText};
use ruff_source_file::{LineIndex, OneIndexed};
use ruff_text_size::Ranged;
use std::cmp::Ordering;
use std::ops::Range;
use std::sync::Arc;
#[derive(Debug, Default)]
pub(super) struct FailuresByLine {
failures: Vec<String>,
lines: Vec<LineFailures>,
}
impl FailuresByLine {
pub(super) fn iter(&self) -> impl Iterator<Item = (OneIndexed, &[String])> {
self.lines.iter().map(|line_failures| {
(
line_failures.line_number,
&self.failures[line_failures.range.clone()],
)
})
}
fn push(&mut self, line_number: OneIndexed, messages: Vec<String>) {
let start = self.failures.len();
self.failures.extend(messages);
self.lines.push(LineFailures {
line_number,
range: start..self.failures.len(),
});
}
fn is_empty(&self) -> bool {
self.lines.is_empty()
}
}
#[derive(Debug)]
struct LineFailures {
line_number: OneIndexed,
range: Range<usize>,
}
pub(super) fn match_file<T>(
db: &Db,
file: File,
diagnostics: impl IntoIterator<Item = T>,
) -> Result<(), FailuresByLine>
where
T: Diagnostic + Clone,
{
// Parse assertions from comments in the file, and get diagnostics from the file; both
// ordered by line number.
let assertions = InlineFileAssertions::from_file(db, file);
let diagnostics = SortedDiagnostics::new(diagnostics, &line_index(db, file));
// Get iterators over assertions and diagnostics grouped by line, in ascending line order.
let mut line_assertions = assertions.into_iter();
let mut line_diagnostics = diagnostics.iter_lines();
let mut current_assertions = line_assertions.next();
let mut current_diagnostics = line_diagnostics.next();
let matcher = Matcher::from_file(db, file);
let mut failures = FailuresByLine::default();
loop {
match (&current_assertions, &current_diagnostics) {
(Some(assertions), Some(diagnostics)) => {
match assertions.line_number.cmp(&diagnostics.line_number) {
Ordering::Equal => {
// We have assertions and diagnostics on the same line; check for
// matches and error on any that don't match, then advance both
// iterators.
matcher
.match_line(diagnostics, assertions)
.unwrap_or_else(|messages| {
failures.push(assertions.line_number, messages);
});
current_assertions = line_assertions.next();
current_diagnostics = line_diagnostics.next();
}
Ordering::Less => {
// We have assertions on an earlier line than diagnostics; report these
// assertions as all unmatched, and advance the assertions iterator.
failures.push(assertions.line_number, unmatched(assertions));
current_assertions = line_assertions.next();
}
Ordering::Greater => {
// We have diagnostics on an earlier line than assertions; report these
// diagnostics as all unmatched, and advance the diagnostics iterator.
failures.push(diagnostics.line_number, unmatched(diagnostics));
current_diagnostics = line_diagnostics.next();
}
}
}
(Some(assertions), None) => {
// We've exhausted diagnostics but still have assertions; report these assertions
// as unmatched and advance the assertions iterator.
failures.push(assertions.line_number, unmatched(assertions));
current_assertions = line_assertions.next();
}
(None, Some(diagnostics)) => {
// We've exhausted assertions but still have diagnostics; report these
// diagnostics as unmatched and advance the diagnostics iterator.
failures.push(diagnostics.line_number, unmatched(diagnostics));
current_diagnostics = line_diagnostics.next();
}
// When we've exhausted both diagnostics and assertions, break.
(None, None) => break,
}
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
pub(super) trait Diagnostic: Ranged {
fn rule(&self) -> &str;
fn message(&self) -> &str;
}
impl Diagnostic for Arc<TypeCheckDiagnostic> {
fn rule(&self) -> &str {
self.as_ref().rule()
}
fn message(&self) -> &str {
self.as_ref().message()
}
}
trait Unmatched {
fn unmatched(&self) -> String;
}
fn unmatched<'a, T: Unmatched + 'a>(unmatched: &'a [T]) -> Vec<String> {
unmatched.iter().map(Unmatched::unmatched).collect()
}
trait UnmatchedWithColumn {
fn unmatched_with_column(&self, column: OneIndexed) -> String;
}
impl Unmatched for Assertion<'_> {
fn unmatched(&self) -> String {
format!("{} {self}", "unmatched assertion:".red())
}
}
fn maybe_add_undefined_reveal_clarification<T: Diagnostic>(
diagnostic: &T,
original: std::fmt::Arguments,
) -> String {
if diagnostic.rule() == "undefined-reveal" {
format!(
"{} add a `# revealed` assertion on this line (original diagnostic: {original})",
"used built-in `reveal_type`:".yellow()
)
} else {
format!("{} {original}", "unexpected error:".red())
}
}
impl<T> Unmatched for T
where
T: Diagnostic,
{
fn unmatched(&self) -> String {
maybe_add_undefined_reveal_clarification(
self,
format_args!(r#"[{}] "{}""#, self.rule(), self.message()),
)
}
}
impl<T> UnmatchedWithColumn for T
where
T: Diagnostic,
{
fn unmatched_with_column(&self, column: OneIndexed) -> String {
maybe_add_undefined_reveal_clarification(
self,
format_args!(r#"{column} [{}] "{}""#, self.rule(), self.message()),
)
}
}
struct Matcher {
line_index: LineIndex,
source: SourceText,
}
impl Matcher {
fn from_file(db: &Db, file: File) -> Self {
Self {
line_index: line_index(db, file),
source: source_text(db, file),
}
}
/// Check a slice of [`Diagnostic`]s against a slice of [`Assertion`]s.
///
/// Return vector of [`Unmatched`] for any unmatched diagnostics or assertions.
fn match_line<'a, 'b, T: Diagnostic + 'a>(
&self,
diagnostics: &'a [T],
assertions: &'a [Assertion<'b>],
) -> Result<(), Vec<String>>
where
'b: 'a,
{
let mut failures = vec![];
let mut unmatched: Vec<_> = diagnostics.iter().collect();
for assertion in assertions {
if matches!(
assertion,
Assertion::Error(ErrorAssertion {
rule: None,
message_contains: None,
..
})
) {
failures.push(format!(
"{} no rule or message text",
"invalid assertion:".red()
));
continue;
}
if !self.matches(assertion, &mut unmatched) {
failures.push(assertion.unmatched());
}
}
for diagnostic in unmatched {
failures.push(diagnostic.unmatched_with_column(self.column(diagnostic)));
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
fn column<T: Ranged>(&self, ranged: &T) -> OneIndexed {
self.line_index
.source_location(ranged.start(), &self.source)
.column
}
/// Check if `assertion` matches any [`Diagnostic`]s in `unmatched`.
///
/// If so, return `true` and remove the matched diagnostics from `unmatched`. Otherwise, return
/// `false`.
///
/// An `Error` assertion can only match one diagnostic; even if it could match more than one,
/// we short-circuit after the first match.
///
/// A `Revealed` assertion must match a revealed-type diagnostic, and may also match an
/// undefined-reveal diagnostic, if present.
fn matches<T: Diagnostic>(&self, assertion: &Assertion, unmatched: &mut Vec<&T>) -> bool {
match assertion {
Assertion::Error(error) => {
let position = unmatched.iter().position(|diagnostic| {
!error.rule.is_some_and(|rule| rule != diagnostic.rule())
&& !error
.column
.is_some_and(|col| col != self.column(*diagnostic))
&& !error
.message_contains
.is_some_and(|needle| !diagnostic.message().contains(needle))
});
if let Some(position) = position {
unmatched.swap_remove(position);
true
} else {
false
}
}
Assertion::Revealed(expected_type) => {
let mut matched_revealed_type = None;
let mut matched_undefined_reveal = None;
let expected_reveal_type_message = format!("Revealed type is `{expected_type}`");
for (index, diagnostic) in unmatched.iter().enumerate() {
if matched_revealed_type.is_none()
&& diagnostic.rule() == "revealed-type"
&& diagnostic.message() == expected_reveal_type_message
{
matched_revealed_type = Some(index);
} else if matched_undefined_reveal.is_none()
&& diagnostic.rule() == "undefined-reveal"
{
matched_undefined_reveal = Some(index);
}
if matched_revealed_type.is_some() && matched_undefined_reveal.is_some() {
break;
}
}
let mut idx = 0;
unmatched.retain(|_| {
let retain =
Some(idx) != matched_revealed_type && Some(idx) != matched_undefined_reveal;
idx += 1;
retain
});
matched_revealed_type.is_some()
}
}
}
}
#[cfg(test)]
mod tests {
use super::FailuresByLine;
use ruff_db::files::system_path_to_file;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use ruff_python_trivia::textwrap::dedent;
use ruff_source_file::OneIndexed;
use ruff_text_size::{Ranged, TextRange};
#[derive(Clone, Debug)]
struct TestDiagnostic {
rule: &'static str,
message: &'static str,
range: TextRange,
}
impl TestDiagnostic {
fn new(rule: &'static str, message: &'static str, offset: usize) -> Self {
let offset: u32 = offset.try_into().unwrap();
Self {
rule,
message,
range: TextRange::new(offset.into(), (offset + 1).into()),
}
}
}
impl super::Diagnostic for TestDiagnostic {
fn rule(&self) -> &str {
self.rule
}
fn message(&self) -> &str {
self.message
}
}
impl Ranged for TestDiagnostic {
fn range(&self) -> ruff_text_size::TextRange {
self.range
}
}
fn get_result(source: &str, diagnostics: Vec<TestDiagnostic>) -> Result<(), FailuresByLine> {
colored::control::set_override(false);
let mut db = crate::db::Db::setup(SystemPathBuf::from("/src"));
db.write_file("/src/test.py", source).unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
super::match_file(&db, file, diagnostics)
}
fn assert_fail(result: Result<(), FailuresByLine>, messages: &[(usize, &[&str])]) {
let Err(failures) = result else {
panic!("expected a failure");
};
let expected: Vec<(OneIndexed, Vec<String>)> = messages
.iter()
.map(|(idx, msgs)| {
(
OneIndexed::from_zero_indexed(*idx),
msgs.iter().map(ToString::to_string).collect(),
)
})
.collect();
let failures: Vec<(OneIndexed, Vec<String>)> = failures
.iter()
.map(|(idx, msgs)| (idx, msgs.to_vec()))
.collect();
assert_eq!(failures, expected);
}
fn assert_ok(result: &Result<(), FailuresByLine>) {
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn revealed_match() {
let result = get_result(
"x # revealed: Foo",
vec![TestDiagnostic::new(
"revealed-type",
"Revealed type is `Foo`",
0,
)],
);
assert_ok(&result);
}
#[test]
fn revealed_wrong_rule() {
let result = get_result(
"x # revealed: Foo",
vec![TestDiagnostic::new(
"not-revealed-type",
"Revealed type is `Foo`",
0,
)],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: revealed: Foo",
r#"unexpected error: 1 [not-revealed-type] "Revealed type is `Foo`""#,
],
)],
);
}
#[test]
fn revealed_wrong_message() {
let result = get_result(
"x # revealed: Foo",
vec![TestDiagnostic::new("revealed-type", "Something else", 0)],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: revealed: Foo",
r#"unexpected error: 1 [revealed-type] "Something else""#,
],
)],
);
}
#[test]
fn revealed_unmatched() {
let result = get_result("x # revealed: Foo", vec![]);
assert_fail(result, &[(0, &["unmatched assertion: revealed: Foo"])]);
}
#[test]
fn revealed_match_with_undefined() {
let result = get_result(
"x # revealed: Foo",
vec![
TestDiagnostic::new("revealed-type", "Revealed type is `Foo`", 0),
TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0),
],
);
assert_ok(&result);
}
#[test]
fn revealed_match_with_only_undefined() {
let result = get_result(
"x # revealed: Foo",
vec![TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0)],
);
assert_fail(result, &[(0, &["unmatched assertion: revealed: Foo"])]);
}
#[test]
fn revealed_mismatch_with_undefined() {
let result = get_result(
"x # revealed: Foo",
vec![
TestDiagnostic::new("revealed-type", "Revealed type is `Bar`", 0),
TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0),
],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: revealed: Foo",
r#"unexpected error: 1 [revealed-type] "Revealed type is `Bar`""#,
],
)],
);
}
#[test]
fn undefined_reveal_type_unmatched() {
let result = get_result(
"reveal_type(1)",
vec![
TestDiagnostic::new("undefined-reveal", "undefined reveal message", 0),
TestDiagnostic::new("revealed-type", "Revealed type is `Literal[1]`", 12),
],
);
assert_fail(
result,
&[(
0,
&[
"used built-in `reveal_type`: add a `# revealed` assertion on this line (\
original diagnostic: [undefined-reveal] \"undefined reveal message\")",
r#"unexpected error: [revealed-type] "Revealed type is `Literal[1]`""#,
],
)],
);
}
#[test]
fn undefined_reveal_type_mismatched() {
let result = get_result(
"reveal_type(1) # error: [something-else]",
vec![
TestDiagnostic::new("undefined-reveal", "undefined reveal message", 0),
TestDiagnostic::new("revealed-type", "Revealed type is `Literal[1]`", 12),
],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: error: [something-else]",
"used built-in `reveal_type`: add a `# revealed` assertion on this line (\
original diagnostic: 1 [undefined-reveal] \"undefined reveal message\")",
r#"unexpected error: 13 [revealed-type] "Revealed type is `Literal[1]`""#,
],
)],
);
}
#[test]
fn error_unmatched() {
let result = get_result("x # error: [rule]", vec![]);
assert_fail(result, &[(0, &["unmatched assertion: error: [rule]"])]);
}
#[test]
fn error_match_rule() {
let result = get_result(
"x # error: [some-rule]",
vec![TestDiagnostic::new("some-rule", "Any message", 0)],
);
assert_ok(&result);
}
#[test]
fn error_wrong_rule() {
let result = get_result(
"x # error: [some-rule]",
vec![TestDiagnostic::new("anything", "Any message", 0)],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: error: [some-rule]",
r#"unexpected error: 1 [anything] "Any message""#,
],
)],
);
}
#[test]
fn error_match_message() {
let result = get_result(
r#"x # error: "contains this""#,
vec![TestDiagnostic::new("anything", "message contains this", 0)],
);
assert_ok(&result);
}
#[test]
fn error_wrong_message() {
let result = get_result(
r#"x # error: "contains this""#,
vec![TestDiagnostic::new("anything", "Any message", 0)],
);
assert_fail(
result,
&[(
0,
&[
r#"unmatched assertion: error: "contains this""#,
r#"unexpected error: 1 [anything] "Any message""#,
],
)],
);
}
#[test]
fn error_match_column_and_rule() {
let result = get_result(
"x # error: 1 [some-rule]",
vec![TestDiagnostic::new("some-rule", "Any message", 0)],
);
assert_ok(&result);
}
#[test]
fn error_wrong_column() {
let result = get_result(
"x # error: 2 [rule]",
vec![TestDiagnostic::new("rule", "Any message", 0)],
);
assert_fail(
result,
&[(
0,
&[
"unmatched assertion: error: 2 [rule]",
r#"unexpected error: 1 [rule] "Any message""#,
],
)],
);
}
#[test]
fn error_match_column_and_message() {
let result = get_result(
r#"x # error: 1 "contains this""#,
vec![TestDiagnostic::new("anything", "message contains this", 0)],
);
assert_ok(&result);
}
#[test]
fn error_match_rule_and_message() {
let result = get_result(
r#"x # error: [a-rule] "contains this""#,
vec![TestDiagnostic::new("a-rule", "message contains this", 0)],
);
assert_ok(&result);
}
#[test]
fn error_match_all() {
let result = get_result(
r#"x # error: 1 [a-rule] "contains this""#,
vec![TestDiagnostic::new("a-rule", "message contains this", 0)],
);
assert_ok(&result);
}
#[test]
fn error_match_all_wrong_column() {
let result = get_result(
r#"x # error: 2 [some-rule] "contains this""#,
vec![TestDiagnostic::new("some-rule", "message contains this", 0)],
);
assert_fail(
result,
&[(
0,
&[
r#"unmatched assertion: error: 2 [some-rule] "contains this""#,
r#"unexpected error: 1 [some-rule] "message contains this""#,
],
)],
);
}
#[test]
fn error_match_all_wrong_rule() {
let result = get_result(
r#"x # error: 1 [some-rule] "contains this""#,
vec![TestDiagnostic::new(
"other-rule",
"message contains this",
0,
)],
);
assert_fail(
result,
&[(
0,
&[
r#"unmatched assertion: error: 1 [some-rule] "contains this""#,
r#"unexpected error: 1 [other-rule] "message contains this""#,
],
)],
);
}
#[test]
fn error_match_all_wrong_message() {
let result = get_result(
r#"x # error: 1 [some-rule] "contains this""#,
vec![TestDiagnostic::new("some-rule", "Any message", 0)],
);
assert_fail(
result,
&[(
0,
&[
r#"unmatched assertion: error: 1 [some-rule] "contains this""#,
r#"unexpected error: 1 [some-rule] "Any message""#,
],
)],
);
}
#[test]
fn interspersed_matches_and_mismatches() {
let source = dedent(
r#"
1 # error: [line-one]
2
3 # error: [line-three]
4 # error: [line-four]
5
6: # error: [line-six]
"#,
);
let two = source.find('2').unwrap();
let three = source.find('3').unwrap();
let five = source.find('5').unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("line-two", "msg", two),
TestDiagnostic::new("line-three", "msg", three),
TestDiagnostic::new("line-five", "msg", five),
],
);
assert_fail(
result,
&[
(1, &["unmatched assertion: error: [line-one]"]),
(2, &[r#"unexpected error: [line-two] "msg""#]),
(4, &["unmatched assertion: error: [line-four]"]),
(5, &[r#"unexpected error: [line-five] "msg""#]),
(6, &["unmatched assertion: error: [line-six]"]),
],
);
}
#[test]
fn more_diagnostics_than_assertions() {
let source = dedent(
r#"
1 # error: [line-one]
2
"#,
);
let one = source.find('1').unwrap();
let two = source.find('2').unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("line-one", "msg", one),
TestDiagnostic::new("line-two", "msg", two),
],
);
assert_fail(result, &[(2, &[r#"unexpected error: [line-two] "msg""#])]);
}
#[test]
fn multiple_assertions_and_diagnostics_same_line() {
let source = dedent(
"
# error: [one-rule]
# error: [other-rule]
x
",
);
let x = source.find('x').unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("one-rule", "msg", x),
TestDiagnostic::new("other-rule", "msg", x),
],
);
assert_ok(&result);
}
#[test]
fn multiple_assertions_and_diagnostics_same_line_all_same() {
let source = dedent(
"
# error: [one-rule]
# error: [one-rule]
x
",
);
let x = source.find('x').unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("one-rule", "msg", x),
TestDiagnostic::new("one-rule", "msg", x),
],
);
assert_ok(&result);
}
#[test]
fn multiple_assertions_and_diagnostics_same_line_mismatch() {
let source = dedent(
"
# error: [one-rule]
# error: [other-rule]
x
",
);
let x = source.find('x').unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("one-rule", "msg", x),
TestDiagnostic::new("other-rule", "msg", x),
TestDiagnostic::new("third-rule", "msg", x),
],
);
assert_fail(
result,
&[(3, &[r#"unexpected error: 1 [third-rule] "msg""#])],
);
}
#[test]
fn parenthesized_expression() {
let source = dedent(
"
a = b + (
error: [undefined-reveal]
reveal_type(5) # revealed: Literal[5]
)
",
);
let reveal = source.find("reveal_type").unwrap();
let result = get_result(
&source,
vec![
TestDiagnostic::new("undefined-reveal", "msg", reveal),
TestDiagnostic::new("revealed-type", "Revealed type is `Literal[5]`", reveal),
],
);
assert_ok(&result);
}
#[test]
fn bare_error_assertion_not_allowed() {
let source = "x # error:";
let x = source.find('x').unwrap();
let result = get_result(
source,
vec![TestDiagnostic::new("some-rule", "some message", x)],
);
assert_fail(
result,
&[(
0,
&[
"invalid assertion: no rule or message text",
r#"unexpected error: 1 [some-rule] "some message""#,
],
)],
);
}
#[test]
fn column_only_error_assertion_not_allowed() {
let source = "x # error: 1";
let x = source.find('x').unwrap();
let result = get_result(
source,
vec![TestDiagnostic::new("some-rule", "some message", x)],
);
assert_fail(
result,
&[(
0,
&[
"invalid assertion: no rule or message text",
r#"unexpected error: 1 [some-rule] "some message""#,
],
)],
);
}
}

View File

@@ -0,0 +1,576 @@
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use ruff_index::{newtype_index, IndexVec};
use rustc_hash::{FxHashMap, FxHashSet};
/// Parse the Markdown `source` as a test suite with given `title`.
pub(crate) fn parse<'s>(title: &'s str, source: &'s str) -> anyhow::Result<MarkdownTestSuite<'s>> {
let parser = Parser::new(title, source);
parser.parse()
}
/// A parsed markdown file containing tests.
///
/// Borrows from the source string and filepath it was created from.
#[derive(Debug)]
pub(crate) struct MarkdownTestSuite<'s> {
/// Header sections.
sections: IndexVec<SectionId, Section<'s>>,
/// Test files embedded within the Markdown file.
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
}
impl<'s> MarkdownTestSuite<'s> {
pub(crate) fn tests(&self) -> MarkdownTestIterator<'_, 's> {
MarkdownTestIterator {
suite: self,
current_file_index: 0,
}
}
}
/// A single test inside a [`MarkdownTestSuite`].
///
/// A test is a single header section (or the implicit root section, if there are no Markdown
/// headers in the file), containing one or more embedded Python files as fenced code blocks, and
/// containing no nested header subsections.
#[derive(Debug)]
pub(crate) struct MarkdownTest<'m, 's> {
suite: &'m MarkdownTestSuite<'s>,
section: &'m Section<'s>,
files: &'m [EmbeddedFile<'s>],
}
impl<'m, 's> MarkdownTest<'m, 's> {
pub(crate) fn name(&self) -> String {
let mut name = String::new();
let mut parent_id = self.section.parent_id;
while let Some(next_id) = parent_id {
let parent = &self.suite.sections[next_id];
parent_id = parent.parent_id;
if !name.is_empty() {
name.insert_str(0, " - ");
}
name.insert_str(0, parent.title);
}
if !name.is_empty() {
name.push_str(" - ");
}
name.push_str(self.section.title);
name
}
pub(crate) fn files(&self) -> impl Iterator<Item = &'m EmbeddedFile<'s>> {
self.files.iter()
}
}
/// Iterator yielding all [`MarkdownTest`]s in a [`MarkdownTestSuite`].
#[derive(Debug)]
pub(crate) struct MarkdownTestIterator<'m, 's> {
suite: &'m MarkdownTestSuite<'s>,
current_file_index: usize,
}
impl<'m, 's> Iterator for MarkdownTestIterator<'m, 's> {
type Item = MarkdownTest<'m, 's>;
fn next(&mut self) -> Option<Self::Item> {
let mut current_file_index = self.current_file_index;
let mut file = self.suite.files.get(current_file_index.into());
let section_id = file?.section;
while file.is_some_and(|file| file.section == section_id) {
current_file_index += 1;
file = self.suite.files.get(current_file_index.into());
}
let files = &self.suite.files[EmbeddedFileId::from_usize(self.current_file_index)
..EmbeddedFileId::from_usize(current_file_index)];
self.current_file_index = current_file_index;
Some(MarkdownTest {
suite: self.suite,
section: &self.suite.sections[section_id],
files,
})
}
}
#[newtype_index]
struct SectionId;
/// A single header section of a [`MarkdownTestSuite`], or the implicit root "section".
///
/// A header section is the part of a Markdown file beginning with a `#`-prefixed header line, and
/// extending until the next header line at the same or higher outline level (that is, with the
/// same number or fewer `#` characters).
///
/// A header section may either contain one or more embedded Python files (making it a
/// [`MarkdownTest`]), or it may contain nested sections (headers with more `#` characters), but
/// not both.
#[derive(Debug)]
struct Section<'s> {
title: &'s str,
level: u8,
parent_id: Option<SectionId>,
}
#[newtype_index]
struct EmbeddedFileId;
/// A single file embedded in a [`Section`] as a fenced code block.
///
/// Currently must be a Python file (`py` language) or type stub (`pyi`). In the future we plan
/// support other kinds of files as well (TOML configuration, typeshed VERSIONS, `pth` files...).
///
/// A Python embedded file makes its containing [`Section`] into a [`MarkdownTest`], and will be
/// type-checked and searched for inline-comment assertions to match against the diagnostics from
/// type checking.
#[derive(Debug)]
pub(crate) struct EmbeddedFile<'s> {
section: SectionId,
pub(crate) path: &'s str,
pub(crate) lang: &'s str,
pub(crate) code: &'s str,
}
/// Matches an arbitrary amount of whitespace (including newlines), followed by a sequence of `#`
/// characters, followed by a title heading, followed by a newline.
static HEADER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(\s*\n)*(?<level>#+)\s+(?<title>.+)\s*\n").unwrap());
/// Matches a code block fenced by triple backticks, possibly with language and `key=val`
/// configuration items following the opening backticks (in the "tag string" of the code block).
static CODE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^```(?<lang>\w+)(?<config>( +\S+)*)\s*\n(?<code>(.|\n)*?)\n```\s*\n").unwrap()
});
#[derive(Debug)]
struct SectionStack(Vec<SectionId>);
impl SectionStack {
fn new(root_section_id: SectionId) -> Self {
Self(vec![root_section_id])
}
fn push(&mut self, section_id: SectionId) {
self.0.push(section_id);
}
fn pop(&mut self) -> Option<SectionId> {
let popped = self.0.pop();
debug_assert_ne!(popped, None, "Should never pop the implicit root section");
debug_assert!(
!self.0.is_empty(),
"Should never pop the implicit root section"
);
popped
}
fn parent(&mut self) -> SectionId {
*self
.0
.last()
.expect("Should never pop the implicit root section")
}
}
/// Parse the source of a Markdown file into a [`MarkdownTestSuite`].
#[derive(Debug)]
struct Parser<'s> {
/// [`Section`]s of the final [`MarkdownTestSuite`].
sections: IndexVec<SectionId, Section<'s>>,
/// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`].
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
/// The unparsed remainder of the Markdown source.
unparsed: &'s str,
/// Stack of ancestor sections.
stack: SectionStack,
/// Names of embedded files in current active section.
current_section_files: Option<FxHashSet<&'s str>>,
}
impl<'s> Parser<'s> {
fn new(title: &'s str, source: &'s str) -> Self {
let mut sections = IndexVec::default();
let root_section_id = sections.push(Section {
title,
level: 0,
parent_id: None,
});
Self {
sections,
files: IndexVec::default(),
unparsed: source,
stack: SectionStack::new(root_section_id),
current_section_files: None,
}
}
fn parse(mut self) -> anyhow::Result<MarkdownTestSuite<'s>> {
self.parse_impl()?;
Ok(self.finish())
}
fn finish(mut self) -> MarkdownTestSuite<'s> {
self.sections.shrink_to_fit();
self.files.shrink_to_fit();
MarkdownTestSuite {
sections: self.sections,
files: self.files,
}
}
fn parse_impl(&mut self) -> anyhow::Result<()> {
while !self.unparsed.is_empty() {
if let Some(captures) = self.scan(&HEADER_RE) {
self.parse_header(&captures)?;
} else if let Some(captures) = self.scan(&CODE_RE) {
self.parse_code_block(&captures)?;
} else {
// ignore other Markdown syntax (paragraphs, etc) used as comments in the test
if let Some(next_newline) = self.unparsed.find('\n') {
(_, self.unparsed) = self.unparsed.split_at(next_newline + 1);
} else {
break;
}
}
}
Ok(())
}
fn parse_header(&mut self, captures: &Captures<'s>) -> anyhow::Result<()> {
let header_level = captures["level"].len();
self.pop_sections_to_level(header_level);
let parent = self.stack.parent();
let section = Section {
// HEADER_RE can't match without a match for group 'title'.
title: captures.name("title").unwrap().into(),
level: header_level.try_into()?,
parent_id: Some(parent),
};
if self.current_section_files.is_some() {
return Err(anyhow::anyhow!(
"Header '{}' not valid inside a test case; parent '{}' has code files.",
section.title,
self.sections[parent].title,
));
}
let section_id = self.sections.push(section);
self.stack.push(section_id);
self.current_section_files = None;
Ok(())
}
fn parse_code_block(&mut self, captures: &Captures<'s>) -> anyhow::Result<()> {
// We never pop the implicit root section.
let parent = self.stack.parent();
let mut config: FxHashMap<&'s str, &'s str> = FxHashMap::default();
if let Some(config_match) = captures.name("config") {
for item in config_match.as_str().split_whitespace() {
let mut parts = item.split('=');
let key = parts.next().unwrap();
let Some(val) = parts.next() else {
return Err(anyhow::anyhow!("Invalid config item `{}`.", item));
};
if parts.next().is_some() {
return Err(anyhow::anyhow!("Invalid config item `{}`.", item));
}
if config.insert(key, val).is_some() {
return Err(anyhow::anyhow!("Duplicate config item `{}`.", item));
}
}
}
let path = config.get("path").copied().unwrap_or("test.py");
self.files.push(EmbeddedFile {
path,
section: parent,
// CODE_RE can't match without matches for 'lang' and 'code'.
lang: captures.name("lang").unwrap().into(),
code: captures.name("code").unwrap().into(),
});
if let Some(current_files) = &mut self.current_section_files {
if !current_files.insert(path) {
if path == "test.py" {
return Err(anyhow::anyhow!(
"Test `{}` has duplicate files named `{path}`. \
(This is the default filename; \
consider giving some files an explicit name with `path=...`.)",
self.sections[parent].title
));
}
return Err(anyhow::anyhow!(
"Test `{}` has duplicate files named `{path}`.",
self.sections[parent].title
));
};
} else {
self.current_section_files = Some(FxHashSet::from_iter([path]));
}
Ok(())
}
fn pop_sections_to_level(&mut self, level: usize) {
while level <= self.sections[self.stack.parent()].level.into() {
self.stack.pop();
// We would have errored before pushing a child section if there were files, so we know
// no parent section can have files.
self.current_section_files = None;
}
}
/// Get capture groups and advance cursor past match if unparsed text matches `pattern`.
fn scan(&mut self, pattern: &Regex) -> Option<Captures<'s>> {
if let Some(captures) = pattern.captures(self.unparsed) {
let (_, unparsed) = self.unparsed.split_at(captures.get(0).unwrap().end());
self.unparsed = unparsed;
Some(captures)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use ruff_python_trivia::textwrap::dedent;
#[test]
fn empty() {
let mf = super::parse("file.md", "").unwrap();
assert!(mf.tests().next().is_none());
}
#[test]
fn single_file_test() {
let source = dedent(
"
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
assert_eq!(test.name(), "file.md");
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn multiple_tests() {
let source = dedent(
"
# One
```py
x = 1
```
# Two
```py
y = 2
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test1, test2] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected two tests");
};
assert_eq!(test1.name(), "file.md - One");
assert_eq!(test2.name(), "file.md - Two");
let [file] = test1.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
let [file] = test2.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "y = 2");
}
#[test]
fn custom_file_path() {
let source = dedent(
"
```py path=foo.py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn multi_line_file() {
let source = dedent(
"
```py
x = 1
y = 2
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.code, "x = 1\ny = 2");
}
#[test]
fn no_header_inside_test() {
let source = dedent(
"
# One
```py
x = 1
```
## Two
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Header 'Two' not valid inside a test case; parent 'One' has code files."
);
}
#[test]
fn invalid_config_item_no_equals() {
let source = dedent(
"
```py foo
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Invalid config item `foo`.");
}
#[test]
fn invalid_config_item_too_many_equals() {
let source = dedent(
"
```py foo=bar=baz
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Invalid config item `foo=bar=baz`.");
}
#[test]
fn invalid_config_item_duplicate() {
let source = dedent(
"
```py foo=bar foo=baz
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Duplicate config item `foo=baz`.");
}
#[test]
fn no_duplicate_name_files_in_test() {
let source = dedent(
"
```py
x = 1
```
```py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `test.py`. \
(This is the default filename; consider giving some files an explicit name \
with `path=...`.)"
);
}
#[test]
fn no_duplicate_name_files_in_test_non_default() {
let source = dedent(
"
```py path=foo.py
x = 1
```
```py path=foo.py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `foo.py`."
);
}
}

View File

@@ -20,7 +20,6 @@ ruff_python_ast = { workspace = true }
ruff_python_codegen = { workspace = true }
ruff_python_formatter = { workspace = true }
ruff_python_parser = { workspace = true }
ruff_python_stdlib = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_workspace = { workspace = true, features = ["schemars"] }

View File

@@ -6,8 +6,8 @@ use std::path::PathBuf;
use anyhow::Result;
use ruff_python_ast::PySourceType;
use ruff_python_codegen::round_trip;
use ruff_python_stdlib::path::is_jupyter_notebook;
#[derive(clap::Args)]
pub(crate) struct Args {
@@ -18,7 +18,7 @@ pub(crate) struct Args {
pub(crate) fn main(args: &Args) -> Result<()> {
let path = args.file.as_path();
if is_jupyter_notebook(path) {
if PySourceType::from(path).is_ipynb() {
println!("{}", ruff_notebook::round_trip(path)?);
} else {
let contents = fs::read_to_string(&args.file)?;

View File

@@ -36,6 +36,11 @@ async def func():
...
async def main():
async with asyncio.timeout(7):
print({i async for i in long_running_range()})
async def func():
with anyio.move_on_after(delay=0.2):
...

View File

@@ -87,3 +87,7 @@ field24: typing.Union[Literal[1], typing.Union[Literal[2], typing.Union[Literal[
# Should use the first literal subscript attribute when fixing
field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error
from typing import IO, Literal
InlineOption = Literal["a"] | Literal["b"] | IO[str]

View File

@@ -1,5 +1,6 @@
# TDO006 - accepted
# TODO (evanrittenhouse): this is a valid TODO
# Todoism is a word which starts with todo, but is not a todo
# TDO006 - error
# ToDo (evanrittenhouse): invalid capitalization
# todo (evanrittenhouse): another invalid capitalization

View File

@@ -46,3 +46,12 @@ open(p, opener=opener)
open(p, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)
open(p, 'r', - 1, None, None, None, True, None)
open(p, 'r', - 1, None, None, None, False, opener)
# Cannot be upgraded `pathlib.Open` does not support fds
# See https://github.com/astral-sh/ruff/issues/12871
open(1)
open(1, "w")
x = 2
open(x)
def foo(y: int):
open(y)

View File

@@ -104,3 +104,41 @@ x = [
]
}
]
# Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
x:A = "foo"[::-1],
y:B = [[["foo", "bar"]]],
z:object = "fooo",
):
pass
class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
self,
x:A = "foo"[::-1],
y:B = [[["foo", "bar"]]],
z:object = "fooo",
):
pass
class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
pass
# Should be no E231 errors on any of these:
def pep_696_good[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes](
x: A = "foo"[::-1],
y: B = [[["foo", "bar"]]],
z: object = "fooo",
):
pass
class PEP696Good[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes]:
pass
class PEP696GoodWithEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes]():
pass
class PEP696GoodWithNonEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes](object, something_dynamic[x::-1]):
pass

View File

@@ -59,3 +59,19 @@ f"normal {f"{a=}"} normal"
print(f"{foo = }")
# ...but then it creates false negatives for now
print(f"{foo(a = 1)}")
# There should be at least one E251 diagnostic for each type parameter here:
def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
pass
class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
pass
# The last of these should cause us to emit E231,
# but E231 isn't tested by this fixture:
def pep_696_good[A = int, B: object = str, C:object = memoryview]():
pass
class PEP696Good[A = int, B: object = str, C:object = memoryview]:
def pep_696_good_method[A = int, B: object = str, C:object = memoryview](self):
pass

View File

@@ -0,0 +1,53 @@
# Errors
_ = "0123456789"
_ = "01234567"
_ = "0123456789abcdefABCDEF"
_ = "abcdefghijklmnopqrstuvwxyz"
_ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
_ = " \t\n\r\v\f"
_ = "" in "1234567890"
_ = "" in "12345670"
_ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
_ = (
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
"'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
)
_ = id("0123"
"4567"
"89")
_ = "" in ("123"
"456"
"789"
"0")
_ = "" in ( # comment
"123"
"456"
"789"
"0")
_ = "" in (
"123"
"456" # inline comment
"789"
"0")
_ = (
"0123456789"
).capitalize()
_ = (
"0123456789"
# with comment
).capitalize()
# Ok
_ = "1234567890"
_ = "1234"
_ = "" in "1234"

View File

@@ -169,4 +169,32 @@ def ignore_step():
text = "!x!y!z"
if text.startswith("!"):
text = text[1::2]
print(text)
print(text)
def handle_unicode():
# should be skipped!
text = "řetězec"
if text.startswith("ř"):
text = text[2:]
# should be linted
# with fix `text = text.removeprefix("ř")`
text = "řetězec"
if text.startswith("ř"):
text = text[1:]
def handle_surrogates():
# should be linted
text = "\ud800\udc00heythere"
if text.startswith("\ud800\udc00"):
text = text[2:]
text = "\U00010000heythere"
if text.startswith("\U00010000"):
text = text[1:]
# should not be linted
text = "\ud800\udc00heythere"
if text.startswith("\ud800\udc00"):
text = text[1:]

View File

@@ -185,3 +185,9 @@ def f():
global task
loop = asyncio.get_event_loop()
task = loop.create_task(main()) # Error
# OK
# The task is returned by the lambda
f = lambda *args: asyncio.create_task(foo())
f = lambda *args: lambda *args: asyncio.create_task(foo())
f = lambda *args: [asyncio.create_task(foo()) for x in args]

View File

@@ -1355,6 +1355,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.enabled(Rule::SingleItemMembershipTest) {
refurb::rules::single_item_membership_test(checker, expr, left, ops, comparators);
}
if checker.enabled(Rule::HardcodedStringCharset) {
refurb::rules::hardcoded_string_charset_comparison(checker, compare);
}
}
Expr::NumberLiteral(number_literal @ ast::ExprNumberLiteral { .. }) => {
if checker.source_type.is_stub() && checker.enabled(Rule::NumericLiteralTooLong) {
@@ -1364,7 +1367,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
refurb::rules::math_constant(checker, number_literal);
}
}
Expr::StringLiteral(ast::ExprStringLiteral { value, range: _ }) => {
Expr::StringLiteral(string_like @ ast::ExprStringLiteral { value, range: _ }) => {
if checker.enabled(Rule::UnicodeKindPrefix) {
for string_part in value {
pyupgrade::rules::unicode_kind_prefix(checker, string_part);
@@ -1375,6 +1378,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
ruff::rules::missing_fstring_syntax(checker, string_literal);
}
}
if checker.enabled(Rule::HardcodedStringCharset) {
refurb::rules::hardcoded_string_charset_literal(checker, string_like);
}
}
Expr::If(
if_exp @ ast::ExprIf {

View File

@@ -1055,6 +1055,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Refurb, "148") => (RuleGroup::Preview, rules::refurb::rules::UnnecessaryEnumerate),
(Refurb, "152") => (RuleGroup::Preview, rules::refurb::rules::MathConstant),
(Refurb, "154") => (RuleGroup::Preview, rules::refurb::rules::RepeatedGlobal),
(Refurb, "156") => (RuleGroup::Preview, rules::refurb::rules::HardcodedStringCharset),
(Refurb, "157") => (RuleGroup::Preview, rules::refurb::rules::VerboseDecimalConstructor),
(Refurb, "161") => (RuleGroup::Stable, rules::refurb::rules::BitCount),
(Refurb, "163") => (RuleGroup::Stable, rules::refurb::rules::RedundantLogBase),

View File

@@ -287,19 +287,23 @@ impl<'a> TodoDirective<'a> {
pub(crate) fn from_comment(comment: &'a str, comment_range: TextRange) -> Option<Self> {
// The directive's offset from the start of the comment.
let mut relative_offset = TextSize::new(0);
let mut subset_opt = Some(comment);
let mut subset = comment;
// Loop over `#`-delimited sections of the comment to check for directives. This will
// correctly handle cases like `# foo # TODO`.
while let Some(subset) = subset_opt {
loop {
let trimmed = subset.trim_start_matches('#').trim_start();
let offset = subset.text_len() - trimmed.text_len();
relative_offset += offset;
// Find the first word. Don't use split by whitespace because that would include the `:` character
// in `TODO:`
let first_word = trimmed.split(|c: char| !c.is_alphanumeric()).next()?;
// If we detect a TodoDirectiveKind variant substring in the comment, construct and
// return the appropriate TodoDirective
if let Ok(directive_kind) = trimmed.parse::<TodoDirectiveKind>() {
if let Ok(directive_kind) = first_word.parse::<TodoDirectiveKind>() {
let len = directive_kind.len();
return Some(Self {
@@ -310,11 +314,11 @@ impl<'a> TodoDirective<'a> {
}
// Shrink the subset to check for the next phrase starting with "#".
subset_opt = if let Some(new_offset) = trimmed.find('#') {
if let Some(new_offset) = trimmed.find('#') {
relative_offset += TextSize::try_from(new_offset).unwrap();
subset.get(relative_offset.to_usize()..)
subset = &subset[relative_offset.to_usize()..];
} else {
None
break;
};
}
@@ -334,30 +338,13 @@ impl FromStr for TodoDirectiveKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
// The lengths of the respective variant strings: TODO, FIXME, HACK, XXX
for length in [3, 4, 5] {
let Some(substr) = s.get(..length) else {
break;
};
match substr.to_lowercase().as_str() {
"fixme" => {
return Ok(TodoDirectiveKind::Fixme);
}
"hack" => {
return Ok(TodoDirectiveKind::Hack);
}
"todo" => {
return Ok(TodoDirectiveKind::Todo);
}
"xxx" => {
return Ok(TodoDirectiveKind::Xxx);
}
_ => continue,
}
match s.to_lowercase().as_str() {
"fixme" => Ok(TodoDirectiveKind::Fixme),
"hack" => Ok(TodoDirectiveKind::Hack),
"todo" => Ok(TodoDirectiveKind::Todo),
"xxx" => Ok(TodoDirectiveKind::Xxx),
_ => Err(()),
}
Err(())
}
}

View File

@@ -19,28 +19,19 @@ ASYNC100.py:18:5: ASYNC100 A `with trio.move_on_after(...):` context does not co
| |___________^ ASYNC100
|
ASYNC100.py:40:5: ASYNC100 A `with anyio.move_on_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
39 | async def func():
40 | with anyio.move_on_after(delay=0.2):
| _____^
41 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:45:5: ASYNC100 A `with anyio.fail_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:45:5: ASYNC100 A `with anyio.move_on_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
44 | async def func():
45 | with anyio.fail_after():
45 | with anyio.move_on_after(delay=0.2):
| _____^
46 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:50:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:50:5: ASYNC100 A `with anyio.fail_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
49 | async def func():
50 | with anyio.CancelScope():
50 | with anyio.fail_after():
| _____^
51 | | ...
| |___________^ ASYNC100
@@ -49,7 +40,7 @@ ASYNC100.py:50:5: ASYNC100 A `with anyio.CancelScope(...):` context does not con
ASYNC100.py:55:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
54 | async def func():
55 | with anyio.CancelScope(), nullcontext():
55 | with anyio.CancelScope():
| _____^
56 | | ...
| |___________^ ASYNC100
@@ -58,44 +49,53 @@ ASYNC100.py:55:5: ASYNC100 A `with anyio.CancelScope(...):` context does not con
ASYNC100.py:60:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
59 | async def func():
60 | with nullcontext(), anyio.CancelScope():
60 | with anyio.CancelScope(), nullcontext():
| _____^
61 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:65:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:65:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
64 | async def func():
65 | async with asyncio.timeout(delay=0.2):
65 | with nullcontext(), anyio.CancelScope():
| _____^
66 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:70:5: ASYNC100 A `with asyncio.timeout_at(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:70:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
69 | async def func():
70 | async with asyncio.timeout_at(when=0.2):
70 | async with asyncio.timeout(delay=0.2):
| _____^
71 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:80:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:75:5: ASYNC100 A `with asyncio.timeout_at(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
79 | async def func():
80 | async with asyncio.timeout(delay=0.2), asyncio.TaskGroup(), asyncio.timeout(delay=0.2):
74 | async def func():
75 | async with asyncio.timeout_at(when=0.2):
| _____^
81 | | ...
76 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:90:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
ASYNC100.py:85:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
89 | async def func():
90 | async with asyncio.timeout(delay=0.2), asyncio.timeout(delay=0.2):
84 | async def func():
85 | async with asyncio.timeout(delay=0.2), asyncio.TaskGroup(), asyncio.timeout(delay=0.2):
| _____^
91 | | ...
86 | | ...
| |___________^ ASYNC100
|
ASYNC100.py:95:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint.
|
94 | async def func():
95 | async with asyncio.timeout(delay=0.2), asyncio.timeout(delay=0.2):
| _____^
96 | | ...
| |___________^ ASYNC100
|

View File

@@ -191,22 +191,28 @@ impl Violation for StartProcessWithAShell {
/// Checks for functions that start a process without a shell.
///
/// ## Why is this bad?
/// The `subprocess` module provides more powerful facilities for spawning new
/// processes and retrieving their results; using that module is preferable to
/// using these functions.
/// Invoking any kind of external executable via a function call can pose
/// security risks if arbitrary variables are passed to the executable, or if
/// the input is otherwise unsanitised or unvalidated.
///
/// This rule specifically flags functions in the `os` module that spawn
/// subprocesses *without* the use of a shell. Note that these typically pose a
/// much smaller security risk than subprocesses that are started *with* a
/// shell, which are flagged by [`start-process-with-a-shell`] (`S605`). This
/// gives you the option of enabling one rule while disabling the other if you
/// decide that the security risk from these functions is acceptable for your
/// use case.
///
/// ## Example
/// ```python
/// os.spawnlp(os.P_NOWAIT, "/bin/mycmd", "mycmd", "myarg")
/// import os
///
///
/// def insecure_function(arbitrary_user_input: str):
/// os.spawnlp(os.P_NOWAIT, "/bin/mycmd", "mycmd", arbitrary_user_input)
/// ```
///
/// Use instead:
/// ```python
/// subprocess.Popen(["/bin/mycmd", "myarg"])
/// ```
///
/// ## References
/// - [Python documentation: Replacing the `os.spawn` family](https://docs.python.org/3/library/subprocess.html#replacing-the-os-spawn-family)
/// [start-process-with-a-shell]: https://docs.astral.sh/ruff/rules/start-process-with-a-shell/#start-process-with-a-shell-s605
#[violation]
pub struct StartProcessWithNoShell;
@@ -480,7 +486,7 @@ impl From<&Expr> for Safety {
///
/// ## Examples
/// ```python
/// import subprocess
/// import os
///
/// os.system("/bin/ls")
/// os.system("./bin/ls")

View File

@@ -16,9 +16,9 @@ use crate::fix::edits::add_argument;
/// resulting iterator will be silently truncated to the length of the shortest
/// iterable. This can lead to subtle bugs.
///
/// Use the `strict` parameter to raise a `ValueError` if the iterables are of
/// non-uniform length. If the iterables are intentionally different lengths, the
/// parameter should be explicitly set to `False`.
/// Pass `strict=True` to raise a `ValueError` if the iterables are of
/// non-uniform length. Alternatively, if the iterables are deliberately
/// different lengths, pass `strict=False` to make the intention explicit.
///
/// ## Example
/// ```python
@@ -47,7 +47,7 @@ impl AlwaysFixableViolation for ZipWithoutExplicitStrict {
}
fn fix_title(&self) -> String {
"Add explicit `strict=False`".to_string()
"Add explicit value for parameter `strict=`".to_string()
}
}

View File

@@ -9,7 +9,7 @@ B905.py:4:1: B905 [*] `zip()` without an explicit `strict=` parameter
5 | zip(range(3))
6 | zip("a", "b")
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
1 1 | from itertools import count, cycle, repeat
@@ -30,7 +30,7 @@ B905.py:5:1: B905 [*] `zip()` without an explicit `strict=` parameter
6 | zip("a", "b")
7 | zip("a", "b", *zip("c"))
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
2 2 |
@@ -51,7 +51,7 @@ B905.py:6:1: B905 [*] `zip()` without an explicit `strict=` parameter
7 | zip("a", "b", *zip("c"))
8 | zip(zip("a"), strict=False)
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
3 3 | # Errors
@@ -72,7 +72,7 @@ B905.py:7:1: B905 [*] `zip()` without an explicit `strict=` parameter
8 | zip(zip("a"), strict=False)
9 | zip(zip("a", strict=True))
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
4 4 | zip()
@@ -93,7 +93,7 @@ B905.py:7:16: B905 [*] `zip()` without an explicit `strict=` parameter
8 | zip(zip("a"), strict=False)
9 | zip(zip("a", strict=True))
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
4 4 | zip()
@@ -113,7 +113,7 @@ B905.py:8:5: B905 [*] `zip()` without an explicit `strict=` parameter
| ^^^^^^^^ B905
9 | zip(zip("a", strict=True))
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
5 5 | zip(range(3))
@@ -134,7 +134,7 @@ B905.py:9:1: B905 [*] `zip()` without an explicit `strict=` parameter
10 |
11 | # OK
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
6 6 | zip("a", "b")
@@ -153,7 +153,7 @@ B905.py:24:1: B905 [*] `zip()` without an explicit `strict=` parameter
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B905
25 | zip([1, 2, 3], repeat(1, times=4))
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
21 21 | zip([1, 2, 3], repeat(1, times=None))
@@ -174,7 +174,7 @@ B905.py:25:1: B905 [*] `zip()` without an explicit `strict=` parameter
26 |
27 | import builtins
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
22 22 |
@@ -193,7 +193,7 @@ B905.py:29:1: B905 [*] `zip()` without an explicit `strict=` parameter
29 | builtins.zip([1, 2, 3])
| ^^^^^^^^^^^^^^^^^^^^^^^ B905
|
= help: Add explicit `strict=False`
= help: Add explicit value for parameter `strict=`
Safe fix
26 26 |

View File

@@ -2,6 +2,7 @@ use std::path::Path;
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::PySourceType;
use ruff_python_stdlib::path::is_module_file;
use ruff_python_stdlib::sys::is_known_standard_library;
use ruff_text_size::TextRange;
@@ -42,10 +43,7 @@ pub(crate) fn builtin_module_shadowing(
allowed_modules: &[String],
target_version: PythonVersion,
) -> Option<Diagnostic> {
if !path
.extension()
.is_some_and(|ext| ext == "py" || ext == "pyi")
{
if !PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file_or_stub) {
return None;
}

View File

@@ -12,9 +12,8 @@ use crate::rules::flake8_comprehensions::fixes;
/// Checks for unnecessary `dict`, `list`, and `set` comprehension.
///
/// ## Why is this bad?
/// It's unnecessary to use a `dict`/`list`/`set` comprehension to build a
/// data structure if the elements are unchanged. Wrap the iterable with
/// `dict()`, `list()`, or `set()` instead.
/// It's unnecessary to use a `dict`/`list`/`set` comprehension to build a data structure if the
/// elements are unchanged. Wrap the iterable with `dict()`, `list()`, or `set()` instead.
///
/// ## Examples
/// ```python
@@ -30,10 +29,33 @@ use crate::rules::flake8_comprehensions::fixes;
/// set(iterable)
/// ```
///
/// ## Known problems
///
/// This rule may produce false positives for dictionary comprehensions that iterate over a mapping.
/// The `dict` constructor behaves differently depending on if it receives a sequence (e.g., a
/// `list`) or a mapping (e.g., a `dict`). When a comprehension iterates over the keys of a mapping,
/// replacing it with a `dict` constructor call will give a different result.
///
/// For example:
///
/// ```pycon
/// >>> d1 = {(1, 2): 3, (4, 5): 6}
/// >>> {x: y for x, y in d1} # Iterates over the keys of a mapping
/// {1: 2, 4: 5}
/// >>> dict(d1) # Ruff's incorrect suggested fix
/// (1, 2): 3, (4, 5): 6}
/// >>> dict(d1.keys()) # Correct fix
/// {1: 2, 4: 5}
/// ```
///
/// When the comprehension iterates over a sequence, Ruff's suggested fix is correct. However, Ruff
/// cannot consistently infer if the iterable type is a sequence or a mapping and cannot suggest
/// the correct fix for mappings.
///
/// ## Fix safety
/// This rule's fix is marked as unsafe, as it may occasionally drop comments
/// when rewriting the comprehension. In most cases, though, comments will be
/// preserved.
/// Due to the known problem with dictionary comprehensions, this fix is marked as unsafe.
///
/// Additionally, this fix may drop comments when rewriting the comprehension.
#[violation]
pub struct UnnecessaryComprehension {
obj_type: String,

View File

@@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::PySourceType;
use ruff_python_trivia::CommentRanges;
use ruff_source_file::Locator;
use ruff_text_size::{TextRange, TextSize};
@@ -51,7 +52,7 @@ pub(crate) fn implicit_namespace_package(
) -> Option<Diagnostic> {
if package.is_none()
// Ignore non-`.py` files, which don't require an `__init__.py`.
&& path.extension().is_some_and( |ext| ext == "py")
&& PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file)
// Ignore any files that are direct children of the project root.
&& !path
.parent()

View File

@@ -80,6 +80,8 @@ pub(crate) fn unnecessary_literal_union<'a>(checker: &mut Checker, expr: &'a Exp
} else {
literal_exprs.push(slice);
}
} else {
other_exprs.push(expr);
}
} else {
other_exprs.push(expr);

View File

@@ -517,6 +517,8 @@ PYI030.py:89:10: PYI030 [*] Multiple literal members in a union. Use a single li
88 | # Should use the first literal subscript attribute when fixing
89 | field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI030
90 |
91 | from typing import IO, Literal
|
= help: Replace with a single `Literal`
@@ -526,5 +528,22 @@ PYI030.py:89:10: PYI030 [*] Multiple literal members in a union. Use a single li
88 88 | # Should use the first literal subscript attribute when fixing
89 |-field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error
89 |+field25: typing.Union[typing_extensions.Literal[1, 2, 3, 4], str] # Error
90 90 |
91 91 | from typing import IO, Literal
92 92 |
PYI030.py:93:16: PYI030 [*] Multiple literal members in a union. Use a single literal, e.g. `Literal["a", "b"]`
|
91 | from typing import IO, Literal
92 |
93 | InlineOption = Literal["a"] | Literal["b"] | IO[str]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI030
|
= help: Replace with a single `Literal`
Safe fix
90 90 |
91 91 | from typing import IO, Literal
92 92 |
93 |-InlineOption = Literal["a"] | Literal["b"] | IO[str]
93 |+InlineOption = Literal["a", "b"] | IO[str]

View File

@@ -1,58 +1,56 @@
---
source: crates/ruff_linter/src/rules/flake8_todos/mod.rs
---
TD006.py:4:3: TD006 [*] Invalid TODO capitalization: `ToDo` should be `TODO`
TD006.py:5:3: TD006 [*] Invalid TODO capitalization: `ToDo` should be `TODO`
|
2 | # TODO (evanrittenhouse): this is a valid TODO
3 | # TDO006 - error
4 | # ToDo (evanrittenhouse): invalid capitalization
3 | # Todoism is a word which starts with todo, but is not a todo
4 | # TDO006 - error
5 | # ToDo (evanrittenhouse): invalid capitalization
| ^^^^ TD006
5 | # todo (evanrittenhouse): another invalid capitalization
6 | # foo # todo: invalid capitalization
6 | # todo (evanrittenhouse): another invalid capitalization
7 | # foo # todo: invalid capitalization
|
= help: Replace `ToDo` with `TODO`
Safe fix
1 1 | # TDO006 - accepted
2 2 | # TODO (evanrittenhouse): this is a valid TODO
3 3 | # TDO006 - error
4 |-# ToDo (evanrittenhouse): invalid capitalization
4 |+# TODO (evanrittenhouse): invalid capitalization
5 5 | # todo (evanrittenhouse): another invalid capitalization
6 6 | # foo # todo: invalid capitalization
3 3 | # Todoism is a word which starts with todo, but is not a todo
4 4 | # TDO006 - error
5 |-# ToDo (evanrittenhouse): invalid capitalization
5 |+# TODO (evanrittenhouse): invalid capitalization
6 6 | # todo (evanrittenhouse): another invalid capitalization
7 7 | # foo # todo: invalid capitalization
TD006.py:5:3: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO`
TD006.py:6:3: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO`
|
3 | # TDO006 - error
4 | # ToDo (evanrittenhouse): invalid capitalization
5 | # todo (evanrittenhouse): another invalid capitalization
4 | # TDO006 - error
5 | # ToDo (evanrittenhouse): invalid capitalization
6 | # todo (evanrittenhouse): another invalid capitalization
| ^^^^ TD006
6 | # foo # todo: invalid capitalization
7 | # foo # todo: invalid capitalization
|
= help: Replace `todo` with `TODO`
Safe fix
2 2 | # TODO (evanrittenhouse): this is a valid TODO
3 3 | # TDO006 - error
4 4 | # ToDo (evanrittenhouse): invalid capitalization
5 |-# todo (evanrittenhouse): another invalid capitalization
5 |+# TODO (evanrittenhouse): another invalid capitalization
6 6 | # foo # todo: invalid capitalization
3 3 | # Todoism is a word which starts with todo, but is not a todo
4 4 | # TDO006 - error
5 5 | # ToDo (evanrittenhouse): invalid capitalization
6 |-# todo (evanrittenhouse): another invalid capitalization
6 |+# TODO (evanrittenhouse): another invalid capitalization
7 7 | # foo # todo: invalid capitalization
TD006.py:6:9: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO`
TD006.py:7:9: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO`
|
4 | # ToDo (evanrittenhouse): invalid capitalization
5 | # todo (evanrittenhouse): another invalid capitalization
6 | # foo # todo: invalid capitalization
5 | # ToDo (evanrittenhouse): invalid capitalization
6 | # todo (evanrittenhouse): another invalid capitalization
7 | # foo # todo: invalid capitalization
| ^^^^ TD006
|
= help: Replace `todo` with `TODO`
Safe fix
3 3 | # TDO006 - error
4 4 | # ToDo (evanrittenhouse): invalid capitalization
5 5 | # todo (evanrittenhouse): another invalid capitalization
6 |-# foo # todo: invalid capitalization
6 |+# foo # TODO: invalid capitalization
4 4 | # TDO006 - error
5 5 | # ToDo (evanrittenhouse): invalid capitalization
6 6 | # todo (evanrittenhouse): another invalid capitalization
7 |-# foo # todo: invalid capitalization
7 |+# foo # TODO: invalid capitalization

View File

@@ -1,5 +1,7 @@
use ruff_diagnostics::{Diagnostic, DiagnosticKind};
use ruff_python_ast::{Expr, ExprBooleanLiteral, ExprCall};
use ruff_python_ast::{self as ast, Expr, ExprBooleanLiteral, ExprCall};
use ruff_python_semantic::analyze::typing;
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
@@ -124,6 +126,10 @@ pub(crate) fn replaceable_by_pathlib(checker: &mut Checker, call: &ExprCall) {
.arguments
.find_argument("opener", 7)
.is_some_and(|expr| !expr.is_none_literal_expr())
|| call
.arguments
.find_positional(0)
.is_some_and(|expr| is_file_descriptor(expr, checker.semantic()))
{
return None;
}
@@ -159,3 +165,26 @@ pub(crate) fn replaceable_by_pathlib(checker: &mut Checker, call: &ExprCall) {
}
}
}
/// Returns `true` if the given expression looks like a file descriptor, i.e., if it is an integer.
fn is_file_descriptor(expr: &Expr, semantic: &SemanticModel) -> bool {
if matches!(
expr,
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: ast::Number::Int(_),
..
})
) {
return true;
};
let Some(name) = expr.as_name_expr() else {
return false;
};
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return false;
};
typing::is_int(binding, semantic)
}

View File

@@ -317,5 +317,3 @@ full_name.py:47:1: PTH123 `open()` should be replaced by `Path.open()`
| ^^^^ PTH123
48 | open(p, 'r', - 1, None, None, None, False, opener)
|

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::PySourceType;
use ruff_python_stdlib::identifiers::{is_migration_name, is_module_name};
use ruff_python_stdlib::path::is_module_file;
use ruff_text_size::TextRange;
@@ -53,10 +54,7 @@ pub(crate) fn invalid_module_name(
package: Option<&Path>,
ignore_names: &IgnoreNames,
) -> Option<Diagnostic> {
if !path
.extension()
.is_some_and(|ext| ext == "py" || ext == "pyi")
{
if !PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file_or_stub) {
return None;
}

View File

@@ -6,7 +6,7 @@ use ruff_text_size::Ranged;
use crate::checkers::logical_lines::LogicalLinesContext;
use super::LogicalLine;
use super::{DefinitionState, LogicalLine};
/// ## What it does
/// Checks for missing whitespace after `,`, `;`, and `:`.
@@ -28,22 +28,10 @@ pub struct MissingWhitespace {
token: TokenKind,
}
impl MissingWhitespace {
fn token_text(&self) -> char {
match self.token {
TokenKind::Colon => ':',
TokenKind::Semi => ';',
TokenKind::Comma => ',',
_ => unreachable!(),
}
}
}
impl AlwaysFixableViolation for MissingWhitespace {
#[derive_message_formats]
fn message(&self) -> String {
let token = self.token_text();
format!("Missing whitespace after '{token}'")
format!("Missing whitespace after {}", self.token)
}
fn fix_title(&self) -> String {
@@ -54,11 +42,13 @@ impl AlwaysFixableViolation for MissingWhitespace {
/// E231
pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesContext) {
let mut fstrings = 0u32;
let mut definition_state = DefinitionState::from_tokens(line.tokens());
let mut brackets = Vec::new();
let mut iter = line.tokens().iter().peekable();
while let Some(token) = iter.next() {
let kind = token.kind();
definition_state.visit_token_kind(kind);
match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
@@ -97,7 +87,9 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesC
if let Some(next_token) = iter.peek() {
match (kind, next_token.kind()) {
(TokenKind::Colon, _)
if matches!(brackets.last(), Some(TokenKind::Lsqb)) =>
if matches!(brackets.last(), Some(TokenKind::Lsqb))
&& !(definition_state.in_type_params()
&& brackets.len() == 1) =>
{
continue; // Slice syntax, no space required
}
@@ -111,13 +103,10 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesC
}
}
let mut diagnostic =
let diagnostic =
Diagnostic::new(MissingWhitespace { token: kind }, token.range());
diagnostic.set_fix(Fix::safe_edit(Edit::insertion(
" ".to_string(),
token.end(),
)));
context.push_diagnostic(diagnostic);
let fix = Fix::safe_edit(Edit::insertion(" ".to_string(), token.end()));
context.push_diagnostic(diagnostic.with_fix(fix));
}
}
_ => {}

View File

@@ -470,6 +470,112 @@ struct Line {
tokens_end: u32,
}
/// Keeps track of whether we are currently visiting a class or function definition in a
/// [`LogicalLine`]. If we are visiting a class or function, the enum also keeps track
/// of the [type parameters] of the class/function.
///
/// Call [`DefinitionState::visit_token_kind`] on the [`TokenKind`] of each
/// successive [`LogicalLineToken`] to ensure the state remains up to date.
///
/// [type parameters]: https://docs.python.org/3/reference/compound_stmts.html#type-params
#[derive(Debug, Clone, Copy)]
enum DefinitionState {
InClass(TypeParamsState),
InFunction(TypeParamsState),
NotInClassOrFunction,
}
impl DefinitionState {
fn from_tokens<'a>(tokens: impl IntoIterator<Item = &'a LogicalLineToken>) -> Self {
let mut token_kinds = tokens.into_iter().map(LogicalLineToken::kind);
while let Some(token_kind) = token_kinds.next() {
let state = match token_kind {
TokenKind::Indent | TokenKind::Dedent => continue,
TokenKind::Class => Self::InClass(TypeParamsState::default()),
TokenKind::Def => Self::InFunction(TypeParamsState::default()),
TokenKind::Async if matches!(token_kinds.next(), Some(TokenKind::Def)) => {
Self::InFunction(TypeParamsState::default())
}
_ => Self::NotInClassOrFunction,
};
return state;
}
Self::NotInClassOrFunction
}
const fn in_function_definition(self) -> bool {
matches!(self, Self::InFunction(_))
}
const fn type_params_state(self) -> Option<TypeParamsState> {
match self {
Self::InClass(state) | Self::InFunction(state) => Some(state),
Self::NotInClassOrFunction => None,
}
}
fn in_type_params(self) -> bool {
matches!(
self.type_params_state(),
Some(TypeParamsState::InTypeParams { .. })
)
}
fn visit_token_kind(&mut self, token_kind: TokenKind) {
let type_params_state_mut = match self {
Self::InClass(type_params_state) | Self::InFunction(type_params_state) => {
type_params_state
}
Self::NotInClassOrFunction => return,
};
match token_kind {
TokenKind::Lpar if type_params_state_mut.before_type_params() => {
*type_params_state_mut = TypeParamsState::TypeParamsEnded;
}
TokenKind::Lsqb => match type_params_state_mut {
TypeParamsState::TypeParamsEnded => {}
TypeParamsState::BeforeTypeParams => {
*type_params_state_mut = TypeParamsState::InTypeParams {
inner_square_brackets: 0,
};
}
TypeParamsState::InTypeParams {
inner_square_brackets,
} => *inner_square_brackets += 1,
},
TokenKind::Rsqb => {
if let TypeParamsState::InTypeParams {
inner_square_brackets,
} = type_params_state_mut
{
if *inner_square_brackets == 0 {
*type_params_state_mut = TypeParamsState::TypeParamsEnded;
} else {
*inner_square_brackets -= 1;
}
}
}
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, Default)]
enum TypeParamsState {
#[default]
BeforeTypeParams,
InTypeParams {
inner_square_brackets: u32,
},
TypeParamsEnded,
}
impl TypeParamsState {
const fn before_type_params(self) -> bool {
matches!(self, Self::BeforeTypeParams)
}
}
#[cfg(test)]
mod tests {
use ruff_python_parser::parse_module;

View File

@@ -4,7 +4,7 @@ use ruff_python_parser::TokenKind;
use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::checkers::logical_lines::LogicalLinesContext;
use crate::rules::pycodestyle::rules::logical_lines::{LogicalLine, LogicalLineToken};
use crate::rules::pycodestyle::rules::logical_lines::{DefinitionState, LogicalLine};
/// ## What it does
/// Checks for missing whitespace around the equals sign in an unannotated
@@ -84,18 +84,6 @@ impl AlwaysFixableViolation for MissingWhitespaceAroundParameterEquals {
}
}
fn is_in_def(tokens: &[LogicalLineToken]) -> bool {
for token in tokens {
match token.kind() {
TokenKind::Async | TokenKind::Indent | TokenKind::Dedent => continue,
TokenKind::Def => return true,
_ => return false,
}
}
false
}
/// E251, E252
pub(crate) fn whitespace_around_named_parameter_equals(
line: &LogicalLine,
@@ -106,17 +94,14 @@ pub(crate) fn whitespace_around_named_parameter_equals(
let mut annotated_func_arg = false;
let mut prev_end = TextSize::default();
let in_def = is_in_def(line.tokens());
let mut definition_state = DefinitionState::from_tokens(line.tokens());
let mut iter = line.tokens().iter().peekable();
while let Some(token) = iter.next() {
let kind = token.kind();
if kind == TokenKind::NonLogicalNewline {
continue;
}
definition_state.visit_token_kind(kind);
match kind {
TokenKind::NonLogicalNewline => continue,
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
TokenKind::Lpar | TokenKind::Lsqb => {
@@ -128,15 +113,16 @@ pub(crate) fn whitespace_around_named_parameter_equals(
annotated_func_arg = false;
}
}
TokenKind::Colon if parens == 1 && in_def => {
TokenKind::Colon if parens == 1 && definition_state.in_function_definition() => {
annotated_func_arg = true;
}
TokenKind::Comma if parens == 1 => {
annotated_func_arg = false;
}
TokenKind::Equal if parens > 0 && fstrings == 0 => {
if annotated_func_arg && parens == 1 {
TokenKind::Equal
if definition_state.in_type_params() || (parens > 0 && fstrings == 0) =>
{
if definition_state.in_type_params() || (annotated_func_arg && parens == 1) {
let start = token.start();
if start == prev_end && prev_end != TextSize::new(0) {
let mut diagnostic =

View File

@@ -476,3 +476,432 @@ E23.py:102:24: E231 [*] Missing whitespace after ','
103 103 | },
104 104 | ]
105 105 | }
E23.py:109:18: E231 [*] Missing whitespace after ':'
|
108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
110 | x:A = "foo"[::-1],
111 | y:B = [[["foo", "bar"]]],
|
= help: Add missing whitespace
Safe fix
106 106 | ]
107 107 |
108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
109 |+def pep_696_bad[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 110 | x:A = "foo"[::-1],
111 111 | y:B = [[["foo", "bar"]]],
112 112 | z:object = "fooo",
E23.py:109:40: E231 [*] Missing whitespace after ':'
|
108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
110 | x:A = "foo"[::-1],
111 | y:B = [[["foo", "bar"]]],
|
= help: Add missing whitespace
Safe fix
106 106 | ]
107 107 |
108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
109 |+def pep_696_bad[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes](
110 110 | x:A = "foo"[::-1],
111 111 | y:B = [[["foo", "bar"]]],
112 112 | z:object = "fooo",
E23.py:109:70: E231 [*] Missing whitespace after ':'
|
108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
110 | x:A = "foo"[::-1],
111 | y:B = [[["foo", "bar"]]],
|
= help: Add missing whitespace
Safe fix
106 106 | ]
107 107 |
108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
109 |+def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes](
110 110 | x:A = "foo"[::-1],
111 111 | y:B = [[["foo", "bar"]]],
112 112 | z:object = "fooo",
E23.py:110:6: E231 [*] Missing whitespace after ':'
|
108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 | x:A = "foo"[::-1],
| ^ E231
111 | y:B = [[["foo", "bar"]]],
112 | z:object = "fooo",
|
= help: Add missing whitespace
Safe fix
107 107 |
108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 |- x:A = "foo"[::-1],
110 |+ x: A = "foo"[::-1],
111 111 | y:B = [[["foo", "bar"]]],
112 112 | z:object = "fooo",
113 113 | ):
E23.py:111:6: E231 [*] Missing whitespace after ':'
|
109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 | x:A = "foo"[::-1],
111 | y:B = [[["foo", "bar"]]],
| ^ E231
112 | z:object = "fooo",
113 | ):
|
= help: Add missing whitespace
Safe fix
108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults
109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 110 | x:A = "foo"[::-1],
111 |- y:B = [[["foo", "bar"]]],
111 |+ y: B = [[["foo", "bar"]]],
112 112 | z:object = "fooo",
113 113 | ):
114 114 | pass
E23.py:112:6: E231 [*] Missing whitespace after ':'
|
110 | x:A = "foo"[::-1],
111 | y:B = [[["foo", "bar"]]],
112 | z:object = "fooo",
| ^ E231
113 | ):
114 | pass
|
= help: Add missing whitespace
Safe fix
109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
110 110 | x:A = "foo"[::-1],
111 111 | y:B = [[["foo", "bar"]]],
112 |- z:object = "fooo",
112 |+ z: object = "fooo",
113 113 | ):
114 114 | pass
115 115 |
E23.py:116:18: E231 [*] Missing whitespace after ':'
|
114 | pass
115 |
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
| ^ E231
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 | self,
|
= help: Add missing whitespace
Safe fix
113 113 | ):
114 114 | pass
115 115 |
116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
116 |+class PEP696Bad[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
E23.py:116:40: E231 [*] Missing whitespace after ':'
|
114 | pass
115 |
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
| ^ E231
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 | self,
|
= help: Add missing whitespace
Safe fix
113 113 | ):
114 114 | pass
115 115 |
116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
116 |+class PEP696Bad[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes]:
117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
E23.py:116:70: E231 [*] Missing whitespace after ':'
|
114 | pass
115 |
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
| ^ E231
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 | self,
|
= help: Add missing whitespace
Safe fix
113 113 | ):
114 114 | pass
115 115 |
116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
116 |+class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes]:
117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
E23.py:117:29: E231 [*] Missing whitespace after ':'
|
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
118 | self,
119 | x:A = "foo"[::-1],
|
= help: Add missing whitespace
Safe fix
114 114 | pass
115 115 |
116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
117 |+ def pep_696_bad_method[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
120 120 | y:B = [[["foo", "bar"]]],
E23.py:117:51: E231 [*] Missing whitespace after ':'
|
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
118 | self,
119 | x:A = "foo"[::-1],
|
= help: Add missing whitespace
Safe fix
114 114 | pass
115 115 |
116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
117 |+ def pep_696_bad_method[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
120 120 | y:B = [[["foo", "bar"]]],
E23.py:117:81: E231 [*] Missing whitespace after ':'
|
116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
| ^ E231
118 | self,
119 | x:A = "foo"[::-1],
|
= help: Add missing whitespace
Safe fix
114 114 | pass
115 115 |
116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
117 |+ def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
120 120 | y:B = [[["foo", "bar"]]],
E23.py:119:10: E231 [*] Missing whitespace after ':'
|
117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 | self,
119 | x:A = "foo"[::-1],
| ^ E231
120 | y:B = [[["foo", "bar"]]],
121 | z:object = "fooo",
|
= help: Add missing whitespace
Safe fix
116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]:
117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 |- x:A = "foo"[::-1],
119 |+ x: A = "foo"[::-1],
120 120 | y:B = [[["foo", "bar"]]],
121 121 | z:object = "fooo",
122 122 | ):
E23.py:120:10: E231 [*] Missing whitespace after ':'
|
118 | self,
119 | x:A = "foo"[::-1],
120 | y:B = [[["foo", "bar"]]],
| ^ E231
121 | z:object = "fooo",
122 | ):
|
= help: Add missing whitespace
Safe fix
117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](
118 118 | self,
119 119 | x:A = "foo"[::-1],
120 |- y:B = [[["foo", "bar"]]],
120 |+ y: B = [[["foo", "bar"]]],
121 121 | z:object = "fooo",
122 122 | ):
123 123 | pass
E23.py:121:10: E231 [*] Missing whitespace after ':'
|
119 | x:A = "foo"[::-1],
120 | y:B = [[["foo", "bar"]]],
121 | z:object = "fooo",
| ^ E231
122 | ):
123 | pass
|
= help: Add missing whitespace
Safe fix
118 118 | self,
119 119 | x:A = "foo"[::-1],
120 120 | y:B = [[["foo", "bar"]]],
121 |- z:object = "fooo",
121 |+ z: object = "fooo",
122 122 | ):
123 123 | pass
124 124 |
E23.py:125:32: E231 [*] Missing whitespace after ':'
|
123 | pass
124 |
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
| ^ E231
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 | pass
|
= help: Add missing whitespace
Safe fix
122 122 | ):
123 123 | pass
124 124 |
125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
125 |+class PEP696BadWithEmptyBases[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
E23.py:125:54: E231 [*] Missing whitespace after ':'
|
123 | pass
124 |
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
| ^ E231
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 | pass
|
= help: Add missing whitespace
Safe fix
122 122 | ):
123 123 | pass
124 124 |
125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
125 |+class PEP696BadWithEmptyBases[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes]():
126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
E23.py:125:84: E231 [*] Missing whitespace after ':'
|
123 | pass
124 |
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
| ^ E231
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 | pass
|
= help: Add missing whitespace
Safe fix
122 122 | ):
123 123 | pass
124 124 |
125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
125 |+class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes]():
126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
E23.py:126:47: E231 [*] Missing whitespace after ':'
|
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
| ^ E231
127 | pass
|
= help: Add missing whitespace
Safe fix
123 123 | pass
124 124 |
125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
126 |+ class IndentedPEP696BadWithNonEmptyBases[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
129 129 | # Should be no E231 errors on any of these:
E23.py:126:69: E231 [*] Missing whitespace after ':'
|
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
| ^ E231
127 | pass
|
= help: Add missing whitespace
Safe fix
123 123 | pass
124 124 |
125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
126 |+ class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
129 129 | # Should be no E231 errors on any of these:
E23.py:126:99: E231 [*] Missing whitespace after ':'
|
125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
| ^ E231
127 | pass
|
= help: Add missing whitespace
Safe fix
123 123 | pass
124 124 |
125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]():
126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]):
126 |+ class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes](object, something_dynamic[x::-1]):
127 127 | pass
128 128 |
129 129 | # Should be no E231 errors on any of these:

View File

@@ -85,4 +85,392 @@ E25.py:46:36: E252 [*] Missing whitespace around parameter equals
48 48 | #: Okay
49 49 | def add(a: int = _default(name='f')):
E25.py:64:18: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A =int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:18: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A= int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:26: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B = str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:33: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C = bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:49: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object =int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:49: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object= int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:64: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object =str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:64: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object= str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:80: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object = bool, G: object= bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:64:96: E252 [*] Missing whitespace around parameter equals
|
63 | # There should be at least one E251 diagnostic for each type parameter here:
64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
| ^ E252
65 | pass
|
= help: Add missing whitespace
Safe fix
61 61 | print(f"{foo(a = 1)}")
62 62 |
63 63 | # There should be at least one E251 diagnostic for each type parameter here:
64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object = bytes]():
65 65 | pass
66 66 |
67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
E25.py:67:18: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A =int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:18: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A= int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:26: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B = str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:33: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C = bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:49: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object =int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:49: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object= int, E: object=str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:64: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object =str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:64: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object= str, F: object =bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:80: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object = bool, G: object= bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,
E25.py:67:96: E252 [*] Missing whitespace around parameter equals
|
65 | pass
66 |
67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
| ^ E252
68 | pass
|
= help: Add missing whitespace
Safe fix
64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]():
65 65 | pass
66 66 |
67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]:
67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object = bytes]:
68 68 | pass
69 69 |
70 70 | # The last of these should cause us to emit E231,

View File

@@ -33,7 +33,22 @@ use crate::checkers::ast::Checker;
///
/// for city, population in data.items():
/// print(f"{city} has population {population}.")
///
/// ## Known problems
/// If the dictionary key is a tuple, e.g.:
///
/// ```python
/// d = {(1, 2): 3, (3, 4): 5}
/// for x, y in d:
/// print(x, y)
/// ```
///
/// The tuple key is unpacked into `x` and `y` instead of the key and values. This means that
/// the suggested fix of using `d.items()` would result in different runtime behavior. Ruff
/// cannot consistently infer the type of a dictionary's keys.
///
/// ## Fix safety
/// Due to the known problem with tuple keys, this fix is unsafe.
#[violation]
pub struct DictIterMissingItems;
@@ -48,6 +63,7 @@ impl AlwaysFixableViolation for DictIterMissingItems {
}
}
/// PLE1141
pub(crate) fn dict_iter_missing_items(checker: &mut Checker, target: &Expr, iter: &Expr) {
let Expr::Tuple(tuple) = target else {
return;
@@ -78,7 +94,7 @@ pub(crate) fn dict_iter_missing_items(checker: &mut Checker, target: &Expr, iter
}
let mut diagnostic = Diagnostic::new(DictIterMissingItems, iter.range());
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
format!("{}.items()", name.id),
iter.range(),
)));

View File

@@ -10,7 +10,7 @@ dict_iter_missing_items.py:13:13: PLE1141 [*] Unpacking a dictionary in iteratio
|
= help: Add a call to `.items()`
Safe fix
Unsafe fix
10 10 | s2 = {1, 2, 3}
11 11 |
12 12 | # Errors
@@ -30,7 +30,7 @@ dict_iter_missing_items.py:16:13: PLE1141 [*] Unpacking a dictionary in iteratio
|
= help: Add a call to `.items()`
Safe fix
Unsafe fix
13 13 | for k, v in d:
14 14 | pass
15 15 |
@@ -38,6 +38,4 @@ dict_iter_missing_items.py:16:13: PLE1141 [*] Unpacking a dictionary in iteratio
16 |+for k, v in d_tuple_incorrect_tuple.items():
17 17 | pass
18 18 |
19 19 |
19 19 |

View File

@@ -29,6 +29,7 @@ mod tests {
#[test_case(Rule::UnnecessaryEnumerate, Path::new("FURB148.py"))]
#[test_case(Rule::MathConstant, Path::new("FURB152.py"))]
#[test_case(Rule::RepeatedGlobal, Path::new("FURB154.py"))]
#[test_case(Rule::HardcodedStringCharset, Path::new("FURB156.py"))]
#[test_case(Rule::VerboseDecimalConstructor, Path::new("FURB157.py"))]
#[test_case(Rule::UnnecessaryFromFloat, Path::new("FURB164.py"))]
#[test_case(Rule::PrintEmptyString, Path::new("FURB105.py"))]

View File

@@ -0,0 +1,171 @@
use crate::checkers::ast::Checker;
use crate::importer::ImportRequest;
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{CmpOp, Expr, ExprCompare, ExprStringLiteral};
use ruff_text_size::TextRange;
/// ## What it does
/// Checks for uses of hardcoded charsets, which are defined in Python string module.
///
/// ## Why is this bad?
/// Usage of named charsets from the standard library is more readable and less error-prone.
///
/// ## Example
/// ```python
/// x = "0123456789"
/// y in "abcdefghijklmnopqrstuvwxyz"
/// ```
///
/// Use instead
/// ```python
/// import string
///
/// x = string.digits
/// y in string.ascii_lowercase
/// ```
///
/// ## References
/// - [Python documentation: String constants](https://docs.python.org/3/library/string.html#string-constants)
#[violation]
pub struct HardcodedStringCharset {
name: &'static str,
}
impl AlwaysFixableViolation for HardcodedStringCharset {
#[derive_message_formats]
fn message(&self) -> String {
format!("Use of hardcoded string charset")
}
fn fix_title(&self) -> String {
let HardcodedStringCharset { name } = self;
format!("Replace hardcoded charset with `string.{name}`")
}
}
struct NamedCharset {
name: &'static str,
bytes: &'static [u8],
ascii_char_set: AsciiCharSet,
}
/// Represents the set of ascii characters in form of a bitset.
#[derive(Copy, Clone, Eq, PartialEq)]
struct AsciiCharSet(u128);
impl AsciiCharSet {
/// Creates the set of ascii characters from `bytes`.
/// Returns None if there is non-ascii byte.
const fn from_bytes(bytes: &[u8]) -> Option<Self> {
// TODO: simplify implementation, when const-traits are supported
// https://github.com/rust-lang/rust-project-goals/issues/106
let mut bitset = 0;
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii() {
return None;
}
bitset |= 1 << bytes[i];
i += 1;
}
Some(Self(bitset))
}
}
impl NamedCharset {
const fn new(name: &'static str, bytes: &'static [u8]) -> Self {
Self {
name,
bytes,
// SAFETY: The named charset is guaranteed to have only ascii bytes.
// TODO: replace with `.unwrap()`, when `Option::unwrap` will be stable in `const fn`
// https://github.com/rust-lang/rust/issues/67441
ascii_char_set: match AsciiCharSet::from_bytes(bytes) {
Some(ascii_char_set) => ascii_char_set,
None => unreachable!(),
},
}
}
}
const KNOWN_NAMED_CHARSETS: [NamedCharset; 9] = [
NamedCharset::new(
"ascii_letters",
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
),
NamedCharset::new("ascii_lowercase", b"abcdefghijklmnopqrstuvwxyz"),
NamedCharset::new("ascii_uppercase", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
NamedCharset::new("digits", b"0123456789"),
NamedCharset::new("hexdigits", b"0123456789abcdefABCDEF"),
NamedCharset::new("octdigits", b"01234567"),
NamedCharset::new("punctuation", b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"),
NamedCharset::new(
"printable",
b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"\
#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c",
),
NamedCharset::new("whitespace", b" \t\n\r\x0b\x0c"),
];
fn check_charset_as_set(bytes: &[u8]) -> Option<&NamedCharset> {
let ascii_char_set = AsciiCharSet::from_bytes(bytes)?;
KNOWN_NAMED_CHARSETS
.iter()
.find(|&charset| charset.ascii_char_set == ascii_char_set)
}
fn check_charset_exact(bytes: &[u8]) -> Option<&NamedCharset> {
KNOWN_NAMED_CHARSETS
.iter()
.find(|&charset| charset.bytes == bytes)
}
fn push_diagnostic(checker: &mut Checker, range: TextRange, charset: &NamedCharset) {
let name = charset.name;
let mut diagnostic = Diagnostic::new(HardcodedStringCharset { name }, range);
diagnostic.try_set_fix(|| {
let (edit, binding) = checker.importer().get_or_import_symbol(
&ImportRequest::import("string", name),
range.start(),
checker.semantic(),
)?;
Ok(Fix::safe_edits(
Edit::range_replacement(binding, range),
[edit],
))
});
checker.diagnostics.push(diagnostic);
}
/// FURB156
pub(crate) fn hardcoded_string_charset_comparison(checker: &mut Checker, compare: &ExprCompare) {
let (
[CmpOp::In | CmpOp::NotIn],
[Expr::StringLiteral(string_literal @ ExprStringLiteral { value, .. })],
) = (compare.ops.as_ref(), compare.comparators.as_ref())
else {
return;
};
let bytes = value.to_str().as_bytes();
let Some(charset) = check_charset_as_set(bytes) else {
return;
};
// In this case the diagnostic will be emitted via string_literal check.
if charset.bytes == bytes {
return;
}
push_diagnostic(checker, string_literal.range, charset);
}
/// FURB156
pub(crate) fn hardcoded_string_charset_literal(checker: &mut Checker, expr: &ExprStringLiteral) {
if let Some(charset) = check_charset_exact(expr.value.to_str().as_bytes()) {
push_diagnostic(checker, expr.range, charset);
}
}

View File

@@ -3,6 +3,7 @@ pub(crate) use check_and_remove_from_set::*;
pub(crate) use delete_full_slice::*;
pub(crate) use for_loop_set_mutations::*;
pub(crate) use fstring_number_format::*;
pub(crate) use hardcoded_string_charset::*;
pub(crate) use hashlib_digest_hex::*;
pub(crate) use if_exp_instead_of_or_operator::*;
pub(crate) use if_expr_min_max::*;
@@ -36,6 +37,7 @@ mod check_and_remove_from_set;
mod delete_full_slice;
mod for_loop_set_mutations;
mod fstring_number_format;
mod hardcoded_string_charset;
mod hashlib_digest_hex;
mod if_exp_instead_of_or_operator;
mod if_expr_min_max;

View File

@@ -4,7 +4,7 @@ use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast as ast;
use ruff_python_semantic::SemanticModel;
use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextLen};
use ruff_text_size::Ranged;
/// ## What it does
/// Checks for the removal of a prefix or suffix from a string by assigning
@@ -334,8 +334,9 @@ fn affix_matches_slice_bound(data: &RemoveAffixData, semantic: &SemanticModel) -
}),
) => num
.as_int()
.and_then(ast::Int::as_u32) // Only support prefix removal for size at most `u32::MAX`
.is_some_and(|x| x == string_val.to_str().text_len().to_u32()),
// Only support prefix removal for size at most `usize::MAX`
.and_then(ast::Int::as_usize)
.is_some_and(|x| x == string_val.chars().count()),
(
AffixKind::StartsWith,
ast::Expr::Call(ast::ExprCall {
@@ -369,8 +370,8 @@ fn affix_matches_slice_bound(data: &RemoveAffixData, semantic: &SemanticModel) -
// Only support prefix removal for size at most `u32::MAX`
value
.as_int()
.and_then(ast::Int::as_u32)
.is_some_and(|x| x == string_val.to_str().text_len().to_u32())
.and_then(ast::Int::as_usize)
.is_some_and(|x| x == string_val.chars().count())
},
),
(

View File

@@ -0,0 +1,486 @@
---
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB156.py:3:5: FURB156 [*] Use of hardcoded string charset
|
1 | # Errors
2 |
3 | _ = "0123456789"
| ^^^^^^^^^^^^ FURB156
4 | _ = "01234567"
5 | _ = "0123456789abcdefABCDEF"
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 |-_ = "0123456789"
4 |+_ = string.digits
4 5 | _ = "01234567"
5 6 | _ = "0123456789abcdefABCDEF"
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
FURB156.py:4:5: FURB156 [*] Use of hardcoded string charset
|
3 | _ = "0123456789"
4 | _ = "01234567"
| ^^^^^^^^^^ FURB156
5 | _ = "0123456789abcdefABCDEF"
6 | _ = "abcdefghijklmnopqrstuvwxyz"
|
= help: Replace hardcoded charset with `string.octdigits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 |-_ = "01234567"
5 |+_ = string.octdigits
5 6 | _ = "0123456789abcdefABCDEF"
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FURB156.py:5:5: FURB156 [*] Use of hardcoded string charset
|
3 | _ = "0123456789"
4 | _ = "01234567"
5 | _ = "0123456789abcdefABCDEF"
| ^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
6 | _ = "abcdefghijklmnopqrstuvwxyz"
7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
= help: Replace hardcoded charset with `string.hexdigits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
5 |-_ = "0123456789abcdefABCDEF"
6 |+_ = string.hexdigits
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
FURB156.py:6:5: FURB156 [*] Use of hardcoded string charset
|
4 | _ = "01234567"
5 | _ = "0123456789abcdefABCDEF"
6 | _ = "abcdefghijklmnopqrstuvwxyz"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
= help: Replace hardcoded charset with `string.ascii_lowercase`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
5 6 | _ = "0123456789abcdefABCDEF"
6 |-_ = "abcdefghijklmnopqrstuvwxyz"
7 |+_ = string.ascii_lowercase
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
FURB156.py:7:5: FURB156 [*] Use of hardcoded string charset
|
5 | _ = "0123456789abcdefABCDEF"
6 | _ = "abcdefghijklmnopqrstuvwxyz"
7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
|
= help: Replace hardcoded charset with `string.ascii_uppercase`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
5 6 | _ = "0123456789abcdefABCDEF"
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
7 |-_ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 |+_ = string.ascii_uppercase
8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 11 | _ = " \t\n\r\v\f"
FURB156.py:8:5: FURB156 [*] Use of hardcoded string charset
|
6 | _ = "abcdefghijklmnopqrstuvwxyz"
7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 | _ = " \t\n\r\v\f"
|
= help: Replace hardcoded charset with `string.ascii_letters`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
5 6 | _ = "0123456789abcdefABCDEF"
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 |-_ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 |+_ = string.ascii_letters
9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 11 | _ = " \t\n\r\v\f"
11 12 |
FURB156.py:9:5: FURB156 [*] Use of hardcoded string charset
|
7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
10 | _ = " \t\n\r\v\f"
|
= help: Replace hardcoded charset with `string.punctuation`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
6 7 | _ = "abcdefghijklmnopqrstuvwxyz"
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 |-_ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 |+_ = string.punctuation
10 11 | _ = " \t\n\r\v\f"
11 12 |
12 13 | _ = "" in "1234567890"
FURB156.py:10:5: FURB156 [*] Use of hardcoded string charset
|
8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 | _ = " \t\n\r\v\f"
| ^^^^^^^^^^^^^ FURB156
11 |
12 | _ = "" in "1234567890"
|
= help: Replace hardcoded charset with `string.whitespace`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 |-_ = " \t\n\r\v\f"
11 |+_ = string.whitespace
11 12 |
12 13 | _ = "" in "1234567890"
13 14 | _ = "" in "12345670"
FURB156.py:12:11: FURB156 [*] Use of hardcoded string charset
|
10 | _ = " \t\n\r\v\f"
11 |
12 | _ = "" in "1234567890"
| ^^^^^^^^^^^^ FURB156
13 | _ = "" in "12345670"
14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
10 11 | _ = " \t\n\r\v\f"
11 12 |
12 |-_ = "" in "1234567890"
13 |+_ = "" in string.digits
13 14 | _ = "" in "12345670"
14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 16 | _ = (
FURB156.py:13:11: FURB156 [*] Use of hardcoded string charset
|
12 | _ = "" in "1234567890"
13 | _ = "" in "12345670"
| ^^^^^^^^^^ FURB156
14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 | _ = (
|
= help: Replace hardcoded charset with `string.octdigits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
10 11 | _ = " \t\n\r\v\f"
11 12 |
12 13 | _ = "" in "1234567890"
13 |-_ = "" in "12345670"
14 |+_ = "" in string.octdigits
14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 16 | _ = (
16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
FURB156.py:14:5: FURB156 [*] Use of hardcoded string charset
|
12 | _ = "" in "1234567890"
13 | _ = "" in "12345670"
14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156
15 | _ = (
16 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
|
= help: Replace hardcoded charset with `string.printable`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
11 12 |
12 13 | _ = "" in "1234567890"
13 14 | _ = "" in "12345670"
14 |-_ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 |+_ = string.printable
15 16 | _ = (
16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
17 18 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
FURB156.py:16:5: FURB156 [*] Use of hardcoded string charset
|
14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 | _ = (
16 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
| _____^
17 | | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
| |________________________________________________^ FURB156
18 | )
19 | _ = id("0123"
|
= help: Replace hardcoded charset with `string.printable`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
13 14 | _ = "" in "12345670"
14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
15 16 | _ = (
16 |- '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
17 |- "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
17 |+ string.printable
18 18 | )
19 19 | _ = id("0123"
20 20 | "4567"
FURB156.py:19:8: FURB156 [*] Use of hardcoded string charset
|
17 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
18 | )
19 | _ = id("0123"
| ________^
20 | | "4567"
21 | | "89")
| |___________^ FURB156
22 | _ = "" in ("123"
23 | "456"
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'
17 18 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
18 19 | )
19 |-_ = id("0123"
20 |- "4567"
21 |- "89")
20 |+_ = id(string.digits)
22 21 | _ = "" in ("123"
23 22 | "456"
24 23 | "789"
FURB156.py:22:12: FURB156 [*] Use of hardcoded string charset
|
20 | "4567"
21 | "89")
22 | _ = "" in ("123"
| ____________^
23 | | "456"
24 | | "789"
25 | | "0")
| |______________^ FURB156
26 |
27 | _ = "" in ( # comment
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
19 20 | _ = id("0123"
20 21 | "4567"
21 22 | "89")
22 |-_ = "" in ("123"
23 |- "456"
24 |- "789"
25 |- "0")
23 |+_ = "" in (string.digits)
26 24 |
27 25 | _ = "" in ( # comment
28 26 | "123"
FURB156.py:28:5: FURB156 [*] Use of hardcoded string charset
|
27 | _ = "" in ( # comment
28 | "123"
| _____^
29 | | "456"
30 | | "789"
31 | | "0")
| |_______^ FURB156
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
25 26 | "0")
26 27 |
27 28 | _ = "" in ( # comment
28 |- "123"
29 |- "456"
30 |- "789"
31 |- "0")
29 |+ string.digits)
32 30 |
33 31 |
34 32 | _ = "" in (
FURB156.py:35:5: FURB156 [*] Use of hardcoded string charset
|
34 | _ = "" in (
35 | "123"
| _____^
36 | | "456" # inline comment
37 | | "789"
38 | | "0")
| |_______^ FURB156
39 |
40 | _ = (
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
32 33 |
33 34 |
34 35 | _ = "" in (
35 |- "123"
36 |- "456" # inline comment
37 |- "789"
38 |- "0")
36 |+ string.digits)
39 37 |
40 38 | _ = (
41 39 | "0123456789"
FURB156.py:41:5: FURB156 [*] Use of hardcoded string charset
|
40 | _ = (
41 | "0123456789"
| ^^^^^^^^^^^^ FURB156
42 | ).capitalize()
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
38 39 | "0")
39 40 |
40 41 | _ = (
41 |- "0123456789"
42 |+ string.digits
42 43 | ).capitalize()
43 44 |
44 45 | _ = (
FURB156.py:45:5: FURB156 [*] Use of hardcoded string charset
|
44 | _ = (
45 | "0123456789"
| ^^^^^^^^^^^^ FURB156
46 | # with comment
47 | ).capitalize()
|
= help: Replace hardcoded charset with `string.digits`
Safe fix
1 1 | # Errors
2 |+import string
2 3 |
3 4 | _ = "0123456789"
4 5 | _ = "01234567"
--------------------------------------------------------------------------------
42 43 | ).capitalize()
43 44 |
44 45 | _ = (
45 |- "0123456789"
46 |+ string.digits
46 47 | # with comment
47 48 | ).capitalize()
48 49 |

View File

@@ -250,4 +250,74 @@ FURB188.py:162:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing
162 |+ text = text.removeprefix("!")
164 163 | print(text)
165 164 |
166 165 |
166 165 |
FURB188.py:183:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice.
|
181 | # with fix `text = text.removeprefix("ř")`
182 | text = "řetězec"
183 | if text.startswith("ř"):
| _____^
184 | | text = text[1:]
| |_______________________^ FURB188
|
= help: Use removeprefix instead of assignment conditional upon startswith.
Safe fix
180 180 | # should be linted
181 181 | # with fix `text = text.removeprefix("ř")`
182 182 | text = "řetězec"
183 |- if text.startswith("ř"):
184 |- text = text[1:]
183 |+ text = text.removeprefix("ř")
185 184 |
186 185 |
187 186 | def handle_surrogates():
FURB188.py:190:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice.
|
188 | # should be linted
189 | text = "\ud800\udc00heythere"
190 | if text.startswith("\ud800\udc00"):
| _____^
191 | | text = text[2:]
| |_______________________^ FURB188
192 | text = "\U00010000heythere"
193 | if text.startswith("\U00010000"):
|
= help: Use removeprefix instead of assignment conditional upon startswith.
Safe fix
187 187 | def handle_surrogates():
188 188 | # should be linted
189 189 | text = "\ud800\udc00heythere"
190 |- if text.startswith("\ud800\udc00"):
191 |- text = text[2:]
190 |+ text = text.removeprefix("\ud800\udc00")
192 191 | text = "\U00010000heythere"
193 192 | if text.startswith("\U00010000"):
194 193 | text = text[1:]
FURB188.py:193:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice.
|
191 | text = text[2:]
192 | text = "\U00010000heythere"
193 | if text.startswith("\U00010000"):
| _____^
194 | | text = text[1:]
| |_______________________^ FURB188
195 |
196 | # should not be linted
|
= help: Use removeprefix instead of assignment conditional upon startswith.
Safe fix
190 190 | if text.startswith("\ud800\udc00"):
191 191 | text = text[2:]
192 192 | text = "\U00010000heythere"
193 |- if text.startswith("\U00010000"):
194 |- text = text[1:]
193 |+ text = text.removeprefix("\U00010000")
195 194 |
196 195 | # should not be linted
197 196 | text = "\ud800\udc00heythere"

View File

@@ -35,7 +35,7 @@ use crate::rules::fastapi::rules::is_fastapi_route_call;
/// 5. The string references variables that are not in scope, or it doesn't capture variables at all.
/// 6. Any format specifiers in the potential f-string are invalid.
/// 7. The string is part of a function call that is known to expect a template string rather than an
/// evaluated f-string: for example, a [`logging`] call, a [`gettext`] call, or a [`fastAPI` path].
/// evaluated f-string: for example, a [`logging`] call, a [`gettext`] call, or a [fastAPI path].
///
/// ## Example
///
@@ -52,9 +52,9 @@ use crate::rules::fastapi::rules::is_fastapi_route_call;
/// print(f"Hello {name}! It is {day_of_week} today!")
/// ```
///
/// [`logging`]: https://docs.python.org/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application
/// [`gettext`]: https://docs.python.org/3/library/gettext.html
/// [`fastAPI` path]: https://fastapi.tiangolo.com/tutorial/path-params/
/// [logging]: https://docs.python.org/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application
/// [gettext]: https://docs.python.org/3/library/gettext.html
/// [fastAPI path]: https://fastapi.tiangolo.com/tutorial/path-params/
#[violation]
pub struct MissingFStringSyntax;

View File

@@ -238,9 +238,19 @@ impl Cell {
//
// This is to avoid false positives when these variables are referenced
// elsewhere in the notebook.
//
// Refer https://github.com/astral-sh/ruff/issues/13718 for `ipytest`.
!matches!(
command,
"capture" | "debug" | "prun" | "pypy" | "python" | "python3" | "time" | "timeit"
"capture"
| "debug"
| "ipytest"
| "prun"
| "pypy"
| "python"
| "python3"
| "time"
| "timeit"
)
})
}

View File

@@ -1004,6 +1004,14 @@ impl Visitor<'_> for AwaitVisitor {
crate::visitor::walk_expr(self, expr);
}
}
fn visit_comprehension(&mut self, comprehension: &'_ crate::Comprehension) {
if comprehension.is_async {
self.seen_await = true;
} else {
crate::visitor::walk_comprehension(self, comprehension);
}
}
}
/// Return `true` if a `Stmt` is a docstring.

View File

@@ -96,6 +96,14 @@ impl Int {
}
}
/// Return the [`Int`] as an u64, if it can be represented as that data type.
pub fn as_usize(&self) -> Option<usize> {
match &self.0 {
Number::Small(small) => usize::try_from(*small).ok(),
Number::Big(_) => None,
}
}
/// Return the [`Int`] as an i8, if it can be represented as that data type.
pub fn as_i8(&self) -> Option<i8> {
match &self.0 {

View File

@@ -68,7 +68,7 @@ pub enum TomlSourceType {
Unrecognized,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, is_macro::Is)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PySourceType {
/// The source is a Python file (`.py`).
@@ -99,13 +99,33 @@ impl PySourceType {
Some(ty)
}
pub fn try_from_path(path: impl AsRef<Path>) -> Option<Self> {
path.as_ref()
.extension()
.and_then(OsStr::to_str)
.and_then(Self::try_from_extension)
}
pub const fn is_py_file(self) -> bool {
matches!(self, Self::Python)
}
pub const fn is_stub(self) -> bool {
matches!(self, Self::Stub)
}
pub const fn is_py_file_or_stub(self) -> bool {
matches!(self, Self::Python | Self::Stub)
}
pub const fn is_ipynb(self) -> bool {
matches!(self, Self::Ipynb)
}
}
impl<P: AsRef<Path>> From<P> for PySourceType {
fn from(path: P) -> Self {
path.as_ref()
.extension()
.and_then(OsStr::to_str)
.map_or(Self::Python, Self::from_extension)
Self::try_from_path(path).unwrap_or_default()
}
}

View File

@@ -33,14 +33,15 @@ node_lines = (
nodes = []
for node_line in node_lines:
node = node_line.split("(")[1].split(")")[0].split("::")[-1].split("<")[0]
# `FString` and `StringLiteral` has a custom implementation while the formatting for
# `FStringLiteralElement` and `FStringExpressionElement` are handled by the `FString`
# `FString` has a custom implementation while the formatting for
# `FStringLiteralElement`, `FStringFormatSpec` and `FStringExpressionElement` are handled by the `FString`
# implementation.
if node in (
"FString",
"StringLiteral",
"FStringLiteralElement",
"FStringExpressionElement",
"FStringFormatSpec",
"Identifier",
):
continue
nodes.append(node)

View File

@@ -307,3 +307,11 @@ hello {
]
} --------
"""
# Implicit concatenated f-string containing quotes
_ = (
'This string should change its quotes to double quotes'
f'This string uses double quotes in an expression {"woah"}'
f'This f-string does not use any quotes.'
)

View File

@@ -4,37 +4,17 @@ use ruff_python_ast::{AnyNodeRef, ExprStringLiteral};
use crate::expression::parentheses::{
in_parentheses_only_group, NeedsParentheses, OptionalParentheses,
};
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*;
use crate::string::{AnyString, FormatImplicitConcatenatedString};
#[derive(Default)]
pub struct FormatExprStringLiteral {
kind: ExprStringLiteralKind,
}
#[derive(Default, Copy, Clone, Debug)]
pub enum ExprStringLiteralKind {
#[default]
String,
Docstring,
}
impl ExprStringLiteralKind {
const fn string_literal_kind(self) -> StringLiteralKind {
match self {
ExprStringLiteralKind::String => StringLiteralKind::String,
ExprStringLiteralKind::Docstring => StringLiteralKind::Docstring,
}
}
const fn is_docstring(self) -> bool {
matches!(self, ExprStringLiteralKind::Docstring)
}
kind: StringLiteralKind,
}
impl FormatRuleWithOptions<ExprStringLiteral, PyFormatContext<'_>> for FormatExprStringLiteral {
type Options = ExprStringLiteralKind;
type Options = StringLiteralKind;
fn with_options(mut self, options: Self::Options) -> Self {
self.kind = options;
@@ -47,9 +27,7 @@ impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral {
let ExprStringLiteral { value, .. } = item;
match value.as_slice() {
[string_literal] => {
FormatStringLiteral::new(string_literal, self.kind.string_literal_kind()).fmt(f)
}
[string_literal] => string_literal.format().with_options(self.kind).fmt(f),
_ => {
// This is just a sanity check because [`DocstringStmt::try_from_statement`]
// ensures that the docstring is a *single* string literal.

View File

@@ -2935,6 +2935,42 @@ impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::TypeParamParamSpec {
}
}
impl FormatRule<ast::StringLiteral, PyFormatContext<'_>>
for crate::other::string_literal::FormatStringLiteral
{
#[inline]
fn fmt(&self, node: &ast::StringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
FormatNodeRule::<ast::StringLiteral>::fmt(self, node, f)
}
}
impl<'ast> AsFormat<PyFormatContext<'ast>> for ast::StringLiteral {
type Format<'a> = FormatRefWithRule<
'a,
ast::StringLiteral,
crate::other::string_literal::FormatStringLiteral,
PyFormatContext<'ast>,
>;
fn format(&self) -> Self::Format<'_> {
FormatRefWithRule::new(
self,
crate::other::string_literal::FormatStringLiteral::default(),
)
}
}
impl<'ast> IntoFormat<PyFormatContext<'ast>> for ast::StringLiteral {
type Format = FormatOwnedWithRule<
ast::StringLiteral,
crate::other::string_literal::FormatStringLiteral,
PyFormatContext<'ast>,
>;
fn into_format(self) -> Self::Format {
FormatOwnedWithRule::new(
self,
crate::other::string_literal::FormatStringLiteral::default(),
)
}
}
impl FormatRule<ast::BytesLiteral, PyFormatContext<'_>>
for crate::other::bytes_literal::FormatBytesLiteral
{

View File

@@ -9,7 +9,6 @@ pub struct FormatBytesLiteral;
impl FormatNodeRule<BytesLiteral> for FormatBytesLiteral {
fn fmt_fields(&self, item: &BytesLiteral, f: &mut PyFormatter) -> FormatResult<()> {
StringNormalizer::from_context(f.context())
.with_preferred_quote_style(f.options().quote_style())
.normalize(item.into())
.fmt(f)
}

View File

@@ -1,10 +1,12 @@
use crate::prelude::*;
use crate::preview::{
is_f_string_formatting_enabled, is_f_string_implicit_concatenated_string_literal_quotes_enabled,
};
use crate::string::{Quoting, StringNormalizer, StringQuotes};
use ruff_formatter::write;
use ruff_python_ast::{AnyStringFlags, FString, StringFlags};
use ruff_source_file::Locator;
use crate::prelude::*;
use crate::preview::is_f_string_formatting_enabled;
use crate::string::{Quoting, StringNormalizer, StringQuotes};
use ruff_text_size::Ranged;
use super::f_string_element::FormatFStringElement;
@@ -29,9 +31,16 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let locator = f.context().locator();
let normalizer = StringNormalizer::from_context(f.context())
.with_quoting(self.quoting)
.with_preferred_quote_style(f.options().quote_style());
// If the preview style is enabled, make the decision on what quotes to use locally for each
// f-string instead of globally for the entire f-string expression.
let quoting =
if is_f_string_implicit_concatenated_string_literal_quotes_enabled(f.context()) {
f_string_quoting(self.value, &locator)
} else {
self.quoting
};
let normalizer = StringNormalizer::from_context(f.context()).with_quoting(quoting);
// If f-string formatting is disabled (not in preview), then we will
// fall back to the previous behavior of normalizing the f-string.
@@ -140,3 +149,20 @@ impl FStringLayout {
matches!(self, FStringLayout::Multiline)
}
}
fn f_string_quoting(f_string: &FString, locator: &Locator) -> Quoting {
let triple_quoted = f_string.flags.is_triple_quoted();
if f_string.elements.expressions().any(|expression| {
let string_content = locator.slice(expression.range());
if triple_quoted {
string_content.contains(r#"""""#) || string_content.contains("'''")
} else {
string_content.contains(['"', '\''])
}
}) {
Quoting::Preserve
} else {
Quoting::CanChange
}
}

View File

@@ -1,7 +1,7 @@
use ruff_python_ast::FStringPart;
use crate::other::f_string::FormatFString;
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*;
use crate::string::Quoting;
@@ -25,14 +25,13 @@ impl<'a> FormatFStringPart<'a> {
impl Format<PyFormatContext<'_>> for FormatFStringPart<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self.part {
FStringPart::Literal(string_literal) => FormatStringLiteral::new(
string_literal,
// If an f-string part is a string literal, the f-string is always
// implicitly concatenated e.g., `"foo" f"bar {x}"`. A standalone
// string literal would be a string expression, not an f-string.
StringLiteralKind::InImplicitlyConcatenatedFString(self.quoting),
)
.fmt(f),
#[allow(deprecated)]
FStringPart::Literal(string_literal) => string_literal
.format()
.with_options(StringLiteralKind::InImplicitlyConcatenatedFString(
self.quoting,
))
.fmt(f),
FStringPart::FString(f_string) => FormatFString::new(f_string, self.quoting).fmt(f),
}
}

View File

@@ -1,23 +1,28 @@
use ruff_formatter::FormatRuleWithOptions;
use ruff_python_ast::StringLiteral;
use crate::prelude::*;
use crate::preview::is_f_string_implicit_concatenated_string_literal_quotes_enabled;
use crate::string::{docstring, Quoting, StringNormalizer};
use crate::QuoteStyle;
pub(crate) struct FormatStringLiteral<'a> {
value: &'a StringLiteral,
#[derive(Default)]
pub struct FormatStringLiteral {
layout: StringLiteralKind,
}
impl<'a> FormatStringLiteral<'a> {
pub(crate) fn new(value: &'a StringLiteral, layout: StringLiteralKind) -> Self {
Self { value, layout }
impl FormatRuleWithOptions<StringLiteral, PyFormatContext<'_>> for FormatStringLiteral {
type Options = StringLiteralKind;
fn with_options(mut self, layout: StringLiteralKind) -> Self {
self.layout = layout;
self
}
}
/// The kind of a string literal.
#[derive(Copy, Clone, Debug, Default)]
pub(crate) enum StringLiteralKind {
pub enum StringLiteralKind {
/// A normal string literal e.g., `"foo"`.
#[default]
String,
@@ -26,6 +31,8 @@ pub(crate) enum StringLiteralKind {
/// A string literal that is implicitly concatenated with an f-string. This
/// makes the overall expression an f-string whose quoting detection comes
/// from the parent node (f-string expression).
#[deprecated]
#[allow(private_interfaces)]
InImplicitlyConcatenatedFString(Quoting),
}
@@ -36,16 +43,28 @@ impl StringLiteralKind {
}
/// Returns the quoting to be used for this string literal.
fn quoting(self) -> Quoting {
fn quoting(self, context: &PyFormatContext) -> Quoting {
match self {
StringLiteralKind::String | StringLiteralKind::Docstring => Quoting::CanChange,
StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => quoting,
#[allow(deprecated)]
StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => {
// Allow string literals to pick the "optimal" quote character
// even if any other fstring in the implicit concatenation uses an expression
// containing a quote character.
// TODO: Remove StringLiteralKind::InImplicitlyConcatenatedFString when promoting
// this style to stable and remove the layout from `AnyStringPart::String`.
if is_f_string_implicit_concatenated_string_literal_quotes_enabled(context) {
Quoting::CanChange
} else {
quoting
}
}
}
}
}
impl Format<PyFormatContext<'_>> for FormatStringLiteral<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
impl FormatNodeRule<StringLiteral> for FormatStringLiteral {
fn fmt_fields(&self, item: &StringLiteral, f: &mut PyFormatter) -> FormatResult<()> {
let quote_style = f.options().quote_style();
let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() {
// Per PEP 8 and PEP 257, always prefer double quotes for docstrings,
@@ -56,9 +75,9 @@ impl Format<PyFormatContext<'_>> for FormatStringLiteral<'_> {
};
let normalized = StringNormalizer::from_context(f.context())
.with_quoting(self.layout.quoting())
.with_quoting(self.layout.quoting(f.context()))
.with_preferred_quote_style(quote_style)
.normalize(self.value.into());
.normalize(item.into());
if self.layout.is_docstring() {
docstring::format(&normalized, f)

View File

@@ -19,6 +19,13 @@ pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool
context.is_preview()
}
/// See [#13539](https://github.com/astral-sh/ruff/pull/13539)
pub(crate) fn is_f_string_implicit_concatenated_string_literal_quotes_enabled(
context: &PyFormatContext,
) -> bool {
context.is_preview()
}
pub(crate) fn is_with_single_item_pre_39_enabled(context: &PyFormatContext) -> bool {
context.is_preview()
}

View File

@@ -11,7 +11,7 @@ use crate::comments::{
leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments,
};
use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, WithNodeLevel};
use crate::expression::expr_string_literal::ExprStringLiteralKind;
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*;
use crate::statement::stmt_expr::FormatStmtExpr;
use crate::verbatim::{
@@ -850,7 +850,7 @@ impl Format<PyFormatContext<'_>> for DocstringStmt<'_> {
.then_some(source_position(self.docstring.start())),
string_literal
.format()
.with_options(ExprStringLiteralKind::Docstring),
.with_options(StringLiteralKind::Docstring),
f.options()
.source_map_generation()
.is_enabled()

View File

@@ -10,9 +10,6 @@ use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextRange};
use crate::expression::expr_f_string::f_string_quoting;
use crate::other::f_string::FormatFString;
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
use crate::prelude::*;
use crate::string::Quoting;
/// Represents any kind of string expression. This could be either a string,
@@ -46,6 +43,10 @@ impl<'a> AnyString<'a> {
}
}
pub(crate) const fn is_fstring(self) -> bool {
matches!(self, Self::FString(_))
}
/// Returns the quoting to be used for this string.
pub(super) fn quoting(self, locator: &Locator<'_>) -> Quoting {
match self {
@@ -54,23 +55,21 @@ impl<'a> AnyString<'a> {
}
}
/// Returns a vector of all the [`AnyStringPart`] of this string.
pub(super) fn parts(self, quoting: Quoting) -> AnyStringPartsIter<'a> {
/// Returns an iterator over the [`AnyStringPart`]s of this string.
pub(super) fn parts(self) -> AnyStringPartsIter<'a> {
match self {
Self::String(ExprStringLiteral { value, .. }) => {
AnyStringPartsIter::String(value.iter())
}
Self::Bytes(ExprBytesLiteral { value, .. }) => AnyStringPartsIter::Bytes(value.iter()),
Self::FString(ExprFString { value, .. }) => {
AnyStringPartsIter::FString(value.iter(), quoting)
}
Self::FString(ExprFString { value, .. }) => AnyStringPartsIter::FString(value.iter()),
}
}
pub(crate) fn is_multiline(self, source: &str) -> bool {
match self {
AnyString::String(_) | AnyString::Bytes(_) => {
self.parts(Quoting::default())
self.parts()
.next()
.is_some_and(|part| part.flags().is_triple_quoted())
&& memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some()
@@ -139,7 +138,7 @@ impl<'a> From<&'a ExprFString> for AnyString<'a> {
pub(super) enum AnyStringPartsIter<'a> {
String(std::slice::Iter<'a, StringLiteral>),
Bytes(std::slice::Iter<'a, ast::BytesLiteral>),
FString(std::slice::Iter<'a, ast::FStringPart>, Quoting),
FString(std::slice::Iter<'a, ast::FStringPart>),
}
impl<'a> Iterator for AnyStringPartsIter<'a> {
@@ -147,27 +146,12 @@ impl<'a> Iterator for AnyStringPartsIter<'a> {
fn next(&mut self) -> Option<Self::Item> {
let part = match self {
Self::String(inner) => {
let part = inner.next()?;
AnyStringPart::String {
part,
layout: StringLiteralKind::String,
}
}
Self::String(inner) => AnyStringPart::String(inner.next()?),
Self::Bytes(inner) => AnyStringPart::Bytes(inner.next()?),
Self::FString(inner, quoting) => {
let part = inner.next()?;
match part {
ast::FStringPart::Literal(string_literal) => AnyStringPart::String {
part: string_literal,
layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting),
},
ast::FStringPart::FString(f_string) => AnyStringPart::FString {
part: f_string,
quoting: *quoting,
},
}
}
Self::FString(inner) => match inner.next()? {
ast::FStringPart::Literal(string_literal) => AnyStringPart::String(string_literal),
ast::FStringPart::FString(f_string) => AnyStringPart::FString(f_string),
},
};
Some(part)
@@ -182,23 +166,17 @@ impl FusedIterator for AnyStringPartsIter<'_> {}
/// This is constructed from the [`AnyString::parts`] method on [`AnyString`].
#[derive(Clone, Debug)]
pub(super) enum AnyStringPart<'a> {
String {
part: &'a ast::StringLiteral,
layout: StringLiteralKind,
},
String(&'a ast::StringLiteral),
Bytes(&'a ast::BytesLiteral),
FString {
part: &'a ast::FString,
quoting: Quoting,
},
FString(&'a ast::FString),
}
impl AnyStringPart<'_> {
fn flags(&self) -> AnyStringFlags {
match self {
Self::String { part, .. } => part.flags.into(),
Self::String(part) => part.flags.into(),
Self::Bytes(bytes_literal) => bytes_literal.flags.into(),
Self::FString { part, .. } => part.flags.into(),
Self::FString(part) => part.flags.into(),
}
}
}
@@ -206,9 +184,9 @@ impl AnyStringPart<'_> {
impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> {
fn from(value: &AnyStringPart<'a>) -> Self {
match value {
AnyStringPart::String { part, .. } => AnyNodeRef::StringLiteral(part),
AnyStringPart::String(part) => AnyNodeRef::StringLiteral(part),
AnyStringPart::Bytes(part) => AnyNodeRef::BytesLiteral(part),
AnyStringPart::FString { part, .. } => AnyNodeRef::FString(part),
AnyStringPart::FString(part) => AnyNodeRef::FString(part),
}
}
}
@@ -216,21 +194,9 @@ impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> {
impl Ranged for AnyStringPart<'_> {
fn range(&self) -> TextRange {
match self {
Self::String { part, .. } => part.range(),
Self::String(part) => part.range(),
Self::Bytes(part) => part.range(),
Self::FString { part, .. } => part.range(),
}
}
}
impl Format<PyFormatContext<'_>> for AnyStringPart<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
match self {
AnyStringPart::String { part, layout } => {
FormatStringLiteral::new(part, *layout).fmt(f)
}
AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f),
Self::FString(part) => part.range(),
}
}
}

View File

@@ -11,7 +11,10 @@ use ruff_text_size::{Ranged, TextRange};
use crate::comments::{leading_comments, trailing_comments};
use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space;
use crate::other::f_string::FormatFString;
use crate::other::string_literal::StringLiteralKind;
use crate::prelude::*;
use crate::string::any::AnyStringPart;
use crate::QuoteStyle;
mod any;
@@ -46,12 +49,28 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> {
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
for part in self.string.parts(quoting) {
for part in self.string.parts() {
let part_comments = comments.leading_dangling_trailing(&part);
let format_part = format_with(|f: &mut PyFormatter| match part {
AnyStringPart::String(part) => {
let kind = if self.string.is_fstring() {
#[allow(deprecated)]
StringLiteralKind::InImplicitlyConcatenatedFString(quoting)
} else {
StringLiteralKind::String
};
part.format().with_options(kind).fmt(f)
}
AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
AnyStringPart::FString(part) => FormatFString::new(part, quoting).fmt(f),
});
joiner.entry(&format_args![
line_suffix_boundary(),
leading_comments(part_comments.leading),
part,
format_part,
trailing_comments(part_comments.trailing)
]);
}

View File

@@ -22,7 +22,7 @@ impl<'a, 'src> StringNormalizer<'a, 'src> {
pub(crate) fn from_context(context: &'a PyFormatContext<'src>) -> Self {
Self {
quoting: Quoting::default(),
preferred_quote_style: QuoteStyle::default(),
preferred_quote_style: context.options().quote_style(),
context,
}
}

View File

@@ -313,6 +313,14 @@ hello {
]
} --------
"""
# Implicit concatenated f-string containing quotes
_ = (
'This string should change its quotes to double quotes'
f'This string uses double quotes in an expression {"woah"}'
f'This f-string does not use any quotes.'
)
```
## Outputs
@@ -649,6 +657,14 @@ hello {
]
} --------
"""
# Implicit concatenated f-string containing quotes
_ = (
"This string should change its quotes to double quotes"
f'This string uses double quotes in an expression {"woah"}'
f"This f-string does not use any quotes."
)
```
@@ -973,6 +989,14 @@ hello {
]
} --------
"""
# Implicit concatenated f-string containing quotes
_ = (
'This string should change its quotes to double quotes'
f'This string uses double quotes in an expression {"woah"}'
f'This f-string does not use any quotes.'
)
```
@@ -1279,7 +1303,7 @@ hello {
# comment 27
# comment 28
} woah {x}"
@@ -287,19 +299,19 @@
@@ -287,27 +299,27 @@
if indent2:
foo = f"""hello world
hello {
@@ -1314,4 +1338,14 @@ hello {
+ ]
+ } --------
"""
# Implicit concatenated f-string containing quotes
_ = (
- 'This string should change its quotes to double quotes'
+ "This string should change its quotes to double quotes"
f'This string uses double quotes in an expression {"woah"}'
- f'This f-string does not use any quotes.'
+ f"This f-string does not use any quotes."
)
```

View File

@@ -11,7 +11,7 @@ use ruff_python_stdlib::typing::{
};
use ruff_text_size::Ranged;
use crate::analyze::type_inference::{PythonType, ResolvedPythonType};
use crate::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
use crate::model::SemanticModel;
use crate::{Binding, BindingKind, Modules};
@@ -576,7 +576,7 @@ trait BuiltinTypeChecker {
/// Builtin type name.
const BUILTIN_TYPE_NAME: &'static str;
/// Type name as found in the `Typing` module.
const TYPING_NAME: &'static str;
const TYPING_NAME: Option<&'static str>;
/// [`PythonType`] associated with the intended type.
const EXPR_TYPE: PythonType;
@@ -584,7 +584,7 @@ trait BuiltinTypeChecker {
fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
let value = map_subscript(annotation);
semantic.match_builtin_expr(value, Self::BUILTIN_TYPE_NAME)
|| semantic.match_typing_expr(value, Self::TYPING_NAME)
|| Self::TYPING_NAME.is_some_and(|name| semantic.match_typing_expr(value, name))
}
/// Check initializer expression to match the intended type.
@@ -624,7 +624,7 @@ struct ListChecker;
impl BuiltinTypeChecker for ListChecker {
const BUILTIN_TYPE_NAME: &'static str = "list";
const TYPING_NAME: &'static str = "List";
const TYPING_NAME: Option<&'static str> = Some("List");
const EXPR_TYPE: PythonType = PythonType::List;
}
@@ -632,7 +632,7 @@ struct DictChecker;
impl BuiltinTypeChecker for DictChecker {
const BUILTIN_TYPE_NAME: &'static str = "dict";
const TYPING_NAME: &'static str = "Dict";
const TYPING_NAME: Option<&'static str> = Some("Dict");
const EXPR_TYPE: PythonType = PythonType::Dict;
}
@@ -640,7 +640,7 @@ struct SetChecker;
impl BuiltinTypeChecker for SetChecker {
const BUILTIN_TYPE_NAME: &'static str = "set";
const TYPING_NAME: &'static str = "Set";
const TYPING_NAME: Option<&'static str> = Some("Set");
const EXPR_TYPE: PythonType = PythonType::Set;
}
@@ -648,10 +648,18 @@ struct TupleChecker;
impl BuiltinTypeChecker for TupleChecker {
const BUILTIN_TYPE_NAME: &'static str = "tuple";
const TYPING_NAME: &'static str = "Tuple";
const TYPING_NAME: Option<&'static str> = Some("Tuple");
const EXPR_TYPE: PythonType = PythonType::Tuple;
}
struct IntChecker;
impl BuiltinTypeChecker for IntChecker {
const BUILTIN_TYPE_NAME: &'static str = "int";
const TYPING_NAME: Option<&'static str> = None;
const EXPR_TYPE: PythonType = PythonType::Number(NumberLike::Integer);
}
pub struct IoBaseChecker;
impl TypeChecker for IoBaseChecker {
@@ -761,6 +769,11 @@ pub fn is_dict(binding: &Binding, semantic: &SemanticModel) -> bool {
check_type::<DictChecker>(binding, semantic)
}
/// Test whether the given binding can be considered an integer.
pub fn is_int(binding: &Binding, semantic: &SemanticModel) -> bool {
check_type::<IntChecker>(binding, semantic)
}
/// Test whether the given binding can be considered a set.
///
/// For this, we check what value might be associated with it through it's initialization and

View File

@@ -5,8 +5,7 @@ use rustc_hash::FxHashMap;
use ruff_python_ast::helpers::from_relative_import;
use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
use ruff_python_ast::{self as ast, Expr, ExprContext, Operator, Stmt};
use ruff_python_stdlib::path::is_python_stub_file;
use ruff_python_ast::{self as ast, Expr, ExprContext, Operator, PySourceType, Stmt};
use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::binding::{
@@ -2246,7 +2245,7 @@ bitflags! {
impl SemanticModelFlags {
pub fn new(path: &Path) -> Self {
if is_python_stub_file(path) {
if PySourceType::from(path).is_stub() {
Self::STUB_FILE
} else {
Self::default()

View File

@@ -1,3 +1,4 @@
use std::ffi::OsStr;
use std::path::Path;
/// Return `true` if the [`Path`] is named `pyproject.toml`.
@@ -6,38 +7,10 @@ pub fn is_pyproject_toml(path: &Path) -> bool {
.is_some_and(|name| name == "pyproject.toml")
}
/// Return `true` if the [`Path`] appears to be that of a Python interface definition file (`.pyi`).
pub fn is_python_stub_file(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "pyi")
}
/// Return `true` if the [`Path`] appears to be that of a Jupyter notebook (`.ipynb`).
pub fn is_jupyter_notebook(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "ipynb")
}
/// Return `true` if a [`Path`] should use the name of its parent directory as its module name.
pub fn is_module_file(path: &Path) -> bool {
path.file_name().is_some_and(|file_name| {
file_name == "__init__.py"
|| file_name == "__init__.pyi"
|| file_name == "__main__.py"
|| file_name == "__main__.pyi"
})
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::path::is_jupyter_notebook;
#[test]
fn test_is_jupyter_notebook() {
let path = Path::new("foo/bar/baz.ipynb");
assert!(is_jupyter_notebook(path));
let path = Path::new("foo/bar/baz.py");
assert!(!is_jupyter_notebook(path));
}
matches!(
path.file_name().and_then(OsStr::to_str),
Some("__init__.py" | "__init__.pyi" | "__main__.py" | "__main__.pyi")
)
}

View File

@@ -194,7 +194,7 @@ impl CommentRanges {
}
/// Returns `true` if a comment is an own-line comment (as opposed to an end-of-line comment).
fn is_own_line(offset: TextSize, locator: &Locator) -> bool {
pub fn is_own_line(offset: TextSize, locator: &Locator) -> bool {
let range = TextRange::new(locator.line_start(offset), offset);
locator.slice(range).chars().all(is_python_whitespace)
}

View File

@@ -1,3 +1,4 @@
use std::sync::Arc;
use {crate::TextRange, crate::TextSize, std::convert::TryInto};
use priv_in_pub::Sealed;
@@ -66,3 +67,12 @@ where
T::range(self)
}
}
impl<T> Ranged for Arc<T>
where
T: Ranged,
{
fn range(&self) -> TextRange {
T::range(self)
}
}

View File

@@ -796,7 +796,7 @@ pub struct LintCommonOptions {
)]
pub typing_modules: Option<Vec<String>>,
/// A list of modules which is allowed even thought they are not used
/// A list of modules which is allowed even though they are not used
/// in the code.
///
/// This is useful when a module has a side effect when imported.

View File

@@ -1,5 +1,5 @@
PyYAML==6.0.2
ruff==0.6.8
ruff==0.6.9
mkdocs==1.6.1
mkdocs-material @ git+ssh://git@github.com/astral-sh/mkdocs-material-insiders.git@38c0b8187325c3bab386b666daf3518ac036f2f4
mkdocs-redirects==1.2.1

View File

@@ -1,5 +1,5 @@
PyYAML==6.0.2
ruff==0.6.8
ruff==0.6.9
mkdocs==1.6.1
mkdocs-material==9.1.18
mkdocs-redirects==1.2.1

View File

@@ -16,7 +16,7 @@
"@cloudflare/workers-types": "^4.20230801.0",
"miniflare": "^3.20230801.1",
"typescript": "^5.1.6",
"wrangler": "3.78.12"
"wrangler": "3.80.0"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
@@ -132,9 +132,9 @@
}
},
"node_modules/@cloudflare/workers-types": {
"version": "4.20240925.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240925.0.tgz",
"integrity": "sha512-KpqyRWvanEuXgBTKYFzRp4NsWOEcswxjsPRSre1zYQcODmc8PUrraVHQUmgvkJgv3FzB+vI9xm7J6oE4MmZHCA==",
"version": "4.20241004.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241004.0.tgz",
"integrity": "sha512-3LrPvtecs4umknOF1bTPNLHUG/ZjeSE6PYBQ/tbO7lwaVhjZTaTugiaCny2byrZupBlVNuubQVktcAgMfw0C1A==",
"dev": true,
"license": "MIT OR Apache-2.0"
},
@@ -559,20 +559,22 @@
}
},
"node_modules/@miniflare/kv": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/@miniflare/kv/-/kv-2.14.2.tgz",
"integrity": "sha512-3rs4cJOGACT/U7NH7j4KD29ugXRYUiM0aGkvOEdFQtChXLsYClJljXpezTfJJxBwZjdS4F2UFTixtFcHp74UfA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@miniflare/kv/-/kv-2.14.4.tgz",
"integrity": "sha512-QlERH0Z+klwLg0xw+/gm2yC34Nnr/I0GcQ+ASYqXeIXBwjqOtMBa3YVQnocaD+BPy/6TUtSpOAShHsEj76R2uw==",
"license": "MIT",
"dependencies": {
"@miniflare/shared": "2.14.2"
"@miniflare/shared": "2.14.4"
},
"engines": {
"node": ">=16.13"
}
},
"node_modules/@miniflare/kv/node_modules/@miniflare/shared": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.2.tgz",
"integrity": "sha512-dDnYIztz10zDQjaFJ8Gy9UaaBWZkw3NyhFdpX6tAeyPA/2lGvkftc42MYmNi8s5ljqkZAtKgWAJnSf2K75NCJw==",
"node_modules/@miniflare/shared": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.4.tgz",
"integrity": "sha512-upl4RSB3hyCnITOFmRZjJj4A72GmkVrtfZTilkdq5Qe5TTlzsjVeDJp7AuNUM9bM8vswRo+N5jOiot6O4PVwwQ==",
"license": "MIT",
"dependencies": {
"@types/better-sqlite3": "^7.6.0",
"kleur": "^4.1.4",
@@ -584,25 +586,12 @@
}
},
"node_modules/@miniflare/storage-memory": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/@miniflare/storage-memory/-/storage-memory-2.14.2.tgz",
"integrity": "sha512-9Wtz9mayHIY0LDsfpMGx+/sfKCq3eAQJzYY+ju1tTEaKR0sVAuO51wu0wbyldsjj9OcBcd2X32iPbIa7KcSZQQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@miniflare/storage-memory/-/storage-memory-2.14.4.tgz",
"integrity": "sha512-9jB5BqNkMZ3SFjbPFeiVkLi1BuSahMhc/W1Y9H0W89qFDrrD+z7EgRgDtHTG1ZRyi9gIlNtt9qhkO1B6W2qb2A==",
"license": "MIT",
"dependencies": {
"@miniflare/shared": "2.14.2"
},
"engines": {
"node": ">=16.13"
}
},
"node_modules/@miniflare/storage-memory/node_modules/@miniflare/shared": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.2.tgz",
"integrity": "sha512-dDnYIztz10zDQjaFJ8Gy9UaaBWZkw3NyhFdpX6tAeyPA/2lGvkftc42MYmNi8s5ljqkZAtKgWAJnSf2K75NCJw==",
"dependencies": {
"@types/better-sqlite3": "^7.6.0",
"kleur": "^4.1.4",
"npx-import": "^1.1.4",
"picomatch": "^2.3.1"
"@miniflare/shared": "2.14.4"
},
"engines": {
"node": ">=16.13"
@@ -1585,9 +1574,9 @@
}
},
"node_modules/wrangler": {
"version": "3.78.12",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.78.12.tgz",
"integrity": "sha512-a/xk/N04IvOGk9J+BLkiFg42GDyPS+0BiJimbrHsbX+CDr8Iqq3HNMEyQld+6zbmq01u/gmc8S7GKVR9vDx4+g==",
"version": "3.80.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.80.0.tgz",
"integrity": "sha512-c9aN7Buf7XgTPpQkw1d0VjNRI4qg3m35PTg70Tg4mOeHwHGjsd74PAsP1G8BjpdqDjfWtsua7tj1g4M3/5dYNQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {

View File

@@ -5,7 +5,7 @@
"@cloudflare/workers-types": "^4.20230801.0",
"miniflare": "^3.20230801.1",
"typescript": "^5.1.6",
"wrangler": "3.78.12"
"wrangler": "3.80.0"
},
"private": true,
"scripts": {

View File

@@ -1129,9 +1129,9 @@
"dev": true
},
"node_modules/@types/react": {
"version": "18.3.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
"version": "18.3.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz",
"integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1149,17 +1149,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz",
"integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz",
"integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.7.0",
"@typescript-eslint/type-utils": "8.7.0",
"@typescript-eslint/utils": "8.7.0",
"@typescript-eslint/visitor-keys": "8.7.0",
"@typescript-eslint/scope-manager": "8.8.0",
"@typescript-eslint/type-utils": "8.8.0",
"@typescript-eslint/utils": "8.8.0",
"@typescript-eslint/visitor-keys": "8.8.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -1183,16 +1183,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz",
"integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz",
"integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "8.7.0",
"@typescript-eslint/types": "8.7.0",
"@typescript-eslint/typescript-estree": "8.7.0",
"@typescript-eslint/visitor-keys": "8.7.0",
"@typescript-eslint/scope-manager": "8.8.0",
"@typescript-eslint/types": "8.8.0",
"@typescript-eslint/typescript-estree": "8.8.0",
"@typescript-eslint/visitor-keys": "8.8.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1212,14 +1212,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz",
"integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz",
"integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.7.0",
"@typescript-eslint/visitor-keys": "8.7.0"
"@typescript-eslint/types": "8.8.0",
"@typescript-eslint/visitor-keys": "8.8.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1230,14 +1230,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz",
"integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz",
"integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.7.0",
"@typescript-eslint/utils": "8.7.0",
"@typescript-eslint/typescript-estree": "8.8.0",
"@typescript-eslint/utils": "8.8.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1255,9 +1255,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz",
"integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz",
"integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1269,14 +1269,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz",
"integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz",
"integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "8.7.0",
"@typescript-eslint/visitor-keys": "8.7.0",
"@typescript-eslint/types": "8.8.0",
"@typescript-eslint/visitor-keys": "8.8.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -1324,16 +1324,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz",
"integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz",
"integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.7.0",
"@typescript-eslint/types": "8.7.0",
"@typescript-eslint/typescript-estree": "8.7.0"
"@typescript-eslint/scope-manager": "8.8.0",
"@typescript-eslint/types": "8.8.0",
"@typescript-eslint/typescript-estree": "8.8.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1347,13 +1347,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz",
"integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==",
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz",
"integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.7.0",
"@typescript-eslint/types": "8.8.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -2398,9 +2398,9 @@
}
},
"node_modules/eslint-module-utils": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz",
"integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==",
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2425,9 +2425,9 @@
}
},
"node_modules/eslint-plugin-import": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz",
"integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==",
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2439,7 +2439,7 @@
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.9.0",
"eslint-module-utils": "^2.12.0",
"hasown": "^2.0.2",
"is-core-module": "^2.15.1",
"is-glob": "^4.0.3",
@@ -2448,13 +2448,14 @@
"object.groupby": "^1.0.3",
"object.values": "^1.2.0",
"semver": "^6.3.1",
"string.prototype.trimend": "^1.0.8",
"tsconfig-paths": "^3.15.0"
},
"engines": {
"node": ">=4"
},
"peerDependencies": {
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
}
},
"node_modules/eslint-plugin-import/node_modules/debug": {
@@ -2491,9 +2492,9 @@
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.0.tgz",
"integrity": "sha512-IHBePmfWH5lKhJnJ7WB1V+v/GolbB0rjS8XYVCSQCZKaQCAUhMoVoOEn1Ef8Z8Wf0a7l8KTJvuZg5/e4qrZ6nA==",
"version": "7.37.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz",
"integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "ruff-ecosystem"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.1"]
dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.2"]
[project.scripts]
ruff-ecosystem = "ruff_ecosystem.cli:entrypoint"

5
ruff.schema.json generated
View File

@@ -17,7 +17,7 @@
}
},
"allowed-unused-imports": {
"description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.",
"description": "A list of modules which is allowed even though they are not used in the code.\n\nThis is useful when a module has a side effect when imported.",
"deprecated": true,
"type": [
"array",
@@ -1899,7 +1899,7 @@
}
},
"allowed-unused-imports": {
"description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.",
"description": "A list of modules which is allowed even though they are not used in the code.\n\nThis is useful when a module has a side effect when imported.",
"type": [
"array",
"null"
@@ -3279,6 +3279,7 @@
"FURB15",
"FURB152",
"FURB154",
"FURB156",
"FURB157",
"FURB16",
"FURB161",