Compare commits
188 Commits
deprecated
...
0.9.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d3e64ccd | ||
|
|
84ceddcbd9 | ||
|
|
ba2f0e998d | ||
|
|
18b497a913 | ||
|
|
7cac0da44d | ||
|
|
b66cc94f9b | ||
|
|
e345307260 | ||
|
|
5588c75d65 | ||
|
|
9d2105b863 | ||
|
|
8fcac0ff36 | ||
|
|
81059d05fc | ||
|
|
24bab7e82e | ||
|
|
d0555f7b5c | ||
|
|
0906554357 | ||
|
|
d296f602e7 | ||
|
|
d47088c8f8 | ||
|
|
1f0ad675d3 | ||
|
|
a84b27e679 | ||
|
|
8d4679b3ae | ||
|
|
b40a7cce15 | ||
|
|
54b3849dfb | ||
|
|
ffd94e9ace | ||
|
|
c816542704 | ||
|
|
3f958a9d4c | ||
|
|
2ebb5e8d4b | ||
|
|
c69b19fe1d | ||
|
|
076d35fb93 | ||
|
|
16f2a93fca | ||
|
|
eb08345fd5 | ||
|
|
7ca778f492 | ||
|
|
827a076a2f | ||
|
|
4855e0b288 | ||
|
|
44ddd98d7e | ||
|
|
82cb8675dd | ||
|
|
5852217198 | ||
|
|
700e969c56 | ||
|
|
4c15d7a559 | ||
|
|
e15419396c | ||
|
|
444b055cec | ||
|
|
6bb32355ef | ||
|
|
cb71393332 | ||
|
|
64e64d2681 | ||
|
|
9d83e76a3b | ||
|
|
5bf0e2e95e | ||
|
|
24c1cf71cb | ||
|
|
f23802e219 | ||
|
|
ff87ea8d42 | ||
|
|
cc60701b59 | ||
|
|
b5e5271adf | ||
|
|
9a33924a65 | ||
|
|
15dd3b5ebd | ||
|
|
b848afeae8 | ||
|
|
de4d9979eb | ||
|
|
ba02294af3 | ||
|
|
11cfe2ea8a | ||
|
|
0529ad67d7 | ||
|
|
102c2eec12 | ||
|
|
dc5e922221 | ||
|
|
62075afe4f | ||
|
|
dfe1b849d0 | ||
|
|
9c64d65552 | ||
|
|
83243de93d | ||
|
|
638186afbd | ||
|
|
d082c1b202 | ||
|
|
30d5e9a2af | ||
|
|
a613345274 | ||
|
|
c81f6c0bd2 | ||
|
|
ba534d1931 | ||
|
|
7e1db01041 | ||
|
|
6331dd6272 | ||
|
|
4fe78db16c | ||
|
|
f5f74c95c5 | ||
|
|
464a893f5d | ||
|
|
a53626a8b2 | ||
|
|
b08ce5fb18 | ||
|
|
418aa35041 | ||
|
|
813a76e9e2 | ||
|
|
3c09100484 | ||
|
|
770b7f3439 | ||
|
|
d9a1034db0 | ||
|
|
bcdb3f9840 | ||
|
|
942d7f395a | ||
|
|
b58f2c399e | ||
|
|
fab86de3ef | ||
|
|
c5c0b724fb | ||
|
|
0d191a13c1 | ||
|
|
b2cb757fa8 | ||
|
|
ce769f6ae2 | ||
|
|
44ac17b3ba | ||
|
|
f1418be81c | ||
|
|
59be5f5278 | ||
|
|
4df0796d61 | ||
|
|
172f62d8f4 | ||
|
|
071862af5a | ||
|
|
fe516e24f5 | ||
|
|
4f2aea8d50 | ||
|
|
5c77898693 | ||
|
|
854ab03078 | ||
|
|
b0b8b06241 | ||
|
|
451f251a31 | ||
|
|
13cf3e65f1 | ||
|
|
56f956a238 | ||
|
|
7a10a40b0d | ||
|
|
3125332ec1 | ||
|
|
15d886a502 | ||
|
|
e1c9d10863 | ||
|
|
23c98849fc | ||
|
|
d151ca85d3 | ||
|
|
6c1e19592e | ||
|
|
0f1035b930 | ||
|
|
2c3d889dbb | ||
|
|
4bec8ba731 | ||
|
|
6090408f65 | ||
|
|
72a4d343ff | ||
|
|
786099a872 | ||
|
|
ca53eefa6f | ||
|
|
98d20a8219 | ||
|
|
9c938442e5 | ||
|
|
9bf138c45a | ||
|
|
e994970538 | ||
|
|
c161e4fb12 | ||
|
|
646f1942aa | ||
|
|
0a2139f496 | ||
|
|
2ef94e5f3e | ||
|
|
3a08570a68 | ||
|
|
2da8c3776b | ||
|
|
fac0360310 | ||
|
|
0ff71bc3f3 | ||
|
|
43fbbdc71b | ||
|
|
a8fb6f0f87 | ||
|
|
23baf3a2c8 | ||
|
|
d0709093fe | ||
|
|
101a6ba805 | ||
|
|
5bb87f8eb6 | ||
|
|
37925ac442 | ||
|
|
cb3361e682 | ||
|
|
c824140fa8 | ||
|
|
f85ea1bf46 | ||
|
|
a77a32b7d4 | ||
|
|
d8c2d20325 | ||
|
|
fcd0f349f9 | ||
|
|
5a9d71a5f1 | ||
|
|
9353482a5a | ||
|
|
716b246cf3 | ||
|
|
4e3982cf95 | ||
|
|
ab2e1905c4 | ||
|
|
1feb3cf41a | ||
|
|
7778d1d646 | ||
|
|
fb58a9b610 | ||
|
|
17a8a55f08 | ||
|
|
99d8ec6769 | ||
|
|
34cc3cab98 | ||
|
|
9384ba4b91 | ||
|
|
2b3550c85f | ||
|
|
90589372da | ||
|
|
b5ffb404de | ||
|
|
cffd1866ce | ||
|
|
569060f46c | ||
|
|
15394a8028 | ||
|
|
fc2ebea736 | ||
|
|
43160b4c3e | ||
|
|
0173738eef | ||
|
|
05ea77b1d4 | ||
|
|
1e790d3885 | ||
|
|
7855f03735 | ||
|
|
84301a7300 | ||
|
|
7b17c9c445 | ||
|
|
23c222368e | ||
|
|
1ecd97855e | ||
|
|
39e2df7ada | ||
|
|
ce8110332c | ||
|
|
555b3a6a2c | ||
|
|
05abd642a8 | ||
|
|
bb6fb4686d | ||
|
|
b4877f1661 | ||
|
|
3235cd8019 | ||
|
|
13e7afca42 | ||
|
|
f349dab4fc | ||
|
|
df713bc507 | ||
|
|
043ff61a0b | ||
|
|
792f9e357e | ||
|
|
6fe404a40f | ||
|
|
770b844fa5 | ||
|
|
7841cddb34 | ||
|
|
d4efef2382 | ||
|
|
e220c74163 | ||
|
|
f54b82147e | ||
|
|
1e053531b6 |
@@ -8,3 +8,7 @@ benchmark = "bench -p ruff_benchmark --bench linter --bench formatter --"
|
||||
# See: https://github.com/astral-sh/ruff/issues/11503
|
||||
[target.'cfg(all(target_env="msvc", target_os = "windows"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.'wasm32-unknown-unknown']
|
||||
# See https://docs.rs/getrandom/latest/getrandom/#webassembly-support
|
||||
rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
|
||||
@@ -6,3 +6,10 @@ failure-output = "immediate-final"
|
||||
fail-fast = false
|
||||
|
||||
status-level = "skip"
|
||||
|
||||
# Mark tests that take longer than 1s as slow.
|
||||
# Terminate after 60s as a stop-gap measure to terminate on deadlock.
|
||||
slow-timeout = { period = "1s", terminate-after = 60 }
|
||||
|
||||
# Show slow jobs in the final summary
|
||||
final-status-level = "slow"
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file cannot use the extension `.yaml`.
|
||||
blank_issues_enabled: false
|
||||
22
.github/ISSUE_TEMPLATE/issue.yaml
vendored
Normal file
22
.github/ISSUE_TEMPLATE/issue.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: New issue
|
||||
description: A generic issue
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to report an issue! We're glad to have you involved with Ruff.
|
||||
|
||||
If you're filing a bug report, please consider including the following information:
|
||||
|
||||
* List of keywords you searched for before creating this issue. Write them down here so that others can find this issue more easily and help provide feedback.
|
||||
e.g. "RUF001", "unused variable", "Jupyter notebook"
|
||||
* A minimal code snippet that reproduces the bug.
|
||||
* The command you invoked (e.g., `ruff /path/to/file.py --fix`), ideally including the `--isolated` flag.
|
||||
* The current Ruff settings (any relevant sections from your `pyproject.toml`).
|
||||
* The current Ruff version (`ruff --version`).
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A description of the issue
|
||||
2
.github/workflows/build-binaries.yml
vendored
2
.github/workflows/build-binaries.yml
vendored
@@ -23,6 +23,8 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
PACKAGE_NAME: ruff
|
||||
MODULE_NAME: ruff
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
env:
|
||||
TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }}
|
||||
run: |
|
||||
version=$(grep "version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g')
|
||||
version=$(grep -m 1 "^version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g')
|
||||
if [ "${TAG}" != "${version}" ]; then
|
||||
echo "The input tag does not match the version from pyproject.toml:" >&2
|
||||
echo "${TAG}" >&2
|
||||
|
||||
62
.github/workflows/ci.yaml
vendored
62
.github/workflows/ci.yaml
vendored
@@ -1,5 +1,7 @@
|
||||
name: CI
|
||||
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -59,6 +61,7 @@ jobs:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- crates/**
|
||||
- "!crates/red_knot*/**"
|
||||
- "!crates/ruff_python_formatter/**"
|
||||
- "!crates/ruff_formatter/**"
|
||||
- "!crates/ruff_dev/**"
|
||||
@@ -116,11 +119,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: |
|
||||
rustup component add clippy
|
||||
rustup target add wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Clippy"
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- name: "Clippy (wasm)"
|
||||
@@ -130,12 +133,13 @@ jobs:
|
||||
name: "cargo test (linux)"
|
||||
runs-on: depot-ubuntu-22.04-16
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install mold"
|
||||
@@ -148,7 +152,6 @@ jobs:
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-insta
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
shell: bash
|
||||
env:
|
||||
@@ -176,12 +179,13 @@ jobs:
|
||||
name: "cargo test (linux, release)"
|
||||
runs-on: depot-ubuntu-22.04-16
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install mold"
|
||||
@@ -194,7 +198,6 @@ jobs:
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-insta
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
shell: bash
|
||||
env:
|
||||
@@ -205,22 +208,23 @@ jobs:
|
||||
name: "cargo test (windows)"
|
||||
runs-on: github-windows-2025-x86_64-16
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install cargo nextest"
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
shell: bash
|
||||
env:
|
||||
NEXTEST_PROFILE: "ci"
|
||||
# Workaround for <https://github.com/nextest-rs/nextest/issues/1493>.
|
||||
RUSTUP_WINDOWS_PATH_ADD_BIN: 1
|
||||
run: |
|
||||
@@ -231,12 +235,13 @@ jobs:
|
||||
name: "cargo test (wasm)"
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -247,7 +252,6 @@ jobs:
|
||||
- uses: jetli/wasm-pack-action@v0.4.0
|
||||
with:
|
||||
version: v0.13.1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Test ruff_wasm"
|
||||
run: |
|
||||
cd crates/ruff_wasm
|
||||
@@ -266,19 +270,19 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install mold"
|
||||
uses: rui314/setup-mold@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Build"
|
||||
run: cargo build --release --locked
|
||||
|
||||
cargo-build-msrv:
|
||||
name: "cargo build (msrv)"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest-8
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -289,6 +293,7 @@ jobs:
|
||||
with:
|
||||
file: "Cargo.toml"
|
||||
field: "workspace.package.rust-version"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
env:
|
||||
MSRV: ${{ steps.msrv.outputs.value }}
|
||||
@@ -303,7 +308,6 @@ jobs:
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-insta
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
shell: bash
|
||||
env:
|
||||
@@ -321,11 +325,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "fuzz -> target"
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install cargo-binstall"
|
||||
uses: cargo-bins/cargo-binstall@main
|
||||
with:
|
||||
@@ -341,7 +345,7 @@ jobs:
|
||||
needs:
|
||||
- cargo-test-linux
|
||||
- determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.parser == 'true' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && needs.determine_changes.outputs.parser == 'true' }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
@@ -377,15 +381,15 @@ jobs:
|
||||
name: "test scripts"
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup component add rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Run all code generation scripts, and verify that the current output is
|
||||
# already checked into git.
|
||||
- run: python crates/ruff_python_ast/generate.py
|
||||
@@ -409,7 +413,7 @@ jobs:
|
||||
- determine_changes
|
||||
# Only runs on pull requests, since that is the only we way we can find the base version for comparison.
|
||||
# Ecosystem check needs linter and/or formatter changes.
|
||||
if: ${{ github.event_name == 'pull_request' && needs.determine_changes.outputs.code == 'true' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && github.event_name == 'pull_request' && needs.determine_changes.outputs.code == 'true' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -426,7 +430,7 @@ jobs:
|
||||
name: ruff
|
||||
path: target/debug
|
||||
|
||||
- uses: dawidd6/action-download-artifact@v7
|
||||
- uses: dawidd6/action-download-artifact@v8
|
||||
name: Download baseline Ruff binary
|
||||
with:
|
||||
name: ruff
|
||||
@@ -543,6 +547,7 @@ jobs:
|
||||
name: "python package"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -577,9 +582,9 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install pre-commit"
|
||||
run: pip install pre-commit
|
||||
- name: "Cache pre-commit"
|
||||
@@ -611,6 +616,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Add SSH key"
|
||||
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
@@ -620,7 +626,6 @@ jobs:
|
||||
run: rustup show
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Insiders dependencies"
|
||||
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
|
||||
run: uv pip install -r docs/requirements-insiders.txt --system
|
||||
@@ -644,16 +649,15 @@ jobs:
|
||||
name: "formatter instabilities and black similarity"
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine_changes
|
||||
if: needs.determine_changes.outputs.formatter == 'true' || github.ref == 'refs/heads/main'
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.formatter == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Cache rust"
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: "Run checks"
|
||||
run: scripts/formatter_ecosystem_checks.sh
|
||||
- name: "Github step summary"
|
||||
@@ -668,7 +672,7 @@ jobs:
|
||||
needs:
|
||||
- cargo-test-linux
|
||||
- determine_changes
|
||||
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
steps:
|
||||
- uses: extractions/setup-just@v2
|
||||
env:
|
||||
@@ -710,7 +714,7 @@ jobs:
|
||||
benchmarks:
|
||||
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') }}
|
||||
if: ${{ github.repository == 'astral-sh/ruff' && !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout Branch"
|
||||
@@ -718,6 +722,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
|
||||
@@ -726,8 +732,6 @@ jobs:
|
||||
with:
|
||||
tool: cargo-codspeed
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: "Build benchmarks"
|
||||
run: cargo codspeed build --features codspeed -p ruff_benchmark
|
||||
|
||||
|
||||
4
.github/workflows/pr-comment.yaml
vendored
4
.github/workflows/pr-comment.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: dawidd6/action-download-artifact@v7
|
||||
- uses: dawidd6/action-download-artifact@v8
|
||||
name: Download pull request number
|
||||
with:
|
||||
name: pr-number
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
echo "pr-number=$(<pr-number)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: dawidd6/action-download-artifact@v7
|
||||
- uses: dawidd6/action-download-artifact@v8
|
||||
name: "Download ecosystem results"
|
||||
id: download-ecosystem-result
|
||||
if: steps.pr-number.outputs.pr-number
|
||||
|
||||
7
.github/zizmor.yml
vendored
7
.github/zizmor.yml
vendored
@@ -10,3 +10,10 @@ rules:
|
||||
ignore:
|
||||
- build-docker.yml
|
||||
- publish-playground.yml
|
||||
excessive-permissions:
|
||||
# it's hard to test what the impact of removing these ignores would be
|
||||
# without actually running the release workflow...
|
||||
ignore:
|
||||
- build-docker.yml
|
||||
- publish-playground.yml
|
||||
- publish-docs.yml
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -29,6 +29,10 @@ tracing.folded
|
||||
tracing-flamechart.svg
|
||||
tracing-flamegraph.svg
|
||||
|
||||
# insta
|
||||
*.rs.pending-snap
|
||||
|
||||
|
||||
###
|
||||
# Rust.gitignore
|
||||
###
|
||||
|
||||
@@ -5,6 +5,7 @@ exclude: |
|
||||
.github/workflows/release.yml|
|
||||
crates/red_knot_vendored/vendor/.*|
|
||||
crates/red_knot_project/resources/.*|
|
||||
crates/ruff_benchmark/resources/.*|
|
||||
crates/ruff_linter/resources/.*|
|
||||
crates/ruff_linter/src/rules/.*/snapshots/.*|
|
||||
crates/ruff_notebook/resources/.*|
|
||||
@@ -23,7 +24,7 @@ repos:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/executablebooks/mdformat
|
||||
rev: 0.7.21
|
||||
rev: 0.7.22
|
||||
hooks:
|
||||
- id: mdformat
|
||||
additional_dependencies:
|
||||
@@ -36,7 +37,7 @@ repos:
|
||||
)$
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.43.0
|
||||
rev: v0.44.0
|
||||
hooks:
|
||||
- id: markdownlint-fix
|
||||
exclude: |
|
||||
@@ -56,10 +57,10 @@ repos:
|
||||
.*?invalid(_.+)*_syntax\.md
|
||||
)$
|
||||
additional_dependencies:
|
||||
- black==24.10.0
|
||||
- black==25.1.0
|
||||
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: v1.29.4
|
||||
rev: v1.29.5
|
||||
hooks:
|
||||
- id: typos
|
||||
|
||||
@@ -73,7 +74,7 @@ repos:
|
||||
pass_filenames: false # This makes it a lot faster
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.2
|
||||
rev: v0.9.4
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
- id: ruff
|
||||
@@ -91,12 +92,12 @@ repos:
|
||||
# zizmor detects security vulnerabilities in GitHub Actions workflows.
|
||||
# Additional configuration for the tool is found in `.github/zizmor.yml`
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.1.1
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||
rev: 0.31.0
|
||||
rev: 0.31.1
|
||||
hooks:
|
||||
- id: check-github-workflows
|
||||
|
||||
|
||||
157
CHANGELOG.md
157
CHANGELOG.md
@@ -1,5 +1,162 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.5
|
||||
|
||||
### Preview features
|
||||
|
||||
- Recognize all symbols named `TYPE_CHECKING` for `in_type_checking_block` ([#15719](https://github.com/astral-sh/ruff/pull/15719))
|
||||
- \[`flake8-comprehensions`\] Handle builtins at top of file correctly for `unnecessary-dict-comprehension-for-iterable` (`C420`) ([#15837](https://github.com/astral-sh/ruff/pull/15837))
|
||||
- \[`flake8-logging`\] `.exception()` and `exc_info=` outside exception handlers (`LOG004`, `LOG014`) ([#15799](https://github.com/astral-sh/ruff/pull/15799))
|
||||
- \[`flake8-pyi`\] Fix incorrect behaviour of `custom-typevar-return-type` preview-mode autofix if `typing` was already imported (`PYI019`) ([#15853](https://github.com/astral-sh/ruff/pull/15853))
|
||||
- \[`flake8-pyi`\] Fix more complex cases (`PYI019`) ([#15821](https://github.com/astral-sh/ruff/pull/15821))
|
||||
- \[`flake8-pyi`\] Make `PYI019` autofixable for `.py` files in preview mode as well as stubs ([#15889](https://github.com/astral-sh/ruff/pull/15889))
|
||||
- \[`flake8-pyi`\] Remove type parameter correctly when it is the last (`PYI019`) ([#15854](https://github.com/astral-sh/ruff/pull/15854))
|
||||
- \[`pylint`\] Fix missing parens in unsafe fix for `unnecessary-dunder-call` (`PLC2801`) ([#15762](https://github.com/astral-sh/ruff/pull/15762))
|
||||
- \[`pyupgrade`\] Better messages and diagnostic range (`UP015`) ([#15872](https://github.com/astral-sh/ruff/pull/15872))
|
||||
- \[`pyupgrade`\] Rename private type parameters in PEP 695 generics (`UP049`) ([#15862](https://github.com/astral-sh/ruff/pull/15862))
|
||||
- \[`refurb`\] Also report non-name expressions (`FURB169`) ([#15905](https://github.com/astral-sh/ruff/pull/15905))
|
||||
- \[`refurb`\] Mark fix as unsafe if there are comments (`FURB171`) ([#15832](https://github.com/astral-sh/ruff/pull/15832))
|
||||
- \[`ruff`\] Classes with mixed type variable style (`RUF053`) ([#15841](https://github.com/astral-sh/ruff/pull/15841))
|
||||
- \[`airflow`\] `BashOperator` has been moved to `airflow.providers.standard.operators.bash.BashOperator` (`AIR302`) ([#15922](https://github.com/astral-sh/ruff/pull/15922))
|
||||
- \[`flake8-pyi`\] Add autofix for unused-private-type-var (`PYI018`) ([#15999](https://github.com/astral-sh/ruff/pull/15999))
|
||||
- \[`flake8-pyi`\] Significantly improve accuracy of `PYI019` if preview mode is enabled ([#15888](https://github.com/astral-sh/ruff/pull/15888))
|
||||
|
||||
### Rule changes
|
||||
|
||||
- Preserve triple quotes and prefixes for strings ([#15818](https://github.com/astral-sh/ruff/pull/15818))
|
||||
- \[`flake8-comprehensions`\] Skip when `TypeError` present from too many (kw)args for `C410`,`C411`, and `C418` ([#15838](https://github.com/astral-sh/ruff/pull/15838))
|
||||
- \[`flake8-pyi`\] Rename `PYI019` and improve its diagnostic message ([#15885](https://github.com/astral-sh/ruff/pull/15885))
|
||||
- \[`pep8-naming`\] Ignore `@override` methods (`N803`) ([#15954](https://github.com/astral-sh/ruff/pull/15954))
|
||||
- \[`pyupgrade`\] Reuse replacement logic from `UP046` and `UP047` to preserve more comments (`UP040`) ([#15840](https://github.com/astral-sh/ruff/pull/15840))
|
||||
- \[`ruff`\] Analyze deferred annotations before enforcing `mutable-(data)class-default` and `function-call-in-dataclass-default-argument` (`RUF008`,`RUF009`,`RUF012`) ([#15921](https://github.com/astral-sh/ruff/pull/15921))
|
||||
- \[`pycodestyle`\] Exempt `sys.path += ...` calls (`E402`) ([#15980](https://github.com/astral-sh/ruff/pull/15980))
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config error only when `flake8-import-conventions` alias conflicts with `isort.required-imports` bound name ([#15918](https://github.com/astral-sh/ruff/pull/15918))
|
||||
- Workaround Even Better TOML crash related to `allOf` ([#15992](https://github.com/astral-sh/ruff/pull/15992))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- \[`flake8-comprehensions`\] Unnecessary `list` comprehension (rewrite as a `set` comprehension) (`C403`) - Handle extraneous parentheses around list comprehension ([#15877](https://github.com/astral-sh/ruff/pull/15877))
|
||||
- \[`flake8-comprehensions`\] Handle trailing comma in fixes for `unnecessary-generator-list/set` (`C400`,`C401`) ([#15929](https://github.com/astral-sh/ruff/pull/15929))
|
||||
- \[`flake8-pyi`\] Fix several correctness issues with `custom-type-var-return-type` (`PYI019`) ([#15851](https://github.com/astral-sh/ruff/pull/15851))
|
||||
- \[`pep8-naming`\] Consider any number of leading underscore for `N801` ([#15988](https://github.com/astral-sh/ruff/pull/15988))
|
||||
- \[`pyflakes`\] Visit forward annotations in `TypeAliasType` as types (`F401`) ([#15829](https://github.com/astral-sh/ruff/pull/15829))
|
||||
- \[`pylint`\] Correct min/max auto-fix and suggestion for (`PL1730`) ([#15930](https://github.com/astral-sh/ruff/pull/15930))
|
||||
- \[`refurb`\] Handle unparenthesized tuples correctly (`FURB122`, `FURB142`) ([#15953](https://github.com/astral-sh/ruff/pull/15953))
|
||||
- \[`refurb`\] Avoid `None | None` as well as better detection and fix (`FURB168`) ([#15779](https://github.com/astral-sh/ruff/pull/15779))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add deprecation warning for `ruff-lsp` related settings ([#15850](https://github.com/astral-sh/ruff/pull/15850))
|
||||
- Docs (`linter.md`): clarify that Python files are always searched for in subdirectories ([#15882](https://github.com/astral-sh/ruff/pull/15882))
|
||||
- Fix a typo in `non_pep695_generic_class.rs` ([#15946](https://github.com/astral-sh/ruff/pull/15946))
|
||||
- Improve Docs: Pylint subcategories' codes ([#15909](https://github.com/astral-sh/ruff/pull/15909))
|
||||
- Remove non-existing `lint.extendIgnore` editor setting ([#15844](https://github.com/astral-sh/ruff/pull/15844))
|
||||
- Update black deviations ([#15928](https://github.com/astral-sh/ruff/pull/15928))
|
||||
- Mention `UP049` in `UP046` and `UP047`, add `See also` section to `UP040` ([#15956](https://github.com/astral-sh/ruff/pull/15956))
|
||||
- Add instance variable examples to `RUF012` ([#15982](https://github.com/astral-sh/ruff/pull/15982))
|
||||
- Explain precedence for `ignore` and `select` config ([#15883](https://github.com/astral-sh/ruff/pull/15883))
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Preview features
|
||||
|
||||
- \[`airflow`\] Extend airflow context parameter check for `BaseOperator.execute` (`AIR302`) ([#15713](https://github.com/astral-sh/ruff/pull/15713))
|
||||
- \[`airflow`\] Update `AIR302` to check for deprecated context keys ([#15144](https://github.com/astral-sh/ruff/pull/15144))
|
||||
- \[`flake8-bandit`\] Permit suspicious imports within stub files (`S4`) ([#15822](https://github.com/astral-sh/ruff/pull/15822))
|
||||
- \[`pylint`\] Do not trigger `PLR6201` on empty collections ([#15732](https://github.com/astral-sh/ruff/pull/15732))
|
||||
- \[`refurb`\] Do not emit diagnostic when loop variables are used outside loop body (`FURB122`) ([#15757](https://github.com/astral-sh/ruff/pull/15757))
|
||||
- \[`ruff`\] Add support for more `re` patterns (`RUF055`) ([#15764](https://github.com/astral-sh/ruff/pull/15764))
|
||||
- \[`ruff`\] Check for shadowed `map` before suggesting fix (`RUF058`) ([#15790](https://github.com/astral-sh/ruff/pull/15790))
|
||||
- \[`ruff`\] Do not emit diagnostic when all arguments to `zip()` are variadic (`RUF058`) ([#15744](https://github.com/astral-sh/ruff/pull/15744))
|
||||
- \[`ruff`\] Parenthesize fix when argument spans multiple lines for `unnecessary-round` (`RUF057`) ([#15703](https://github.com/astral-sh/ruff/pull/15703))
|
||||
|
||||
### Rule changes
|
||||
|
||||
- Preserve quote style in generated code ([#15726](https://github.com/astral-sh/ruff/pull/15726), [#15778](https://github.com/astral-sh/ruff/pull/15778), [#15794](https://github.com/astral-sh/ruff/pull/15794))
|
||||
- \[`flake8-bugbear`\] Exempt `NewType` calls where the original type is immutable (`B008`) ([#15765](https://github.com/astral-sh/ruff/pull/15765))
|
||||
- \[`pylint`\] Honor banned top-level imports by `TID253` in `PLC0415`. ([#15628](https://github.com/astral-sh/ruff/pull/15628))
|
||||
- \[`pyupgrade`\] Ignore `is_typeddict` and `TypedDict` for `deprecated-import` (`UP035`) ([#15800](https://github.com/astral-sh/ruff/pull/15800))
|
||||
|
||||
### CLI
|
||||
|
||||
- Fix formatter warning message for `flake8-quotes` option ([#15788](https://github.com/astral-sh/ruff/pull/15788))
|
||||
- Implement tab autocomplete for `ruff config` ([#15603](https://github.com/astral-sh/ruff/pull/15603))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- \[`flake8-comprehensions`\] Do not emit `unnecessary-map` diagnostic when lambda has different arity (`C417`) ([#15802](https://github.com/astral-sh/ruff/pull/15802))
|
||||
- \[`flake8-comprehensions`\] Parenthesize `sorted` when needed for `unnecessary-call-around-sorted` (`C413`) ([#15825](https://github.com/astral-sh/ruff/pull/15825))
|
||||
- \[`pyupgrade`\] Handle end-of-line comments for `quoted-annotation` (`UP037`) ([#15824](https://github.com/astral-sh/ruff/pull/15824))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add missing config docstrings ([#15803](https://github.com/astral-sh/ruff/pull/15803))
|
||||
- Add references to `trio.run_process` and `anyio.run_process` ([#15761](https://github.com/astral-sh/ruff/pull/15761))
|
||||
- Use `uv init --lib` in tutorial ([#15718](https://github.com/astral-sh/ruff/pull/15718))
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Preview features
|
||||
|
||||
- \[`airflow`\] Argument `fail_stop` in DAG has been renamed as `fail_fast` (`AIR302`) ([#15633](https://github.com/astral-sh/ruff/pull/15633))
|
||||
- \[`airflow`\] Extend `AIR303` with more symbols ([#15611](https://github.com/astral-sh/ruff/pull/15611))
|
||||
- \[`flake8-bandit`\] Report all references to suspicious functions (`S3`) ([#15541](https://github.com/astral-sh/ruff/pull/15541))
|
||||
- \[`flake8-pytest-style`\] Do not emit diagnostics for empty `for` loops (`PT012`, `PT031`) ([#15542](https://github.com/astral-sh/ruff/pull/15542))
|
||||
- \[`flake8-simplify`\] Avoid double negations (`SIM103`) ([#15562](https://github.com/astral-sh/ruff/pull/15562))
|
||||
- \[`pyflakes`\] Fix infinite loop with unused local import in `__init__.py` (`F401`) ([#15517](https://github.com/astral-sh/ruff/pull/15517))
|
||||
- \[`pylint`\] Do not report methods with only one `EM101`-compatible `raise` (`PLR6301`) ([#15507](https://github.com/astral-sh/ruff/pull/15507))
|
||||
- \[`pylint`\] Implement `redefined-slots-in-subclass` (`W0244`) ([#9640](https://github.com/astral-sh/ruff/pull/9640))
|
||||
- \[`pyupgrade`\] Add rules to use PEP 695 generics in classes and functions (`UP046`, `UP047`) ([#15565](https://github.com/astral-sh/ruff/pull/15565), [#15659](https://github.com/astral-sh/ruff/pull/15659))
|
||||
- \[`refurb`\] Implement `for-loop-writes` (`FURB122`) ([#10630](https://github.com/astral-sh/ruff/pull/10630))
|
||||
- \[`ruff`\] Implement `needless-else` clause (`RUF047`) ([#15051](https://github.com/astral-sh/ruff/pull/15051))
|
||||
- \[`ruff`\] Implement `starmap-zip` (`RUF058`) ([#15483](https://github.com/astral-sh/ruff/pull/15483))
|
||||
|
||||
### Rule changes
|
||||
|
||||
- \[`flake8-bugbear`\] Do not raise error if keyword argument is present and target-python version is less or equals than 3.9 (`B903`) ([#15549](https://github.com/astral-sh/ruff/pull/15549))
|
||||
- \[`flake8-comprehensions`\] strip parentheses around generators in `unnecessary-generator-set` (`C401`) ([#15553](https://github.com/astral-sh/ruff/pull/15553))
|
||||
- \[`flake8-pytest-style`\] Rewrite references to `.exception` (`PT027`) ([#15680](https://github.com/astral-sh/ruff/pull/15680))
|
||||
- \[`flake8-simplify`\] Mark fixes as unsafe (`SIM201`, `SIM202`) ([#15626](https://github.com/astral-sh/ruff/pull/15626))
|
||||
- \[`flake8-type-checking`\] Fix some safe fixes being labeled unsafe (`TC006`,`TC008`) ([#15638](https://github.com/astral-sh/ruff/pull/15638))
|
||||
- \[`isort`\] Omit trailing whitespace in `unsorted-imports` (`I001`) ([#15518](https://github.com/astral-sh/ruff/pull/15518))
|
||||
- \[`pydoclint`\] Allow ignoring one line docstrings for `DOC` rules ([#13302](https://github.com/astral-sh/ruff/pull/13302))
|
||||
- \[`pyflakes`\] Apply redefinition fixes by source code order (`F811`) ([#15575](https://github.com/astral-sh/ruff/pull/15575))
|
||||
- \[`pyflakes`\] Avoid removing too many imports in `redefined-while-unused` (`F811`) ([#15585](https://github.com/astral-sh/ruff/pull/15585))
|
||||
- \[`pyflakes`\] Group redefinition fixes by source statement (`F811`) ([#15574](https://github.com/astral-sh/ruff/pull/15574))
|
||||
- \[`pylint`\] Include name of base class in message for `redefined-slots-in-subclass` (`W0244`) ([#15559](https://github.com/astral-sh/ruff/pull/15559))
|
||||
- \[`ruff`\] Update fix for `RUF055` to use `var == value` ([#15605](https://github.com/astral-sh/ruff/pull/15605))
|
||||
|
||||
### Formatter
|
||||
|
||||
- Fix bracket spacing for single-element tuples in f-string expressions ([#15537](https://github.com/astral-sh/ruff/pull/15537))
|
||||
- Fix unstable f-string formatting for expressions containing a trailing comma ([#15545](https://github.com/astral-sh/ruff/pull/15545))
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid quadratic membership check in import fixes ([#15576](https://github.com/astral-sh/ruff/pull/15576))
|
||||
|
||||
### Server
|
||||
|
||||
- Allow `unsafe-fixes` settings for code actions ([#15666](https://github.com/astral-sh/ruff/pull/15666))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- \[`flake8-bandit`\] Add missing single-line/dotall regex flag (`S608`) ([#15654](https://github.com/astral-sh/ruff/pull/15654))
|
||||
- \[`flake8-import-conventions`\] Fix infinite loop between `ICN001` and `I002` (`ICN001`) ([#15480](https://github.com/astral-sh/ruff/pull/15480))
|
||||
- \[`flake8-simplify`\] Do not emit diagnostics for expressions inside string type annotations (`SIM222`, `SIM223`) ([#15405](https://github.com/astral-sh/ruff/pull/15405))
|
||||
- \[`pyflakes`\] Treat arguments passed to the `default=` parameter of `TypeVar` as type expressions (`F821`) ([#15679](https://github.com/astral-sh/ruff/pull/15679))
|
||||
- \[`pyupgrade`\] Avoid syntax error when the iterable is a non-parenthesized tuple (`UP028`) ([#15543](https://github.com/astral-sh/ruff/pull/15543))
|
||||
- \[`ruff`\] Exempt `NewType` calls where the original type is immutable (`RUF009`) ([#15588](https://github.com/astral-sh/ruff/pull/15588))
|
||||
- Preserve raw string prefix and escapes in all codegen fixes ([#15694](https://github.com/astral-sh/ruff/pull/15694))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Generate documentation redirects for lowercase rule codes ([#15564](https://github.com/astral-sh/ruff/pull/15564))
|
||||
- `TRY300`: Add some extra notes on not catching exceptions you didn't expect ([#15036](https://github.com/astral-sh/ruff/pull/15036))
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Preview features
|
||||
|
||||
914
Cargo.lock
generated
914
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -74,11 +74,13 @@ env_logger = { version = "0.11.0" }
|
||||
etcetera = { version = "0.8.0" }
|
||||
fern = { version = "0.7.0" }
|
||||
filetime = { version = "0.2.23" }
|
||||
getrandom = { version = "0.3.1" }
|
||||
glob = { version = "0.3.1" }
|
||||
globset = { version = "0.4.14" }
|
||||
globwalk = { version = "0.9.1" }
|
||||
hashbrown = { version = "0.15.0", default-features = false, features = [
|
||||
"raw-entry",
|
||||
"equivalent",
|
||||
"inline-more",
|
||||
] }
|
||||
ignore = { version = "0.4.22" }
|
||||
@@ -116,7 +118,7 @@ proc-macro2 = { version = "1.0.79" }
|
||||
pyproject-toml = { version = "0.13.4" }
|
||||
quick-junit = { version = "0.5.0" }
|
||||
quote = { version = "1.0.23" }
|
||||
rand = { version = "0.8.5" }
|
||||
rand = { version = "0.9.0" }
|
||||
rayon = { version = "1.10.0" }
|
||||
regex = { version = "1.10.2" }
|
||||
rustc-hash = { version = "2.0.0" }
|
||||
@@ -134,7 +136,12 @@ serde_with = { version = "3.6.0", default-features = false, features = [
|
||||
shellexpand = { version = "3.0.0" }
|
||||
similar = { version = "2.4.0", features = ["inline"] }
|
||||
smallvec = { version = "1.13.2" }
|
||||
snapbox = { version = "0.6.0", features = ["diff", "term-svg", "cmd", "examples"] }
|
||||
snapbox = { version = "0.6.0", features = [
|
||||
"diff",
|
||||
"term-svg",
|
||||
"cmd",
|
||||
"examples",
|
||||
] }
|
||||
static_assertions = "1.1.0"
|
||||
strum = { version = "0.26.0", features = ["strum_macros"] }
|
||||
strum_macros = { version = "0.26.0" }
|
||||
@@ -159,7 +166,6 @@ unicode-ident = { version = "1.0.12" }
|
||||
unicode-width = { version = "0.2.0" }
|
||||
unicode_names2 = { version = "1.2.2" }
|
||||
unicode-normalization = { version = "0.1.23" }
|
||||
ureq = { version = "2.9.6" }
|
||||
url = { version = "2.5.0" }
|
||||
uuid = { version = "1.6.1", features = [
|
||||
"v4",
|
||||
@@ -173,6 +179,10 @@ wasm-bindgen-test = { version = "0.3.42" }
|
||||
wild = { version = "2" }
|
||||
zip = { version = "0.6.6", default-features = false }
|
||||
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = ["getrandom"]
|
||||
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "warn"
|
||||
unreachable_pub = "warn"
|
||||
@@ -305,7 +315,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
|
||||
|
||||
@@ -149,8 +149,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh
|
||||
powershell -c "irm https://astral.sh/ruff/install.ps1 | iex"
|
||||
|
||||
# For a specific version.
|
||||
curl -LsSf https://astral.sh/ruff/0.9.2/install.sh | sh
|
||||
powershell -c "irm https://astral.sh/ruff/0.9.2/install.ps1 | iex"
|
||||
curl -LsSf https://astral.sh/ruff/0.9.5/install.sh | sh
|
||||
powershell -c "irm https://astral.sh/ruff/0.9.5/install.ps1 | iex"
|
||||
```
|
||||
|
||||
You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff),
|
||||
@@ -183,7 +183,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff
|
||||
```yaml
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.9.2
|
||||
rev: v0.9.5
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -33,6 +33,7 @@ tracing-tree = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
ruff_db = { workspace = true, features = ["testing"] }
|
||||
ruff_python_trivia = { workspace = true }
|
||||
|
||||
insta = { workspace = true, features = ["filters"] }
|
||||
insta-cmd = { workspace = true }
|
||||
|
||||
104
crates/red_knot/build.rs
Normal file
104
crates/red_knot/build.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
// The workspace root directory is not available without walking up the tree
|
||||
// https://github.com/rust-lang/cargo/issues/3946
|
||||
let workspace_root = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.join("..")
|
||||
.join("..");
|
||||
|
||||
commit_info(&workspace_root);
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let target = std::env::var("TARGET").unwrap();
|
||||
println!("cargo::rustc-env=RUST_HOST_TARGET={target}");
|
||||
}
|
||||
|
||||
fn commit_info(workspace_root: &Path) {
|
||||
// If not in a git repository, do not attempt to retrieve commit information
|
||||
let git_dir = workspace_root.join(".git");
|
||||
if !git_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(git_head_path) = git_head(&git_dir) {
|
||||
println!("cargo:rerun-if-changed={}", git_head_path.display());
|
||||
|
||||
let git_head_contents = fs::read_to_string(git_head_path);
|
||||
if let Ok(git_head_contents) = git_head_contents {
|
||||
// The contents are either a commit or a reference in the following formats
|
||||
// - "<commit>" when the head is detached
|
||||
// - "ref <ref>" when working on a branch
|
||||
// If a commit, checking if the HEAD file has changed is sufficient
|
||||
// If a ref, we need to add the head file for that ref to rebuild on commit
|
||||
let mut git_ref_parts = git_head_contents.split_whitespace();
|
||||
git_ref_parts.next();
|
||||
if let Some(git_ref) = git_ref_parts.next() {
|
||||
let git_ref_path = git_dir.join(git_ref);
|
||||
println!("cargo:rerun-if-changed={}", git_ref_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = match Command::new("git")
|
||||
.arg("log")
|
||||
.arg("-1")
|
||||
.arg("--date=short")
|
||||
.arg("--abbrev=9")
|
||||
.arg("--format=%H %h %cd %(describe)")
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => output,
|
||||
_ => return,
|
||||
};
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let mut parts = stdout.split_whitespace();
|
||||
let mut next = || parts.next().unwrap();
|
||||
let _commit_hash = next();
|
||||
println!("cargo::rustc-env=RED_KNOT_COMMIT_SHORT_HASH={}", next());
|
||||
println!("cargo::rustc-env=RED_KNOT_COMMIT_DATE={}", next());
|
||||
|
||||
// Describe can fail for some commits
|
||||
// https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem
|
||||
if let Some(describe) = parts.next() {
|
||||
let mut describe_parts = describe.split('-');
|
||||
let _last_tag = describe_parts.next().unwrap();
|
||||
|
||||
// If this is the tagged commit, this component will be missing
|
||||
println!(
|
||||
"cargo::rustc-env=RED_KNOT_LAST_TAG_DISTANCE={}",
|
||||
describe_parts.next().unwrap_or("0")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn git_head(git_dir: &Path) -> Option<PathBuf> {
|
||||
// The typical case is a standard git repository.
|
||||
let git_head_path = git_dir.join("HEAD");
|
||||
if git_head_path.exists() {
|
||||
return Some(git_head_path);
|
||||
}
|
||||
if !git_dir.is_file() {
|
||||
return None;
|
||||
}
|
||||
// If `.git/HEAD` doesn't exist and `.git` is actually a file,
|
||||
// then let's try to attempt to read it as a worktree. If it's
|
||||
// a worktree, then its contents will look like this, e.g.:
|
||||
//
|
||||
// gitdir: /home/andrew/astral/uv/main/.git/worktrees/pr2
|
||||
//
|
||||
// And the HEAD file we want to watch will be at:
|
||||
//
|
||||
// /home/andrew/astral/uv/main/.git/worktrees/pr2/HEAD
|
||||
let contents = fs::read_to_string(git_dir).ok()?;
|
||||
let (label, worktree_path) = contents.split_once(':')?;
|
||||
if label != "gitdir" {
|
||||
return None;
|
||||
}
|
||||
let worktree_path = worktree_path.trim();
|
||||
Some(PathBuf::from(worktree_path))
|
||||
}
|
||||
201
crates/red_knot/src/args.rs
Normal file
201
crates/red_knot/src/args.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use crate::logging::Verbosity;
|
||||
use crate::python_version::PythonVersion;
|
||||
use clap::{ArgAction, ArgMatches, Error, Parser};
|
||||
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
|
||||
use red_knot_project::metadata::value::{RangedValue, RelativePathBuf};
|
||||
use red_knot_python_semantic::lint;
|
||||
use ruff_db::system::SystemPathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author,
|
||||
name = "red-knot",
|
||||
about = "An extremely fast Python type checker."
|
||||
)]
|
||||
#[command(version)]
|
||||
pub(crate) struct Args {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
/// Check a project for type errors.
|
||||
Check(CheckCommand),
|
||||
|
||||
/// Start the language server
|
||||
Server,
|
||||
|
||||
/// Display Red Knot's version
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub(crate) struct CheckCommand {
|
||||
/// Run the command within the given project directory.
|
||||
///
|
||||
/// All `pyproject.toml` files will be discovered by walking up the directory tree from the given project directory,
|
||||
/// as will the project's virtual environment (`.venv`) unless the `venv-path` option is set.
|
||||
///
|
||||
/// Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
pub(crate) project: Option<SystemPathBuf>,
|
||||
|
||||
/// Path to the virtual environment the project uses.
|
||||
///
|
||||
/// If provided, red-knot will use the `site-packages` directory of this virtual environment
|
||||
/// to resolve type information for the project's third-party dependencies.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub(crate) venv_path: Option<SystemPathBuf>,
|
||||
|
||||
/// Custom directory to use for stdlib typeshed stubs.
|
||||
#[arg(long, value_name = "PATH", alias = "custom-typeshed-dir")]
|
||||
pub(crate) typeshed: Option<SystemPathBuf>,
|
||||
|
||||
/// Additional path to use as a module-resolution source (can be passed multiple times).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub(crate) extra_search_path: Option<Vec<SystemPathBuf>>,
|
||||
|
||||
/// Python version to assume when resolving types.
|
||||
#[arg(long, value_name = "VERSION", alias = "target-version")]
|
||||
pub(crate) python_version: Option<PythonVersion>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub(crate) verbosity: Verbosity,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub(crate) rules: RulesArg,
|
||||
|
||||
/// Use exit code 1 if there are any warning-level diagnostics.
|
||||
#[arg(long, conflicts_with = "exit_zero")]
|
||||
pub(crate) error_on_warning: bool,
|
||||
|
||||
/// Always use exit code 0, even when there are error-level diagnostics.
|
||||
#[arg(long)]
|
||||
pub(crate) exit_zero: bool,
|
||||
|
||||
/// Run in watch mode by re-running whenever files change.
|
||||
#[arg(long, short = 'W')]
|
||||
pub(crate) watch: bool,
|
||||
}
|
||||
|
||||
impl CheckCommand {
|
||||
pub(crate) fn into_options(self) -> Options {
|
||||
let rules = if self.rules.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.rules
|
||||
.into_iter()
|
||||
.map(|(rule, level)| (RangedValue::cli(rule), RangedValue::cli(level)))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: self
|
||||
.python_version
|
||||
.map(|version| RangedValue::cli(version.into())),
|
||||
venv_path: self.venv_path.map(RelativePathBuf::cli),
|
||||
typeshed: self.typeshed.map(RelativePathBuf::cli),
|
||||
extra_paths: self.extra_search_path.map(|extra_search_paths| {
|
||||
extra_search_paths
|
||||
.into_iter()
|
||||
.map(RelativePathBuf::cli)
|
||||
.collect()
|
||||
}),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
rules,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of rules to enable or disable with a given severity.
|
||||
///
|
||||
/// This type is used to parse the `--error`, `--warn`, and `--ignore` arguments
|
||||
/// while preserving the order in which they were specified (arguments last override previous severities).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RulesArg(Vec<(String, lint::Level)>);
|
||||
|
||||
impl RulesArg {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
fn into_iter(self) -> impl Iterator<Item = (String, lint::Level)> {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl clap::FromArgMatches for RulesArg {
|
||||
fn from_arg_matches(matches: &ArgMatches) -> Result<Self, Error> {
|
||||
let mut rules = Vec::new();
|
||||
|
||||
for (level, arg_id) in [
|
||||
(lint::Level::Ignore, "ignore"),
|
||||
(lint::Level::Warn, "warn"),
|
||||
(lint::Level::Error, "error"),
|
||||
] {
|
||||
let indices = matches.indices_of(arg_id).into_iter().flatten();
|
||||
let levels = matches.get_many::<String>(arg_id).into_iter().flatten();
|
||||
rules.extend(
|
||||
indices
|
||||
.zip(levels)
|
||||
.map(|(index, rule)| (index, rule, level)),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by their index so that values specified later override earlier ones.
|
||||
rules.sort_by_key(|(index, _, _)| *index);
|
||||
|
||||
Ok(Self(
|
||||
rules
|
||||
.into_iter()
|
||||
.map(|(_, rule, level)| (rule.to_owned(), level))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> {
|
||||
self.0 = Self::from_arg_matches(matches)?.0;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl clap::Args for RulesArg {
|
||||
fn augment_args(cmd: clap::Command) -> clap::Command {
|
||||
const HELP_HEADING: &str = "Enabling / disabling rules";
|
||||
|
||||
cmd.arg(
|
||||
clap::Arg::new("error")
|
||||
.long("error")
|
||||
.action(ArgAction::Append)
|
||||
.help("Treat the given rule as having severity 'error'. Can be specified multiple times.")
|
||||
.value_name("RULE")
|
||||
.help_heading(HELP_HEADING),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("warn")
|
||||
.long("warn")
|
||||
.action(ArgAction::Append)
|
||||
.help("Treat the given rule as having severity 'warn'. Can be specified multiple times.")
|
||||
.value_name("RULE")
|
||||
.help_heading(HELP_HEADING),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("ignore")
|
||||
.long("ignore")
|
||||
.action(ArgAction::Append)
|
||||
.help("Disables the rule. Can be specified multiple times.")
|
||||
.value_name("RULE")
|
||||
.help_heading(HELP_HEADING),
|
||||
)
|
||||
}
|
||||
|
||||
fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
|
||||
Self::augment_args(cmd)
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,29 @@
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::process::{ExitCode, Termination};
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::args::{Args, CheckCommand, Command};
|
||||
use crate::logging::setup_tracing;
|
||||
use anyhow::{anyhow, Context};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use crossbeam::channel as crossbeam_channel;
|
||||
use python_version::PythonVersion;
|
||||
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
|
||||
use red_knot_project::metadata::options::Options;
|
||||
use red_knot_project::watch;
|
||||
use red_knot_project::watch::ProjectWatcher;
|
||||
use red_knot_project::{ProjectDatabase, ProjectMetadata};
|
||||
use red_knot_server::run_server;
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
use ruff_db::diagnostic::{Diagnostic, Severity};
|
||||
use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf};
|
||||
use salsa::plumbing::ZalsaDatabase;
|
||||
|
||||
use crate::logging::{setup_tracing, Verbosity};
|
||||
|
||||
mod args;
|
||||
mod logging;
|
||||
mod python_version;
|
||||
mod verbosity;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author,
|
||||
name = "red-knot",
|
||||
about = "An extremely fast Python type checker."
|
||||
)]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Option<Command>,
|
||||
|
||||
/// Run the command within the given project directory.
|
||||
///
|
||||
/// All `pyproject.toml` files will be discovered by walking up the directory tree from the given project directory,
|
||||
/// as will the project's virtual environment (`.venv`) unless the `venv-path` option is set.
|
||||
///
|
||||
/// Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
project: Option<SystemPathBuf>,
|
||||
|
||||
/// Path to the virtual environment the project uses.
|
||||
///
|
||||
/// If provided, red-knot will use the `site-packages` directory of this virtual environment
|
||||
/// to resolve type information for the project's third-party dependencies.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
venv_path: Option<SystemPathBuf>,
|
||||
|
||||
/// Custom directory to use for stdlib typeshed stubs.
|
||||
#[arg(long, value_name = "PATH", alias = "custom-typeshed-dir")]
|
||||
typeshed: Option<SystemPathBuf>,
|
||||
|
||||
/// Additional path to use as a module-resolution source (can be passed multiple times).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
extra_search_path: Option<Vec<SystemPathBuf>>,
|
||||
|
||||
/// Python version to assume when resolving types.
|
||||
#[arg(long, value_name = "VERSION", alias = "target-version")]
|
||||
python_version: Option<PythonVersion>,
|
||||
|
||||
#[clap(flatten)]
|
||||
verbosity: Verbosity,
|
||||
|
||||
/// Run in watch mode by re-running whenever files change.
|
||||
#[arg(long, short = 'W')]
|
||||
watch: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn to_options(&self, cli_cwd: &SystemPath) -> Options {
|
||||
Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: self.python_version.map(Into::into),
|
||||
venv_path: self
|
||||
.venv_path
|
||||
.as_ref()
|
||||
.map(|venv_path| SystemPath::absolute(venv_path, cli_cwd)),
|
||||
typeshed: self
|
||||
.typeshed
|
||||
.as_ref()
|
||||
.map(|typeshed| SystemPath::absolute(typeshed, cli_cwd)),
|
||||
extra_paths: self.extra_search_path.as_ref().map(|extra_search_paths| {
|
||||
extra_search_paths
|
||||
.iter()
|
||||
.map(|path| SystemPath::absolute(path, cli_cwd))
|
||||
.collect()
|
||||
}),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub enum Command {
|
||||
/// Start the language server
|
||||
Server,
|
||||
}
|
||||
mod version;
|
||||
|
||||
#[allow(clippy::print_stdout, clippy::unnecessary_wraps, clippy::print_stderr)]
|
||||
pub fn main() -> ExitStatus {
|
||||
@@ -125,10 +50,21 @@ pub fn main() -> ExitStatus {
|
||||
fn run() -> anyhow::Result<ExitStatus> {
|
||||
let args = Args::parse_from(std::env::args());
|
||||
|
||||
if matches!(args.command, Some(Command::Server)) {
|
||||
return run_server().map(|()| ExitStatus::Success);
|
||||
match args.command {
|
||||
Command::Server => run_server().map(|()| ExitStatus::Success),
|
||||
Command::Check(check_args) => run_check(check_args),
|
||||
Command::Version => version().map(|()| ExitStatus::Success),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn version() -> Result<()> {
|
||||
let mut stdout = BufWriter::new(io::stdout().lock());
|
||||
let version_info = crate::version::version();
|
||||
writeln!(stdout, "red knot {}", &version_info)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
|
||||
let verbosity = args.verbosity.level();
|
||||
countme::enable(verbosity.is_trace());
|
||||
let _guard = setup_tracing(verbosity)?;
|
||||
@@ -158,14 +94,22 @@ fn run() -> anyhow::Result<ExitStatus> {
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| cli_base_path.clone());
|
||||
|
||||
let system = OsSystem::new(cwd.clone());
|
||||
let cli_options = args.to_options(&cwd);
|
||||
let system = OsSystem::new(cwd);
|
||||
let watch = args.watch;
|
||||
let exit_zero = args.exit_zero;
|
||||
let min_error_severity = if args.error_on_warning {
|
||||
Severity::Warning
|
||||
} else {
|
||||
Severity::Error
|
||||
};
|
||||
|
||||
let cli_options = args.into_options();
|
||||
let mut workspace_metadata = ProjectMetadata::discover(system.current_directory(), &system)?;
|
||||
workspace_metadata.apply_cli_options(cli_options.clone());
|
||||
|
||||
let mut db = ProjectDatabase::new(workspace_metadata, system)?;
|
||||
|
||||
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options);
|
||||
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options, min_error_severity);
|
||||
|
||||
// Listen to Ctrl+C and abort the watch mode.
|
||||
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
|
||||
@@ -177,7 +121,7 @@ fn run() -> anyhow::Result<ExitStatus> {
|
||||
}
|
||||
})?;
|
||||
|
||||
let exit_status = if args.watch {
|
||||
let exit_status = if watch {
|
||||
main_loop.watch(&mut db)?
|
||||
} else {
|
||||
main_loop.run(&mut db)
|
||||
@@ -187,7 +131,11 @@ fn run() -> anyhow::Result<ExitStatus> {
|
||||
|
||||
std::mem::forget(db);
|
||||
|
||||
Ok(exit_status)
|
||||
if exit_zero {
|
||||
Ok(ExitStatus::Success)
|
||||
} else {
|
||||
Ok(exit_status)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
@@ -219,10 +167,18 @@ struct MainLoop {
|
||||
watcher: Option<ProjectWatcher>,
|
||||
|
||||
cli_options: Options,
|
||||
|
||||
/// The minimum severity to consider an error when deciding the exit status.
|
||||
///
|
||||
/// TODO(micha): Get from the terminal settings.
|
||||
min_error_severity: Severity,
|
||||
}
|
||||
|
||||
impl MainLoop {
|
||||
fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) {
|
||||
fn new(
|
||||
cli_options: Options,
|
||||
min_error_severity: Severity,
|
||||
) -> (Self, MainLoopCancellationToken) {
|
||||
let (sender, receiver) = crossbeam_channel::bounded(10);
|
||||
|
||||
(
|
||||
@@ -231,6 +187,7 @@ impl MainLoop {
|
||||
receiver,
|
||||
watcher: None,
|
||||
cli_options,
|
||||
min_error_severity,
|
||||
},
|
||||
MainLoopCancellationToken { sender },
|
||||
)
|
||||
@@ -288,7 +245,10 @@ impl MainLoop {
|
||||
result,
|
||||
revision: check_revision,
|
||||
} => {
|
||||
let has_diagnostics = !result.is_empty();
|
||||
let failed = result
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity() >= self.min_error_severity);
|
||||
|
||||
if check_revision == revision {
|
||||
#[allow(clippy::print_stdout)]
|
||||
for diagnostic in result {
|
||||
@@ -301,7 +261,7 @@ impl MainLoop {
|
||||
}
|
||||
|
||||
if self.watcher.is_none() {
|
||||
return if has_diagnostics {
|
||||
return if failed {
|
||||
ExitStatus::Failure
|
||||
} else {
|
||||
ExitStatus::Success
|
||||
|
||||
105
crates/red_knot/src/version.rs
Normal file
105
crates/red_knot/src/version.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Code for representing Red Knot's release version number.
|
||||
use std::fmt;
|
||||
|
||||
/// Information about the git repository where Red Knot was built from.
|
||||
pub(crate) struct CommitInfo {
|
||||
short_commit_hash: String,
|
||||
commit_date: String,
|
||||
commits_since_last_tag: u32,
|
||||
}
|
||||
|
||||
/// Red Knot's version.
|
||||
pub(crate) struct VersionInfo {
|
||||
/// Red Knot's version, such as "0.5.1"
|
||||
version: String,
|
||||
/// Information about the git commit we may have been built from.
|
||||
///
|
||||
/// `None` if not built from a git repo or if retrieval failed.
|
||||
commit_info: Option<CommitInfo>,
|
||||
}
|
||||
|
||||
impl fmt::Display for VersionInfo {
|
||||
/// Formatted version information: `<version>[+<commits>] (<commit> <date>)`
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.version)?;
|
||||
|
||||
if let Some(ref ci) = self.commit_info {
|
||||
if ci.commits_since_last_tag > 0 {
|
||||
write!(f, "+{}", ci.commits_since_last_tag)?;
|
||||
}
|
||||
write!(f, " ({} {})", ci.short_commit_hash, ci.commit_date)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns information about Red Knot's version.
|
||||
pub(crate) fn version() -> VersionInfo {
|
||||
// Environment variables are only read at compile-time
|
||||
macro_rules! option_env_str {
|
||||
($name:expr) => {
|
||||
option_env!($name).map(|s| s.to_string())
|
||||
};
|
||||
}
|
||||
|
||||
// This version is pulled from Cargo.toml and set by Cargo
|
||||
let version = option_env_str!("CARGO_PKG_VERSION").unwrap();
|
||||
|
||||
// Commit info is pulled from git and set by `build.rs`
|
||||
let commit_info =
|
||||
option_env_str!("RED_KNOT_COMMIT_SHORT_HASH").map(|short_commit_hash| CommitInfo {
|
||||
short_commit_hash,
|
||||
commit_date: option_env_str!("RED_KNOT_COMMIT_DATE").unwrap(),
|
||||
commits_since_last_tag: option_env_str!("RED_KNOT_LAST_TAG_DISTANCE")
|
||||
.as_deref()
|
||||
.map_or(0, |value| value.parse::<u32>().unwrap_or(0)),
|
||||
});
|
||||
|
||||
VersionInfo {
|
||||
version,
|
||||
commit_info,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_snapshot;
|
||||
|
||||
use super::{CommitInfo, VersionInfo};
|
||||
|
||||
#[test]
|
||||
fn version_formatting() {
|
||||
let version = VersionInfo {
|
||||
version: "0.0.0".to_string(),
|
||||
commit_info: None,
|
||||
};
|
||||
assert_snapshot!(version, @"0.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formatting_with_commit_info() {
|
||||
let version = VersionInfo {
|
||||
version: "0.0.0".to_string(),
|
||||
commit_info: Some(CommitInfo {
|
||||
short_commit_hash: "53b0f5d92".to_string(),
|
||||
commit_date: "2023-10-19".to_string(),
|
||||
commits_since_last_tag: 0,
|
||||
}),
|
||||
};
|
||||
assert_snapshot!(version, @"0.0.0 (53b0f5d92 2023-10-19)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formatting_with_commits_since_last_tag() {
|
||||
let version = VersionInfo {
|
||||
version: "0.0.0".to_string(),
|
||||
commit_info: Some(CommitInfo {
|
||||
short_commit_hash: "53b0f5d92".to_string(),
|
||||
commit_date: "2023-10-19".to_string(),
|
||||
commits_since_last_tag: 24,
|
||||
}),
|
||||
};
|
||||
assert_snapshot!(version, @"0.0.0+24 (53b0f5d92 2023-10-19)");
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,769 @@
|
||||
use anyhow::Context;
|
||||
use insta::internals::SettingsBindDropGuard;
|
||||
use insta_cmd::{assert_cmd_snapshot, get_cargo_bin};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Specifying an option on the CLI should take precedence over the same setting in the
|
||||
/// project's configuration.
|
||||
#[test]
|
||||
fn test_config_override() -> anyhow::Result<()> {
|
||||
let tempdir = TempDir::new()?;
|
||||
fn config_override() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([
|
||||
(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.knot.environment]
|
||||
python-version = "3.11"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"test.py",
|
||||
r#"
|
||||
import sys
|
||||
|
||||
std::fs::write(
|
||||
tempdir.path().join("pyproject.toml"),
|
||||
r#"
|
||||
[tool.knot.environment]
|
||||
python-version = "3.11"
|
||||
"#,
|
||||
)
|
||||
.context("Failed to write settings")?;
|
||||
# Access `sys.last_exc` that was only added in Python 3.12
|
||||
print(sys.last_exc)
|
||||
"#,
|
||||
),
|
||||
])?;
|
||||
|
||||
std::fs::write(
|
||||
tempdir.path().join("test.py"),
|
||||
r#"
|
||||
import sys
|
||||
|
||||
# Access `sys.last_exc` that was only added in Python 3.12
|
||||
print(sys.last_exc)
|
||||
"#,
|
||||
)
|
||||
.context("Failed to write test.py")?;
|
||||
|
||||
insta::with_settings!({filters => vec![(&*tempdir_filter(&tempdir), "<temp_dir>/")]}, {
|
||||
assert_cmd_snapshot!(knot().arg("--project").arg(tempdir.path()), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error[lint:unresolved-attribute] <temp_dir>/test.py:5:7 Type `<module 'sys'>` has no attribute `last_exc`
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
});
|
||||
|
||||
assert_cmd_snapshot!(knot().arg("--project").arg(tempdir.path()).arg("--python-version").arg("3.12"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:unresolved-attribute
|
||||
--> <temp_dir>/test.py:5:7
|
||||
|
|
||||
4 | # Access `sys.last_exc` that was only added in Python 3.12
|
||||
5 | print(sys.last_exc)
|
||||
| ^^^^^^^^^^^^ Type `<module 'sys'>` has no attribute `last_exc`
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn knot() -> Command {
|
||||
Command::new(get_cargo_bin("red_knot"))
|
||||
/// Paths specified on the CLI are relative to the current working directory and not the project root.
|
||||
///
|
||||
/// We test this by adding an extra search path from the CLI to the libs directory when
|
||||
/// running the CLI from the child directory (using relative paths).
|
||||
///
|
||||
/// Project layout:
|
||||
/// ```
|
||||
/// - libs
|
||||
/// |- utils.py
|
||||
/// - child
|
||||
/// | - test.py
|
||||
/// - pyproject.toml
|
||||
/// ```
|
||||
///
|
||||
/// And the command is run in the `child` directory.
|
||||
#[test]
|
||||
fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([
|
||||
(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.knot.environment]
|
||||
python-version = "3.11"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"libs/utils.py",
|
||||
r#"
|
||||
def add(a: int, b: int) -> int:
|
||||
a + b
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"child/test.py",
|
||||
r#"
|
||||
from utils import add
|
||||
|
||||
stat = add(10, 15)
|
||||
"#,
|
||||
),
|
||||
])?;
|
||||
|
||||
// Make sure that the CLI fails when the `libs` directory is not in the search path.
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.project_dir().join("child")), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/child/test.py:2:6
|
||||
|
|
||||
2 | from utils import add
|
||||
| ^^^^^ Cannot resolve import `utils`
|
||||
3 |
|
||||
4 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.project_dir().join("child")).arg("--extra-search-path").arg("../libs"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tempdir_filter(tempdir: &TempDir) -> String {
|
||||
format!(r"{}\\?/?", regex::escape(tempdir.path().to_str().unwrap()))
|
||||
/// Paths specified in a configuration file are relative to the project root.
|
||||
///
|
||||
/// We test this by adding `libs` (as a relative path) to the extra search path in the configuration and run
|
||||
/// the CLI from a subdirectory.
|
||||
///
|
||||
/// Project layout:
|
||||
/// ```
|
||||
/// - libs
|
||||
/// |- utils.py
|
||||
/// - child
|
||||
/// | - test.py
|
||||
/// - pyproject.toml
|
||||
/// ```
|
||||
#[test]
|
||||
fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([
|
||||
(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.knot.environment]
|
||||
python-version = "3.11"
|
||||
extra-paths = ["libs"]
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"libs/utils.py",
|
||||
r#"
|
||||
def add(a: int, b: int) -> int:
|
||||
a + b
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"child/test.py",
|
||||
r#"
|
||||
from utils import add
|
||||
|
||||
stat = add(10, 15)
|
||||
"#,
|
||||
),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.project_dir().join("child")), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rule severity can be changed in the configuration file
|
||||
#[test]
|
||||
fn configuration_rule_severity() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
y = 4 / 0
|
||||
|
||||
for a in range(0, y):
|
||||
x = a
|
||||
|
||||
print(x) # possibly-unresolved-reference
|
||||
"#,
|
||||
)?;
|
||||
|
||||
// Assert that there's a possibly unresolved reference diagnostic
|
||||
// and that division-by-zero has a severity of error by default.
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:2:5
|
||||
|
|
||||
2 | y = 4 / 0
|
||||
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
||||
3 |
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
warning: lint:possibly-unresolved-reference
|
||||
--> <temp_dir>/test.py:7:7
|
||||
|
|
||||
5 | x = a
|
||||
6 |
|
||||
7 | print(x) # possibly-unresolved-reference
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
case.write_file(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.knot.rules]
|
||||
division-by-zero = "warn" # demote to warn
|
||||
possibly-unresolved-reference = "ignore"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:2:5
|
||||
|
|
||||
2 | y = 4 / 0
|
||||
| ----- Cannot divide object of type `Literal[4]` by zero
|
||||
3 |
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rule severity can be changed using `--ignore`, `--warn`, and `--error`
|
||||
#[test]
|
||||
fn cli_rule_severity() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
import does_not_exit
|
||||
|
||||
y = 4 / 0
|
||||
|
||||
for a in range(0, y):
|
||||
x = a
|
||||
|
||||
print(x) # possibly-unresolved-reference
|
||||
"#,
|
||||
)?;
|
||||
|
||||
// Assert that there's a possibly unresolved reference diagnostic
|
||||
// and that division-by-zero has a severity of error by default.
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/test.py:2:8
|
||||
|
|
||||
2 | import does_not_exit
|
||||
| ^^^^^^^^^^^^^ Cannot resolve import `does_not_exit`
|
||||
3 |
|
||||
4 | y = 4 / 0
|
||||
|
|
||||
|
||||
error: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:4:5
|
||||
|
|
||||
2 | import does_not_exit
|
||||
3 |
|
||||
4 | y = 4 / 0
|
||||
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
||||
5 |
|
||||
6 | for a in range(0, y):
|
||||
|
|
||||
|
||||
warning: lint:possibly-unresolved-reference
|
||||
--> <temp_dir>/test.py:9:7
|
||||
|
|
||||
7 | x = a
|
||||
8 |
|
||||
9 | print(x) # possibly-unresolved-reference
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case
|
||||
.command()
|
||||
.arg("--ignore")
|
||||
.arg("possibly-unresolved-reference")
|
||||
.arg("--warn")
|
||||
.arg("division-by-zero")
|
||||
.arg("--warn")
|
||||
.arg("unresolved-import"),
|
||||
@r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-import
|
||||
--> <temp_dir>/test.py:2:8
|
||||
|
|
||||
2 | import does_not_exit
|
||||
| ------------- Cannot resolve import `does_not_exit`
|
||||
3 |
|
||||
4 | y = 4 / 0
|
||||
|
|
||||
|
||||
warning: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:4:5
|
||||
|
|
||||
2 | import does_not_exit
|
||||
3 |
|
||||
4 | y = 4 / 0
|
||||
| ----- Cannot divide object of type `Literal[4]` by zero
|
||||
5 |
|
||||
6 | for a in range(0, y):
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rule severity can be changed using `--ignore`, `--warn`, and `--error` and
|
||||
/// values specified last override previous severities.
|
||||
#[test]
|
||||
fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
y = 4 / 0
|
||||
|
||||
for a in range(0, y):
|
||||
x = a
|
||||
|
||||
print(x) # possibly-unresolved-reference
|
||||
"#,
|
||||
)?;
|
||||
|
||||
// Assert that there's a possibly unresolved reference diagnostic
|
||||
// and that division-by-zero has a severity of error by default.
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:2:5
|
||||
|
|
||||
2 | y = 4 / 0
|
||||
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
||||
3 |
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
warning: lint:possibly-unresolved-reference
|
||||
--> <temp_dir>/test.py:7:7
|
||||
|
|
||||
5 | x = a
|
||||
6 |
|
||||
7 | print(x) # possibly-unresolved-reference
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case
|
||||
.command()
|
||||
.arg("--error")
|
||||
.arg("possibly-unresolved-reference")
|
||||
.arg("--warn")
|
||||
.arg("division-by-zero")
|
||||
// Override the error severity with warning
|
||||
.arg("--ignore")
|
||||
.arg("possibly-unresolved-reference"),
|
||||
@r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: lint:division-by-zero
|
||||
--> <temp_dir>/test.py:2:5
|
||||
|
|
||||
2 | y = 4 / 0
|
||||
| ----- Cannot divide object of type `Literal[4]` by zero
|
||||
3 |
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Red Knot warns about unknown rules specified in a configuration file
|
||||
#[test]
|
||||
fn configuration_unknown_rules() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([
|
||||
(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.knot.rules]
|
||||
division-by-zer = "warn" # incorrect rule name
|
||||
"#,
|
||||
),
|
||||
("test.py", "print(10)"),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: unknown-rule
|
||||
--> <temp_dir>/pyproject.toml:3:1
|
||||
|
|
||||
2 | [tool.knot.rules]
|
||||
3 | division-by-zer = "warn" # incorrect rule name
|
||||
| --------------- Unknown lint rule `division-by-zer`
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Red Knot warns about unknown rules specified in a CLI argument
|
||||
#[test]
|
||||
fn cli_unknown_rules() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file("test.py", "print(10)")?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: unknown-rule: Unknown lint rule `division-by-zer`
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_only_warnings() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-reference
|
||||
--> <temp_dir>/test.py:1:7
|
||||
|
|
||||
1 | print(x) # [unresolved-reference]
|
||||
| - Name `x` used when not defined
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_only_info() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
from typing_extensions import reveal_type
|
||||
reveal_type(1)
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
info: revealed-type
|
||||
--> <temp_dir>/test.py:3:1
|
||||
|
|
||||
2 | from typing_extensions import reveal_type
|
||||
3 | reveal_type(1)
|
||||
| -------------- info: Revealed type is `Literal[1]`
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_only_info_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
from typing_extensions import reveal_type
|
||||
reveal_type(1)
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
info: revealed-type
|
||||
--> <temp_dir>/test.py:3:1
|
||||
|
|
||||
2 | from typing_extensions import reveal_type
|
||||
3 | reveal_type(1)
|
||||
| -------------- info: Revealed type is `Literal[1]`
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-reference
|
||||
--> <temp_dir>/test.py:1:7
|
||||
|
|
||||
1 | print(x) # [unresolved-reference]
|
||||
| - Name `x` used when not defined
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
print(x) # [unresolved-reference]
|
||||
print(4[1]) # [non-subscriptable]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-reference
|
||||
--> <temp_dir>/test.py:2:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
| - Name `x` used when not defined
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
|
|
||||
|
||||
error: lint:non-subscriptable
|
||||
--> <temp_dir>/test.py:3:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r###"
|
||||
print(x) # [unresolved-reference]
|
||||
print(4[1]) # [non-subscriptable]
|
||||
"###,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-reference
|
||||
--> <temp_dir>/test.py:2:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
| - Name `x` used when not defined
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
|
|
||||
|
||||
error: lint:non-subscriptable
|
||||
--> <temp_dir>/test.py:3:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file(
|
||||
"test.py",
|
||||
r#"
|
||||
print(x) # [unresolved-reference]
|
||||
print(4[1]) # [non-subscriptable]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--exit-zero"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: lint:unresolved-reference
|
||||
--> <temp_dir>/test.py:2:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
| - Name `x` used when not defined
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
|
|
||||
|
||||
error: lint:non-subscriptable
|
||||
--> <temp_dir>/test.py:3:7
|
||||
|
|
||||
2 | print(x) # [unresolved-reference]
|
||||
3 | print(4[1]) # [non-subscriptable]
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct TestCase {
|
||||
_temp_dir: TempDir,
|
||||
_settings_scope: SettingsBindDropGuard,
|
||||
project_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TestCase {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Canonicalize the tempdir path because macos uses symlinks for tempdirs
|
||||
// and that doesn't play well with our snapshot filtering.
|
||||
let project_dir = temp_dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.context("Failed to canonicalize project path")?;
|
||||
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.add_filter(&tempdir_filter(&project_dir), "<temp_dir>/");
|
||||
settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1");
|
||||
|
||||
let settings_scope = settings.bind_to_scope();
|
||||
|
||||
Ok(Self {
|
||||
project_dir,
|
||||
_temp_dir: temp_dir,
|
||||
_settings_scope: settings_scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> anyhow::Result<Self> {
|
||||
let case = Self::new()?;
|
||||
case.write_files(files)?;
|
||||
Ok(case)
|
||||
}
|
||||
|
||||
fn with_file(path: impl AsRef<Path>, content: &str) -> anyhow::Result<Self> {
|
||||
let case = Self::new()?;
|
||||
case.write_file(path, content)?;
|
||||
Ok(case)
|
||||
}
|
||||
|
||||
fn write_files<'a>(
|
||||
&self,
|
||||
files: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
) -> anyhow::Result<()> {
|
||||
for (path, content) in files {
|
||||
self.write_file(path, content)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file(&self, path: impl AsRef<Path>, content: &str) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
let path = self.project_dir.join(path);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory `{}`", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, &*ruff_python_trivia::textwrap::dedent(content))
|
||||
.with_context(|| format!("Failed to write file `{path}`", path = path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_dir(&self) -> &Path {
|
||||
&self.project_dir
|
||||
}
|
||||
|
||||
fn command(&self) -> Command {
|
||||
let mut command = Command::new(get_cargo_bin("red_knot"));
|
||||
command.current_dir(&self.project_dir).arg("check");
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
fn tempdir_filter(path: &Path) -> String {
|
||||
format!(r"{}\\?/?", regex::escape(path.to_str().unwrap()))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::{anyhow, Context};
|
||||
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
|
||||
use red_knot_project::metadata::pyproject::{PyProject, Tool};
|
||||
use red_knot_project::metadata::value::{RangedValue, RelativePathBuf};
|
||||
use red_knot_project::watch::{directory_watcher, ChangeEvent, ProjectWatcher};
|
||||
use red_knot_project::{Db, ProjectDatabase, ProjectMetadata};
|
||||
use red_knot_python_semantic::{resolve_module, ModuleName, PythonPlatform, PythonVersion};
|
||||
@@ -46,7 +47,7 @@ impl TestCase {
|
||||
#[track_caller]
|
||||
fn panic_with_formatted_events(events: Vec<ChangeEvent>) -> Vec<ChangeEvent> {
|
||||
panic!(
|
||||
"Didn't observe expected change:\n{}",
|
||||
"Didn't observe the expected event. The following events occurred:\n{}",
|
||||
events
|
||||
.into_iter()
|
||||
.map(|event| format!(" - {event:?}"))
|
||||
@@ -321,7 +322,7 @@ where
|
||||
.search_paths
|
||||
.extra_paths
|
||||
.iter()
|
||||
.chain(program_settings.search_paths.typeshed.as_ref())
|
||||
.chain(program_settings.search_paths.custom_typeshed.as_ref())
|
||||
{
|
||||
std::fs::create_dir_all(path.as_std_path())
|
||||
.with_context(|| format!("Failed to create search path `{path}`"))?;
|
||||
@@ -791,7 +792,7 @@ fn search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup_with_options([("bar.py", "import sub.a")], |root_path, _project_path| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![root_path.join("site_packages")]),
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(root_path.join("site_packages"))]),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -832,7 +833,7 @@ fn add_search_path() -> anyhow::Result<()> {
|
||||
// Register site-packages as a search path.
|
||||
case.update_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![site_packages.clone()]),
|
||||
extra_paths: Some(vec![RelativePathBuf::cli("site_packages")]),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -855,7 +856,7 @@ fn remove_search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup_with_options([("bar.py", "import sub.a")], |root_path, _project_path| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![root_path.join("site_packages")]),
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(root_path.join("site_packages"))]),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -896,8 +897,10 @@ print(sys.last_exc, os.getegid())
|
||||
|_root_path, _project_path| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: Some(PythonVersion::PY311),
|
||||
python_platform: Some(PythonPlatform::Identifier("win32".to_string())),
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY311)),
|
||||
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
||||
"win32".to_string(),
|
||||
))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -920,8 +923,10 @@ print(sys.last_exc, os.getegid())
|
||||
// Change the python version
|
||||
case.update_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: Some(PythonVersion::PY312),
|
||||
python_platform: Some(PythonPlatform::Identifier("linux".to_string())),
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
||||
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
||||
"linux".to_string(),
|
||||
))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -951,7 +956,7 @@ fn changed_versions_file() -> anyhow::Result<()> {
|
||||
|root_path, _project_path| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
typeshed: Some(root_path.join("typeshed")),
|
||||
typeshed: Some(RelativePathBuf::cli(root_path.join("typeshed"))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
@@ -1375,11 +1380,13 @@ mod unix {
|
||||
|
||||
Ok(())
|
||||
},
|
||||
|_root, project| {
|
||||
|_root, _project| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![project.join(".venv/lib/python3.12/site-packages")]),
|
||||
python_version: Some(PythonVersion::PY312),
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
".venv/lib/python3.12/site-packages",
|
||||
)]),
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::panic::RefUnwindSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::DEFAULT_LINT_REGISTRY;
|
||||
use crate::{check_file, Project, ProjectMetadata};
|
||||
use crate::{Project, ProjectMetadata};
|
||||
use red_knot_python_semantic::lint::{LintRegistry, RuleSelection};
|
||||
use red_knot_python_semantic::{Db as SemanticDb, Program};
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
@@ -27,7 +27,6 @@ pub struct ProjectDatabase {
|
||||
storage: salsa::Storage<ProjectDatabase>,
|
||||
files: Files,
|
||||
system: Arc<dyn System + Send + Sync + RefUnwindSafe>,
|
||||
rule_selection: Arc<RuleSelection>,
|
||||
}
|
||||
|
||||
impl ProjectDatabase {
|
||||
@@ -35,14 +34,11 @@ impl ProjectDatabase {
|
||||
where
|
||||
S: System + 'static + Send + Sync + RefUnwindSafe,
|
||||
{
|
||||
let rule_selection = RuleSelection::from_registry(&DEFAULT_LINT_REGISTRY);
|
||||
|
||||
let mut db = Self {
|
||||
project: None,
|
||||
storage: salsa::Storage::default(),
|
||||
files: Files::default(),
|
||||
system: Arc::new(system),
|
||||
rule_selection: Arc::new(rule_selection),
|
||||
};
|
||||
|
||||
// TODO: Use the `program_settings` to compute the key for the database's persistent
|
||||
@@ -66,7 +62,7 @@ impl ProjectDatabase {
|
||||
pub fn check_file(&self, file: File) -> Result<Vec<Box<dyn Diagnostic>>, Cancelled> {
|
||||
let _span = tracing::debug_span!("check_file", file=%file.path(self)).entered();
|
||||
|
||||
self.with_db(|db| check_file(db, file))
|
||||
self.with_db(|db| self.project().check_file(db, file))
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the system.
|
||||
@@ -119,7 +115,7 @@ impl SemanticDb for ProjectDatabase {
|
||||
}
|
||||
|
||||
fn rule_selection(&self) -> &RuleSelection {
|
||||
&self.rule_selection
|
||||
self.project().rule_selection(self)
|
||||
}
|
||||
|
||||
fn lint_registry(&self) -> &LintRegistry {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#![allow(clippy::ref_option)]
|
||||
|
||||
use red_knot_python_semantic::lint::{LintRegistry, LintRegistryBuilder};
|
||||
use crate::metadata::options::OptionDiagnostic;
|
||||
pub use db::{Db, ProjectDatabase};
|
||||
use files::{Index, Indexed, IndexedFiles};
|
||||
pub use metadata::{ProjectDiscoveryError, ProjectMetadata};
|
||||
use red_knot_python_semantic::lint::{LintRegistry, LintRegistryBuilder, RuleSelection};
|
||||
use red_knot_python_semantic::register_lints;
|
||||
use red_knot_python_semantic::types::check_types;
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, ParseDiagnostic, Severity};
|
||||
@@ -17,10 +21,6 @@ use salsa::Setter;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use db::{Db, ProjectDatabase};
|
||||
use files::{Index, Indexed, IndexedFiles};
|
||||
pub use metadata::{ProjectDiscoveryError, ProjectMetadata};
|
||||
|
||||
pub mod combine;
|
||||
|
||||
mod db;
|
||||
@@ -68,6 +68,7 @@ pub struct Project {
|
||||
pub metadata: ProjectMetadata,
|
||||
}
|
||||
|
||||
#[salsa::tracked]
|
||||
impl Project {
|
||||
pub fn from_metadata(db: &dyn Db, metadata: ProjectMetadata) -> Self {
|
||||
Project::builder(metadata)
|
||||
@@ -96,13 +97,34 @@ impl Project {
|
||||
self.reload_files(db);
|
||||
}
|
||||
|
||||
pub fn rule_selection(self, db: &dyn Db) -> &RuleSelection {
|
||||
let (selection, _) = self.rule_selection_with_diagnostics(db);
|
||||
selection
|
||||
}
|
||||
|
||||
#[salsa::tracked(return_ref)]
|
||||
fn rule_selection_with_diagnostics(
|
||||
self,
|
||||
db: &dyn Db,
|
||||
) -> (RuleSelection, Vec<OptionDiagnostic>) {
|
||||
self.metadata(db).options().to_rule_selection(db)
|
||||
}
|
||||
|
||||
/// Checks all open files in the project and its dependencies.
|
||||
pub fn check(self, db: &ProjectDatabase) -> Vec<Box<dyn Diagnostic>> {
|
||||
pub(crate) fn check(self, db: &ProjectDatabase) -> Vec<Box<dyn Diagnostic>> {
|
||||
let project_span = tracing::debug_span!("Project::check");
|
||||
let _span = project_span.enter();
|
||||
|
||||
tracing::debug!("Checking project '{name}'", name = self.name(db));
|
||||
let result = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let mut diagnostics: Vec<Box<dyn Diagnostic>> = Vec::new();
|
||||
let (_, options_diagnostics) = self.rule_selection_with_diagnostics(db);
|
||||
diagnostics.extend(options_diagnostics.iter().map(|diagnostic| {
|
||||
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
|
||||
diagnostic
|
||||
}));
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(diagnostics));
|
||||
let inner_result = Arc::clone(&result);
|
||||
|
||||
let db = db.clone();
|
||||
@@ -119,7 +141,7 @@ impl Project {
|
||||
let check_file_span = tracing::debug_span!(parent: &project_span, "check_file", file=%file.path(&db));
|
||||
let _entered = check_file_span.entered();
|
||||
|
||||
let file_diagnostics = check_file(&db, file);
|
||||
let file_diagnostics = check_file_impl(&db, file);
|
||||
result.lock().unwrap().extend(file_diagnostics);
|
||||
});
|
||||
}
|
||||
@@ -128,6 +150,23 @@ impl Project {
|
||||
Arc::into_inner(result).unwrap().into_inner().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn check_file(self, db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
|
||||
let (_, options_diagnostics) = self.rule_selection_with_diagnostics(db);
|
||||
|
||||
let mut file_diagnostics: Vec<_> = options_diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
|
||||
diagnostic
|
||||
})
|
||||
.collect();
|
||||
|
||||
let check_diagnostics = check_file_impl(db, file);
|
||||
file_diagnostics.extend(check_diagnostics);
|
||||
|
||||
file_diagnostics
|
||||
}
|
||||
|
||||
/// Opens a file in the project.
|
||||
///
|
||||
/// This changes the behavior of `check` to only check the open files rather than all files in the project.
|
||||
@@ -265,8 +304,9 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn check_file(db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
|
||||
fn check_file_impl(db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
|
||||
let mut diagnostics: Vec<Box<dyn Diagnostic>> = Vec::new();
|
||||
|
||||
// Abort checking if there are IO errors.
|
||||
let source = source_text(db.upcast(), file);
|
||||
|
||||
@@ -402,8 +442,8 @@ impl Diagnostic for IOErrorDiagnostic {
|
||||
self.error.to_string().into()
|
||||
}
|
||||
|
||||
fn file(&self) -> File {
|
||||
self.file
|
||||
fn file(&self) -> Option<File> {
|
||||
Some(self.file)
|
||||
}
|
||||
|
||||
fn range(&self) -> Option<TextRange> {
|
||||
@@ -418,7 +458,7 @@ impl Diagnostic for IOErrorDiagnostic {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::tests::TestDb;
|
||||
use crate::{check_file, ProjectMetadata};
|
||||
use crate::{check_file_impl, ProjectMetadata};
|
||||
use red_knot_python_semantic::types::check_types;
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
use ruff_db::files::system_path_to_file;
|
||||
@@ -442,7 +482,7 @@ mod tests {
|
||||
|
||||
assert_eq!(source_text(&db, file).as_str(), "");
|
||||
assert_eq!(
|
||||
check_file(&db, file)
|
||||
check_file_impl(&db, file)
|
||||
.into_iter()
|
||||
.map(|diagnostic| diagnostic.message().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -458,7 +498,7 @@ mod tests {
|
||||
|
||||
assert_eq!(source_text(&db, file).as_str(), "");
|
||||
assert_eq!(
|
||||
check_file(&db, file)
|
||||
check_file_impl(&db, file)
|
||||
.into_iter()
|
||||
.map(|diagnostic| diagnostic.message().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use red_knot_python_semantic::ProgramSettings;
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
||||
use ruff_python_ast::name::Name;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::combine::Combine;
|
||||
use crate::metadata::pyproject::{Project, PyProject, PyProjectError};
|
||||
use crate::metadata::value::ValueSource;
|
||||
use options::KnotTomlError;
|
||||
use options::Options;
|
||||
|
||||
pub mod options;
|
||||
pub mod pyproject;
|
||||
pub mod value;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(test, derive(serde::Serialize))]
|
||||
@@ -52,7 +55,7 @@ impl ProjectMetadata {
|
||||
) -> Self {
|
||||
let name = project
|
||||
.and_then(|project| project.name.as_ref())
|
||||
.map(|name| Name::new(&**name))
|
||||
.map(|name| Name::new(&***name))
|
||||
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")));
|
||||
|
||||
// TODO(https://github.com/astral-sh/ruff/issues/15491): Respect requires-python
|
||||
@@ -87,7 +90,10 @@ impl ProjectMetadata {
|
||||
let pyproject_path = project_root.join("pyproject.toml");
|
||||
|
||||
let pyproject = if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
|
||||
match PyProject::from_toml_str(&pyproject_str) {
|
||||
match PyProject::from_toml_str(
|
||||
&pyproject_str,
|
||||
ValueSource::File(Arc::new(pyproject_path.clone())),
|
||||
) {
|
||||
Ok(pyproject) => Some(pyproject),
|
||||
Err(error) => {
|
||||
return Err(ProjectDiscoveryError::InvalidPyProject {
|
||||
@@ -103,7 +109,10 @@ impl ProjectMetadata {
|
||||
// A `knot.toml` takes precedence over a `pyproject.toml`.
|
||||
let knot_toml_path = project_root.join("knot.toml");
|
||||
if let Ok(knot_str) = system.read_to_string(&knot_toml_path) {
|
||||
let options = match Options::from_toml_str(&knot_str) {
|
||||
let options = match Options::from_toml_str(
|
||||
&knot_str,
|
||||
ValueSource::File(Arc::new(knot_toml_path.clone())),
|
||||
) {
|
||||
Ok(options) => options,
|
||||
Err(error) => {
|
||||
return Err(ProjectDiscoveryError::InvalidKnotToml {
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
use crate::metadata::value::{RangedValue, RelativePathBuf, ValueSource, ValueSourceGuard};
|
||||
use crate::Db;
|
||||
use red_knot_python_semantic::lint::{GetLintError, Level, LintSource, RuleSelection};
|
||||
use red_knot_python_semantic::{
|
||||
ProgramSettings, PythonPlatform, PythonVersion, SearchPathSettings, SitePackages,
|
||||
};
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity};
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::system::{System, SystemPath};
|
||||
use ruff_macros::Combine;
|
||||
use ruff_text_size::TextRange;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Debug;
|
||||
use thiserror::Error;
|
||||
|
||||
/// The options for the project.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct Options {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub environment: Option<EnvironmentOptions>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub src: Option<SrcOptions>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rules: Option<Rules>,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub(crate) fn from_toml_str(content: &str) -> Result<Self, KnotTomlError> {
|
||||
pub(crate) fn from_toml_str(content: &str, source: ValueSource) -> Result<Self, KnotTomlError> {
|
||||
let _guard = ValueSourceGuard::new(source);
|
||||
let options = toml::from_str(content)?;
|
||||
Ok(options)
|
||||
}
|
||||
@@ -29,7 +44,12 @@ impl Options {
|
||||
let (python_version, python_platform) = self
|
||||
.environment
|
||||
.as_ref()
|
||||
.map(|env| (env.python_version, env.python_platform.as_ref()))
|
||||
.map(|env| {
|
||||
(
|
||||
env.python_version.as_deref().copied(),
|
||||
env.python_platform.as_deref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
ProgramSettings {
|
||||
@@ -44,19 +64,19 @@ impl Options {
|
||||
project_root: &SystemPath,
|
||||
system: &dyn System,
|
||||
) -> SearchPathSettings {
|
||||
let src_roots =
|
||||
if let Some(src_root) = self.src.as_ref().and_then(|src| src.root.as_deref()) {
|
||||
vec![src_root.to_path_buf()]
|
||||
} else {
|
||||
let src = project_root.join("src");
|
||||
let src_roots = if let Some(src_root) = self.src.as_ref().and_then(|src| src.root.as_ref())
|
||||
{
|
||||
vec![src_root.absolute(project_root, system)]
|
||||
} else {
|
||||
let src = project_root.join("src");
|
||||
|
||||
// Default to `src` and the project root if `src` exists and the root hasn't been specified.
|
||||
if system.is_directory(&src) {
|
||||
vec![project_root.to_path_buf(), src]
|
||||
} else {
|
||||
vec![project_root.to_path_buf()]
|
||||
}
|
||||
};
|
||||
// Default to `src` and the project root if `src` exists and the root hasn't been specified.
|
||||
if system.is_directory(&src) {
|
||||
vec![project_root.to_path_buf(), src]
|
||||
} else {
|
||||
vec![project_root.to_path_buf()]
|
||||
}
|
||||
};
|
||||
|
||||
let (extra_paths, python, typeshed) = self
|
||||
.environment
|
||||
@@ -71,43 +91,139 @@ impl Options {
|
||||
.unwrap_or_default();
|
||||
|
||||
SearchPathSettings {
|
||||
extra_paths: extra_paths.unwrap_or_default(),
|
||||
extra_paths: extra_paths
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| path.absolute(project_root, system))
|
||||
.collect(),
|
||||
src_roots,
|
||||
typeshed,
|
||||
custom_typeshed: typeshed.map(|path| path.absolute(project_root, system)),
|
||||
site_packages: python
|
||||
.map(|venv_path| SitePackages::Derived { venv_path })
|
||||
.map(|venv_path| SitePackages::Derived {
|
||||
venv_path: venv_path.absolute(project_root, system),
|
||||
})
|
||||
.unwrap_or(SitePackages::Known(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn to_rule_selection(&self, db: &dyn Db) -> (RuleSelection, Vec<OptionDiagnostic>) {
|
||||
let registry = db.lint_registry();
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
// Initialize the selection with the defaults
|
||||
let mut selection = RuleSelection::from_registry(registry);
|
||||
|
||||
let rules = self
|
||||
.rules
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|rules| rules.inner.iter());
|
||||
|
||||
for (rule_name, level) in rules {
|
||||
let source = rule_name.source();
|
||||
match registry.get(rule_name) {
|
||||
Ok(lint) => {
|
||||
let lint_source = match source {
|
||||
ValueSource::File(_) => LintSource::File,
|
||||
ValueSource::Cli => LintSource::Cli,
|
||||
};
|
||||
if let Ok(severity) = Severity::try_from(**level) {
|
||||
selection.enable(lint, severity, lint_source);
|
||||
} else {
|
||||
selection.disable(lint);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// `system_path_to_file` can return `Err` if the file was deleted since the configuration
|
||||
// was read. This should be rare and it should be okay to default to not showing a configuration
|
||||
// file in that case.
|
||||
let file = source
|
||||
.file()
|
||||
.and_then(|path| system_path_to_file(db.upcast(), path).ok());
|
||||
|
||||
// TODO: Add a note if the value was configured on the CLI
|
||||
let diagnostic = match error {
|
||||
GetLintError::Unknown(_) => OptionDiagnostic::new(
|
||||
DiagnosticId::UnknownRule,
|
||||
format!("Unknown lint rule `{rule_name}`"),
|
||||
Severity::Warning,
|
||||
),
|
||||
GetLintError::PrefixedWithCategory { suggestion, .. } => {
|
||||
OptionDiagnostic::new(
|
||||
DiagnosticId::UnknownRule,
|
||||
format!(
|
||||
"Unknown lint rule `{rule_name}`. Did you mean `{suggestion}`?"
|
||||
),
|
||||
Severity::Warning,
|
||||
)
|
||||
}
|
||||
|
||||
GetLintError::Removed(_) => OptionDiagnostic::new(
|
||||
DiagnosticId::UnknownRule,
|
||||
format!("Unknown lint rule `{rule_name}`"),
|
||||
Severity::Warning,
|
||||
),
|
||||
};
|
||||
|
||||
diagnostics.push(diagnostic.with_file(file).with_range(rule_name.range()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(selection, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct EnvironmentOptions {
|
||||
pub python_version: Option<PythonVersion>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_version: Option<RangedValue<PythonVersion>>,
|
||||
|
||||
pub python_platform: Option<PythonPlatform>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_platform: Option<RangedValue<PythonPlatform>>,
|
||||
|
||||
/// List of user-provided paths that should take first priority in the module resolution.
|
||||
/// Examples in other type checkers are mypy's MYPYPATH environment variable,
|
||||
/// or pyright's stubPath configuration setting.
|
||||
pub extra_paths: Option<Vec<SystemPathBuf>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_paths: Option<Vec<RelativePathBuf>>,
|
||||
|
||||
/// Optional path to a "typeshed" directory on disk for us to use for standard-library types.
|
||||
/// If this is not provided, we will fallback to our vendored typeshed stubs for the stdlib,
|
||||
/// bundled as a zip file in the binary
|
||||
pub typeshed: Option<SystemPathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub typeshed: Option<RelativePathBuf>,
|
||||
|
||||
// TODO: Rename to python, see https://github.com/astral-sh/ruff/issues/15530
|
||||
/// The path to the user's `site-packages` directory, where third-party packages from ``PyPI`` are installed.
|
||||
pub venv_path: Option<SystemPathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub venv_path: Option<RelativePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct SrcOptions {
|
||||
/// The root of the project, used for finding first-party modules.
|
||||
pub root: Option<SystemPathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root: Option<RelativePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", transparent)]
|
||||
pub struct Rules {
|
||||
inner: FxHashMap<RangedValue<String>, RangedValue<Level>>,
|
||||
}
|
||||
|
||||
impl FromIterator<(RangedValue<String>, RangedValue<Level>)> for Rules {
|
||||
fn from_iter<T: IntoIterator<Item = (RangedValue<String>, RangedValue<Level>)>>(
|
||||
iter: T,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: iter.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -115,3 +231,58 @@ pub enum KnotTomlError {
|
||||
#[error(transparent)]
|
||||
TomlSyntax(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct OptionDiagnostic {
|
||||
id: DiagnosticId,
|
||||
message: String,
|
||||
severity: Severity,
|
||||
file: Option<File>,
|
||||
range: Option<TextRange>,
|
||||
}
|
||||
|
||||
impl OptionDiagnostic {
|
||||
pub fn new(id: DiagnosticId, message: String, severity: Severity) -> Self {
|
||||
Self {
|
||||
id,
|
||||
message,
|
||||
severity,
|
||||
file: None,
|
||||
range: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn with_file(mut self, file: Option<File>) -> Self {
|
||||
self.file = file;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn with_range(mut self, range: Option<TextRange>) -> Self {
|
||||
self.range = range;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Diagnostic for OptionDiagnostic {
|
||||
fn id(&self) -> DiagnosticId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn message(&self) -> Cow<str> {
|
||||
Cow::Borrowed(&self.message)
|
||||
}
|
||||
|
||||
fn file(&self) -> Option<File> {
|
||||
self.file
|
||||
}
|
||||
|
||||
fn range(&self) -> Option<TextRange> {
|
||||
self.range
|
||||
}
|
||||
|
||||
fn severity(&self) -> Severity {
|
||||
self.severity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::ops::Deref;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::metadata::options::Options;
|
||||
use crate::metadata::value::{RangedValue, ValueSource, ValueSourceGuard};
|
||||
|
||||
/// A `pyproject.toml` as specified in PEP 517.
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
@@ -28,7 +29,11 @@ pub enum PyProjectError {
|
||||
}
|
||||
|
||||
impl PyProject {
|
||||
pub(crate) fn from_toml_str(content: &str) -> Result<Self, PyProjectError> {
|
||||
pub(crate) fn from_toml_str(
|
||||
content: &str,
|
||||
source: ValueSource,
|
||||
) -> Result<Self, PyProjectError> {
|
||||
let _guard = ValueSourceGuard::new(source);
|
||||
toml::from_str(content).map_err(PyProjectError::TomlSyntax)
|
||||
}
|
||||
}
|
||||
@@ -43,11 +48,11 @@ pub struct Project {
|
||||
///
|
||||
/// Note: Intentionally option to be more permissive during deserialization.
|
||||
/// `PackageMetadata::from_pyproject` reports missing names.
|
||||
pub name: Option<PackageName>,
|
||||
pub name: Option<RangedValue<PackageName>>,
|
||||
/// The version of the project
|
||||
pub version: Option<Version>,
|
||||
pub version: Option<RangedValue<Version>>,
|
||||
/// The Python versions this project is compatible with.
|
||||
pub requires_python: Option<VersionSpecifiers>,
|
||||
pub requires_python: Option<RangedValue<VersionSpecifiers>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
337
crates/red_knot_project/src/metadata/value.rs
Normal file
337
crates/red_knot_project/src/metadata/value.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use crate::combine::Combine;
|
||||
use crate::Db;
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
||||
use ruff_text_size::{TextRange, TextSize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
use toml::Spanned;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ValueSource {
|
||||
/// Value loaded from a project's configuration file.
|
||||
///
|
||||
/// Ideally, we'd use [`ruff_db::files::File`] but we can't because the database hasn't been
|
||||
/// created when loading the configuration.
|
||||
File(Arc<SystemPathBuf>),
|
||||
/// The value comes from a CLI argument, while it's left open if specified using a short argument,
|
||||
/// long argument (`--extra-paths`) or `--config key=value`.
|
||||
Cli,
|
||||
}
|
||||
|
||||
impl ValueSource {
|
||||
pub fn file(&self) -> Option<&SystemPath> {
|
||||
match self {
|
||||
ValueSource::File(path) => Some(&**path),
|
||||
ValueSource::Cli => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Serde doesn't provide any easy means to pass a value to a [`Deserialize`] implementation,
|
||||
/// but we want to associate each deserialized [`RelativePath`] with the source from
|
||||
/// which it originated. We use a thread local variable to work around this limitation.
|
||||
///
|
||||
/// Use the [`ValueSourceGuard`] to initialize the thread local before calling into any
|
||||
/// deserialization code. It ensures that the thread local variable gets cleaned up
|
||||
/// once deserialization is done (once the guard gets dropped).
|
||||
static VALUE_SOURCE: RefCell<Option<ValueSource>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Guard to safely change the [`VALUE_SOURCE`] for the current thread.
|
||||
#[must_use]
|
||||
pub(super) struct ValueSourceGuard {
|
||||
prev_value: Option<ValueSource>,
|
||||
}
|
||||
|
||||
impl ValueSourceGuard {
|
||||
pub(super) fn new(source: ValueSource) -> Self {
|
||||
let prev = VALUE_SOURCE.replace(Some(source));
|
||||
Self { prev_value: prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ValueSourceGuard {
|
||||
fn drop(&mut self) {
|
||||
VALUE_SOURCE.set(self.prev_value.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that "remembers" where it comes from (source) and its range in source.
|
||||
///
|
||||
/// ## Equality, Hash, and Ordering
|
||||
/// The equality, hash, and ordering are solely based on the value. They disregard the value's range
|
||||
/// or source.
|
||||
///
|
||||
/// This ensures that two resolved configurations are identical even if the position of a value has changed
|
||||
/// or if the values were loaded from different sources.
|
||||
#[derive(Clone)]
|
||||
pub struct RangedValue<T> {
|
||||
value: T,
|
||||
source: ValueSource,
|
||||
|
||||
/// The byte range of `value` in `source`.
|
||||
///
|
||||
/// Can be `None` because not all sources support a range.
|
||||
/// For example, arguments provided on the CLI won't have a range attached.
|
||||
range: Option<TextRange>,
|
||||
}
|
||||
|
||||
impl<T> RangedValue<T> {
|
||||
pub fn new(value: T, source: ValueSource) -> Self {
|
||||
Self::with_range(value, source, TextRange::default())
|
||||
}
|
||||
|
||||
pub fn cli(value: T) -> Self {
|
||||
Self::with_range(value, ValueSource::Cli, TextRange::default())
|
||||
}
|
||||
|
||||
pub fn with_range(value: T, source: ValueSource, range: TextRange) -> Self {
|
||||
Self {
|
||||
value,
|
||||
range: Some(range),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Option<TextRange> {
|
||||
self.range
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &ValueSource {
|
||||
&self.source
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: ValueSource) -> Self {
|
||||
self.source = source;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> T {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Combine for RangedValue<T> {
|
||||
fn combine(self, _other: Self) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self
|
||||
}
|
||||
fn combine_with(&mut self, _other: Self) {}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for RangedValue<T>
|
||||
where
|
||||
T: IntoIterator,
|
||||
{
|
||||
type Item = T::Item;
|
||||
type IntoIter = T::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.value.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
// The type already has an `iter` method thanks to `Deref`.
|
||||
#[allow(clippy::into_iter_without_iter)]
|
||||
impl<'a, T> IntoIterator for &'a RangedValue<T>
|
||||
where
|
||||
&'a T: IntoIterator,
|
||||
{
|
||||
type Item = <&'a T as IntoIterator>::Item;
|
||||
type IntoIter = <&'a T as IntoIterator>::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.value.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
// The type already has a `into_iter_mut` method thanks to `DerefMut`.
|
||||
#[allow(clippy::into_iter_without_iter)]
|
||||
impl<'a, T> IntoIterator for &'a mut RangedValue<T>
|
||||
where
|
||||
&'a mut T: IntoIterator,
|
||||
{
|
||||
type Item = <&'a mut T as IntoIterator>::Item;
|
||||
type IntoIter = <&'a mut T as IntoIterator>::IntoIter;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.value.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for RangedValue<T>
|
||||
where
|
||||
T: fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.value.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Display for RangedValue<T>
|
||||
where
|
||||
T: fmt::Display,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.value.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for RangedValue<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for RangedValue<T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
&mut self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, U: ?Sized> AsRef<U> for RangedValue<T>
|
||||
where
|
||||
T: AsRef<U>,
|
||||
{
|
||||
fn as_ref(&self) -> &U {
|
||||
self.value.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for RangedValue<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.value.eq(&other.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq<T>> PartialEq<T> for RangedValue<T> {
|
||||
fn eq(&self, other: &T) -> bool {
|
||||
self.value.eq(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Eq> Eq for RangedValue<T> {}
|
||||
|
||||
impl<T: Hash> Hash for RangedValue<T> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.value.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialOrd> PartialOrd for RangedValue<T> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.value.partial_cmp(&other.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialOrd<T>> PartialOrd<T> for RangedValue<T> {
|
||||
fn partial_cmp(&self, other: &T) -> Option<Ordering> {
|
||||
self.value.partial_cmp(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord> Ord for RangedValue<T> {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.value.cmp(&other.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for RangedValue<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let spanned: Spanned<T> = Spanned::deserialize(deserializer)?;
|
||||
let span = spanned.span();
|
||||
let range = TextRange::new(
|
||||
TextSize::try_from(span.start).expect("Configuration file to be smaller than 4GB"),
|
||||
TextSize::try_from(span.end).expect("Configuration file to be smaller than 4GB"),
|
||||
);
|
||||
|
||||
Ok(VALUE_SOURCE.with_borrow(|source| {
|
||||
let source = source.clone().unwrap();
|
||||
|
||||
Self::with_range(spanned.into_inner(), source, range)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Serialize for RangedValue<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.value.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A possibly relative path in a configuration file.
|
||||
///
|
||||
/// Relative paths in configuration files or from CLI options
|
||||
/// require different anchoring:
|
||||
///
|
||||
/// * CLI: The path is relative to the current working directory
|
||||
/// * Configuration file: The path is relative to the project's root.
|
||||
#[derive(
|
||||
Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct RelativePathBuf(RangedValue<SystemPathBuf>);
|
||||
|
||||
impl RelativePathBuf {
|
||||
pub fn new(path: impl AsRef<SystemPath>, source: ValueSource) -> Self {
|
||||
Self(RangedValue::new(path.as_ref().to_path_buf(), source))
|
||||
}
|
||||
|
||||
pub fn cli(path: impl AsRef<SystemPath>) -> Self {
|
||||
Self::new(path, ValueSource::Cli)
|
||||
}
|
||||
|
||||
/// Returns the relative path as specified by the user.
|
||||
pub fn path(&self) -> &SystemPath {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns the owned relative path.
|
||||
pub fn into_path_buf(self) -> SystemPathBuf {
|
||||
self.0.into_inner()
|
||||
}
|
||||
|
||||
/// Resolves the absolute path for `self` based on its origin.
|
||||
pub fn absolute_with_db(&self, db: &dyn Db) -> SystemPathBuf {
|
||||
self.absolute(db.project().root(db), db.system())
|
||||
}
|
||||
|
||||
/// Resolves the absolute path for `self` based on its origin.
|
||||
pub fn absolute(&self, project_root: &SystemPath, system: &dyn System) -> SystemPathBuf {
|
||||
let relative_to = match &self.0.source {
|
||||
ValueSource::File(_) => project_root,
|
||||
ValueSource::Cli => system.current_directory(),
|
||||
};
|
||||
|
||||
SystemPath::absolute(&self.0, relative_to)
|
||||
}
|
||||
}
|
||||
|
||||
impl Combine for RelativePathBuf {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
Self(self.0.combine(other.0))
|
||||
}
|
||||
|
||||
fn combine_with(&mut self, other: Self) {
|
||||
self.0.combine_with(other.0);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ ProjectMetadata(
|
||||
name: Name("project-root"),
|
||||
root: "/app",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: Some(SrcOptions(
|
||||
root: Some("src"),
|
||||
)),
|
||||
|
||||
@@ -6,7 +6,6 @@ ProjectMetadata(
|
||||
name: Name("nested-project"),
|
||||
root: "/app/packages/a",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: Some(SrcOptions(
|
||||
root: Some("src"),
|
||||
)),
|
||||
|
||||
@@ -8,11 +8,6 @@ ProjectMetadata(
|
||||
options: Options(
|
||||
environment: Some(EnvironmentOptions(
|
||||
r#python-version: Some("3.10"),
|
||||
r#python-platform: None,
|
||||
r#extra-paths: None,
|
||||
typeshed: None,
|
||||
r#venv-path: None,
|
||||
)),
|
||||
src: None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,8 +5,5 @@ expression: sub_project
|
||||
ProjectMetadata(
|
||||
name: Name("nested-project"),
|
||||
root: "/app/packages/a",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: None,
|
||||
),
|
||||
options: Options(),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ ProjectMetadata(
|
||||
name: Name("super-app"),
|
||||
root: "/app",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: Some(SrcOptions(
|
||||
root: Some("src"),
|
||||
)),
|
||||
|
||||
@@ -5,8 +5,5 @@ expression: project
|
||||
ProjectMetadata(
|
||||
name: Name("backend"),
|
||||
root: "/app",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: None,
|
||||
),
|
||||
options: Options(),
|
||||
)
|
||||
|
||||
@@ -5,8 +5,5 @@ expression: project
|
||||
ProjectMetadata(
|
||||
name: Name("app"),
|
||||
root: "/app",
|
||||
options: Options(
|
||||
environment: None,
|
||||
src: None,
|
||||
),
|
||||
options: Options(),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::{anyhow, Context};
|
||||
use red_knot_project::{ProjectDatabase, ProjectMetadata};
|
||||
use red_knot_python_semantic::{HasTy, SemanticModel};
|
||||
use red_knot_python_semantic::{HasType, SemanticModel};
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf, TestSystem};
|
||||
@@ -197,10 +197,10 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
fn visit_stmt(&mut self, stmt: &Stmt) {
|
||||
match stmt {
|
||||
Stmt::FunctionDef(function) => {
|
||||
let _ty = function.ty(&self.model);
|
||||
let _ty = function.inferred_type(&self.model);
|
||||
}
|
||||
Stmt::ClassDef(class) => {
|
||||
let _ty = class.ty(&self.model);
|
||||
let _ty = class.inferred_type(&self.model);
|
||||
}
|
||||
Stmt::Assign(assign) => {
|
||||
for target in &assign.targets {
|
||||
@@ -243,25 +243,25 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
}
|
||||
|
||||
fn visit_expr(&mut self, expr: &Expr) {
|
||||
let _ty = expr.ty(&self.model);
|
||||
let _ty = expr.inferred_type(&self.model);
|
||||
|
||||
source_order::walk_expr(self, expr);
|
||||
}
|
||||
|
||||
fn visit_parameter(&mut self, parameter: &Parameter) {
|
||||
let _ty = parameter.ty(&self.model);
|
||||
let _ty = parameter.inferred_type(&self.model);
|
||||
|
||||
source_order::walk_parameter(self, parameter);
|
||||
}
|
||||
|
||||
fn visit_parameter_with_default(&mut self, parameter_with_default: &ParameterWithDefault) {
|
||||
let _ty = parameter_with_default.ty(&self.model);
|
||||
let _ty = parameter_with_default.inferred_type(&self.model);
|
||||
|
||||
source_order::walk_parameter_with_default(self, parameter_with_default);
|
||||
}
|
||||
|
||||
fn visit_alias(&mut self, alias: &Alias) {
|
||||
let _ty = alias.ty(&self.model);
|
||||
let _ty = alias.inferred_type(&self.model);
|
||||
|
||||
source_order::walk_alias(self, alias);
|
||||
}
|
||||
@@ -270,6 +270,8 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
/// Whether or not the .py/.pyi version of this file is expected to fail
|
||||
#[rustfmt::skip]
|
||||
const KNOWN_FAILURES: &[(&str, bool, bool)] = &[
|
||||
// related to circular references in nested functions
|
||||
("crates/ruff_linter/resources/test/fixtures/flake8_return/RET503.py", false, true),
|
||||
// related to circular references in class definitions
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py", true, true),
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py", true, true),
|
||||
|
||||
@@ -61,7 +61,13 @@ class MDTestRunner:
|
||||
return False
|
||||
|
||||
# Run it again with 'json' format to find the mdtest executable:
|
||||
json_output = self._run_cargo_test(message_format="json")
|
||||
try:
|
||||
json_output = self._run_cargo_test(message_format="json")
|
||||
except subprocess.CalledProcessError as _:
|
||||
# `cargo test` can still fail if something changed in between the two runs.
|
||||
# Here we don't have a human-readable output, so just show a generic message:
|
||||
self.console.print("[red]Error[/red]: Failed to compile tests")
|
||||
return False
|
||||
|
||||
if json_output:
|
||||
self._get_executable_path_from_json(json_output)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Deferred annotations
|
||||
|
||||
## Deferred annotations in stubs always resolve
|
||||
|
||||
`mod.pyi`:
|
||||
|
||||
```pyi
|
||||
def get_foo() -> Foo: ...
|
||||
class Foo: ...
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import get_foo
|
||||
|
||||
reveal_type(get_foo()) # revealed: Foo
|
||||
```
|
||||
|
||||
## Deferred annotations in regular code fail
|
||||
|
||||
In (regular) source files, annotations are *not* deferred. This also tests that imports from
|
||||
`__future__` that are not `annotations` are ignored.
|
||||
|
||||
```py
|
||||
from __future__ import with_statement as annotations
|
||||
|
||||
# error: [unresolved-reference]
|
||||
def get_foo() -> Foo: ...
|
||||
|
||||
class Foo: ...
|
||||
|
||||
reveal_type(get_foo()) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Deferred annotations in regular code with `__future__.annotations`
|
||||
|
||||
If `__future__.annotations` is imported, annotations *are* deferred.
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
def get_foo() -> Foo: ...
|
||||
|
||||
class Foo: ...
|
||||
|
||||
reveal_type(get_foo()) # revealed: Foo
|
||||
```
|
||||
@@ -36,7 +36,7 @@ def f():
|
||||
reveal_type(a7) # revealed: None
|
||||
reveal_type(a8) # revealed: Literal[1]
|
||||
# TODO: This should be Color.RED
|
||||
reveal_type(b1) # revealed: Literal[0]
|
||||
reveal_type(b1) # revealed: Unknown | Literal[0]
|
||||
|
||||
# error: [invalid-type-form]
|
||||
invalid1: Literal[3 + 4]
|
||||
@@ -106,7 +106,7 @@ def union_example(
|
||||
Literal["B"],
|
||||
Literal[True],
|
||||
None,
|
||||
]
|
||||
],
|
||||
):
|
||||
reveal_type(x) # revealed: Unknown | Literal[-1, "A", b"A", b"\x00", b"\x07", 0, 1, "B", "foo", "bar", True] | None
|
||||
```
|
||||
@@ -116,7 +116,9 @@ def union_example(
|
||||
Only Literal that is defined in typing and typing_extension modules is detected as the special
|
||||
Literal.
|
||||
|
||||
```pyi path=other.pyi
|
||||
`other.pyi`:
|
||||
|
||||
```pyi
|
||||
from typing import _SpecialForm
|
||||
|
||||
Literal: _SpecialForm
|
||||
|
||||
@@ -25,7 +25,9 @@ x = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]` is not
|
||||
|
||||
## Tuple annotations are understood
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
from typing_extensions import Unpack
|
||||
|
||||
a: tuple[()] = ()
|
||||
@@ -40,7 +42,9 @@ i: tuple[str | int, str | int] = (42, 42)
|
||||
j: tuple[str | int] = (42,)
|
||||
```
|
||||
|
||||
```py path=script.py
|
||||
`script.py`:
|
||||
|
||||
```py
|
||||
from module import a, b, c, d, e, f, g, h, i, j
|
||||
|
||||
reveal_type(a) # revealed: tuple[()]
|
||||
@@ -114,7 +118,7 @@ reveal_type(x) # revealed: Foo
|
||||
|
||||
## Annotations in stub files are deferred
|
||||
|
||||
```pyi path=main.pyi
|
||||
```pyi
|
||||
x: Foo
|
||||
|
||||
class Foo: ...
|
||||
@@ -125,7 +129,7 @@ reveal_type(x) # revealed: Foo
|
||||
|
||||
## Annotated assignments in stub files are inferred correctly
|
||||
|
||||
```pyi path=main.pyi
|
||||
```pyi
|
||||
x: int = 1
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
```
|
||||
|
||||
@@ -13,123 +13,90 @@ accessed on the class itself.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, value2: int, flag: bool = False) -> None:
|
||||
# bound but not declared
|
||||
self.pure_instance_variable1 = "value set in __init__"
|
||||
|
||||
# bound but not declared - with type inferred from parameter
|
||||
self.pure_instance_variable2 = value2
|
||||
|
||||
# declared but not bound
|
||||
self.pure_instance_variable3: bytes
|
||||
|
||||
# declared and bound
|
||||
self.pure_instance_variable4: bool = True
|
||||
|
||||
# possibly undeclared/unbound
|
||||
def __init__(self, param: int | None, flag: bool = False) -> None:
|
||||
value = 1 if flag else "a"
|
||||
self.inferred_from_value = value
|
||||
self.inferred_from_other_attribute = self.inferred_from_value
|
||||
self.inferred_from_param = param
|
||||
self.declared_only: bytes
|
||||
self.declared_and_bound: bool = True
|
||||
if flag:
|
||||
self.pure_instance_variable5: str = "possibly set in __init__"
|
||||
self.possibly_undeclared_unbound: str = "possibly set in __init__"
|
||||
|
||||
c_instance = C(1)
|
||||
|
||||
# TODO: should be `Literal["value set in __init__"]`, or `Unknown | Literal[…]` to allow
|
||||
# assignments to this unannotated attribute from other scopes.
|
||||
reveal_type(c_instance.pure_instance_variable1) # revealed: @Todo(implicit instance attribute)
|
||||
reveal_type(c_instance.inferred_from_value) # revealed: Unknown | Literal[1, "a"]
|
||||
|
||||
# TODO: should be `int`
|
||||
reveal_type(c_instance.pure_instance_variable2) # revealed: @Todo(implicit instance attribute)
|
||||
# TODO: Same here. This should be `Unknown | Literal[1, "a"]`
|
||||
reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown
|
||||
|
||||
# TODO: should be `bytes`
|
||||
reveal_type(c_instance.pure_instance_variable3) # revealed: @Todo(implicit instance attribute)
|
||||
# TODO: should be `int | None`
|
||||
reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None
|
||||
|
||||
# TODO: should be `bool`
|
||||
reveal_type(c_instance.pure_instance_variable4) # revealed: @Todo(implicit instance attribute)
|
||||
reveal_type(c_instance.declared_only) # revealed: bytes
|
||||
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: bool
|
||||
|
||||
# TODO: should be `str`
|
||||
# We probably don't want to emit a diagnostic for this being possibly undeclared/unbound.
|
||||
# mypy and pyright do not show an error here.
|
||||
reveal_type(c_instance.pure_instance_variable5) # revealed: @Todo(implicit instance attribute)
|
||||
reveal_type(c_instance.possibly_undeclared_unbound) # revealed: str
|
||||
|
||||
# TODO: If we choose to infer a precise `Literal[…]` type for the instance attribute (see
|
||||
# above), this should be an error: incompatible types in assignment. If we choose to infer
|
||||
# a gradual `Unknown | Literal[…]` type, this assignment is fine.
|
||||
c_instance.pure_instance_variable1 = "value set on instance"
|
||||
# This assignment is fine, as we infer `Unknown | Literal[1, "a"]` for `inferred_from_value`.
|
||||
c_instance.inferred_from_value = "value set on instance"
|
||||
|
||||
# This assignment is also fine:
|
||||
c_instance.inferred_from_param = None
|
||||
|
||||
# TODO: this should be an error (incompatible types in assignment)
|
||||
c_instance.pure_instance_variable2 = "incompatible"
|
||||
c_instance.inferred_from_param = "incompatible"
|
||||
|
||||
# TODO: we already show an error here but the message might be improved?
|
||||
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
|
||||
# error: [unresolved-attribute] "Type `Literal[C]` has no attribute `pure_instance_variable1`"
|
||||
reveal_type(C.pure_instance_variable1) # revealed: Unknown
|
||||
# error: [unresolved-attribute] "Type `Literal[C]` has no attribute `inferred_from_value`"
|
||||
reveal_type(C.inferred_from_value) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error (pure instance variables cannot be accessed on the class)
|
||||
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
|
||||
C.pure_instance_variable1 = "overwritten on class"
|
||||
C.inferred_from_value = "overwritten on class"
|
||||
|
||||
c_instance.pure_instance_variable4 = False
|
||||
# This assignment is fine:
|
||||
c_instance.declared_and_bound = False
|
||||
|
||||
# TODO: After this assignment to the attribute within this scope, we may eventually want to narrow
|
||||
# the `bool` type (see above) for this instance variable to `Literal[False]` here. This is unsound
|
||||
# in general (we don't know what else happened to `c_instance` between the assignment and the use
|
||||
# here), but mypy and pyright support this. In conclusion, this could be `bool` but should probably
|
||||
# be `Literal[False]`.
|
||||
reveal_type(c_instance.pure_instance_variable4) # revealed: @Todo(implicit instance attribute)
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: bool
|
||||
```
|
||||
|
||||
#### Variable declared in class body and declared/bound in `__init__`
|
||||
#### Variable declared in class body and possibly bound in `__init__`
|
||||
|
||||
The same rule applies even if the variable is *declared* (not bound!) in the class body: it is still
|
||||
a pure instance variable.
|
||||
|
||||
```py
|
||||
class C:
|
||||
pure_instance_variable: str
|
||||
declared_and_bound: str | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pure_instance_variable = "value set in __init__"
|
||||
self.declared_and_bound = "value set in __init__"
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: str
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: str | None
|
||||
|
||||
# TODO: we currently plan to emit a diagnostic here. Note that both mypy
|
||||
# and pyright show no error in this case! So we may reconsider this in
|
||||
# the future, if it turns out to produce too many false positives.
|
||||
reveal_type(C.pure_instance_variable) # revealed: str
|
||||
reveal_type(C.declared_and_bound) # revealed: str | None
|
||||
|
||||
# TODO: same as above. We plan to emit a diagnostic here, even if both mypy
|
||||
# and pyright allow this.
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
C.declared_and_bound = "overwritten on class"
|
||||
|
||||
# TODO: this should be an error (incompatible types in assignment)
|
||||
c_instance.pure_instance_variable = 1
|
||||
```
|
||||
|
||||
#### Variable only defined in unrelated method
|
||||
|
||||
We also recognize pure instance variables if they are defined in a method that is not `__init__`.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def set_instance_variable(self) -> None:
|
||||
self.pure_instance_variable = "value set in method"
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# Not that we would use this in static analysis, but for a more realistic example, let's actually
|
||||
# call the method, so that the attribute is bound if this example is actually run.
|
||||
c_instance.set_instance_variable()
|
||||
|
||||
# TODO: should be `Literal["value set in method"]` or `Unknown | Literal[…]` (see above).
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: @Todo(implicit instance attribute)
|
||||
|
||||
# TODO: We already show an error here, but the message might be improved?
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_instance_variable) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `declared_and_bound` of type `str | None`"
|
||||
c_instance.declared_and_bound = 1
|
||||
```
|
||||
|
||||
#### Variable declared in class body and not bound anywhere
|
||||
@@ -139,18 +106,341 @@ instance variable and allow access to it via instances.
|
||||
|
||||
```py
|
||||
class C:
|
||||
pure_instance_variable: str
|
||||
only_declared: str
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: str
|
||||
reveal_type(c_instance.only_declared) # revealed: str
|
||||
|
||||
# TODO: mypy and pyright do not show an error here, but we plan to emit a diagnostic.
|
||||
# The type could be changed to 'Unknown' if we decide to emit an error?
|
||||
reveal_type(C.pure_instance_variable) # revealed: str
|
||||
reveal_type(C.only_declared) # revealed: str
|
||||
|
||||
# TODO: mypy and pyright do not show an error here, but we plan to emit one.
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
C.only_declared = "overwritten on class"
|
||||
```
|
||||
|
||||
#### Mixed declarations/bindings in class body and `__init__`
|
||||
|
||||
```py
|
||||
class C:
|
||||
only_declared_in_body: str | None
|
||||
declared_in_body_and_init: str | None
|
||||
|
||||
declared_in_body_defined_in_init: str | None
|
||||
|
||||
bound_in_body_declared_in_init = "a"
|
||||
|
||||
bound_in_body_and_init = None
|
||||
|
||||
def __init__(self, flag) -> None:
|
||||
self.only_declared_in_init: str | None
|
||||
self.declared_in_body_and_init: str | None = None
|
||||
|
||||
self.declared_in_body_defined_in_init = "a"
|
||||
|
||||
self.bound_in_body_declared_in_init: str | None
|
||||
|
||||
if flag:
|
||||
self.bound_in_body_and_init = "a"
|
||||
|
||||
c_instance = C(True)
|
||||
|
||||
reveal_type(c_instance.only_declared_in_body) # revealed: str | None
|
||||
reveal_type(c_instance.only_declared_in_init) # revealed: str | None
|
||||
reveal_type(c_instance.declared_in_body_and_init) # revealed: str | None
|
||||
|
||||
reveal_type(c_instance.declared_in_body_defined_in_init) # revealed: str | None
|
||||
|
||||
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: str | None
|
||||
|
||||
reveal_type(c_instance.bound_in_body_and_init) # revealed: Unknown | None | Literal["a"]
|
||||
```
|
||||
|
||||
#### Variable defined in non-`__init__` method
|
||||
|
||||
We also recognize pure instance variables if they are defined in a method that is not `__init__`.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, param: int | None, flag: bool = False) -> None:
|
||||
self.initialize(param, flag)
|
||||
|
||||
def initialize(self, param: int | None, flag: bool) -> None:
|
||||
value = 1 if flag else "a"
|
||||
self.inferred_from_value = value
|
||||
self.inferred_from_other_attribute = self.inferred_from_value
|
||||
self.inferred_from_param = param
|
||||
self.declared_only: bytes
|
||||
self.declared_and_bound: bool = True
|
||||
|
||||
c_instance = C(1)
|
||||
|
||||
reveal_type(c_instance.inferred_from_value) # revealed: Unknown | Literal[1, "a"]
|
||||
|
||||
# TODO: Should be `Unknown | Literal[1, "a"]`
|
||||
reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown
|
||||
|
||||
# TODO: Should be `int | None`
|
||||
reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None
|
||||
|
||||
reveal_type(c_instance.declared_only) # revealed: bytes
|
||||
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: bool
|
||||
|
||||
# TODO: We already show an error here, but the message might be improved?
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.inferred_from_value) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error
|
||||
C.inferred_from_value = "overwritten on class"
|
||||
```
|
||||
|
||||
#### Variable defined in multiple methods
|
||||
|
||||
If we see multiple un-annotated assignments to a single attribute (`self.x` below), we build the
|
||||
union of all inferred types (and `Unknown`). If we see multiple conflicting declarations of the same
|
||||
attribute, that should be an error.
|
||||
|
||||
```py
|
||||
def get_int() -> int:
|
||||
return 0
|
||||
|
||||
def get_str() -> str:
|
||||
return "a"
|
||||
|
||||
class C:
|
||||
z: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.x = get_int()
|
||||
self.y: int = 1
|
||||
|
||||
def other_method(self):
|
||||
self.x = get_str()
|
||||
|
||||
# TODO: this redeclaration should be an error
|
||||
self.y: str = "a"
|
||||
|
||||
# TODO: this redeclaration should be an error
|
||||
self.z: str = "a"
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.x) # revealed: Unknown | int | str
|
||||
reveal_type(c_instance.y) # revealed: int
|
||||
reveal_type(c_instance.z) # revealed: int
|
||||
```
|
||||
|
||||
#### Attributes defined in tuple unpackings
|
||||
|
||||
```py
|
||||
def returns_tuple() -> tuple[int, str]:
|
||||
return (1, "a")
|
||||
|
||||
class C:
|
||||
a1, b1 = (1, "a")
|
||||
c1, d1 = returns_tuple()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.a2, self.b2 = (1, "a")
|
||||
self.c2, self.d2 = returns_tuple()
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.a1) # revealed: Unknown | Literal[1]
|
||||
reveal_type(c_instance.b1) # revealed: Unknown | Literal["a"]
|
||||
reveal_type(c_instance.c1) # revealed: Unknown | int
|
||||
reveal_type(c_instance.d1) # revealed: Unknown | str
|
||||
|
||||
# TODO: This should be supported (no error; type should be: `Unknown | Literal[1]`)
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c_instance.a2) # revealed: Unknown
|
||||
|
||||
# TODO: This should be supported (no error; type should be: `Unknown | Literal["a"]`)
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c_instance.b2) # revealed: Unknown
|
||||
|
||||
# TODO: Similar for these two (should be `Unknown | int` and `Unknown | str`, respectively)
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c_instance.c2) # revealed: Unknown
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c_instance.d2) # revealed: Unknown
|
||||
```
|
||||
|
||||
#### Attributes defined in for-loop (unpacking)
|
||||
|
||||
```py
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 1
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
class TupleIterator:
|
||||
def __next__(self) -> tuple[int, str]:
|
||||
return (1, "a")
|
||||
|
||||
class TupleIterable:
|
||||
def __iter__(self) -> TupleIterator:
|
||||
return TupleIterator()
|
||||
|
||||
class C:
|
||||
def __init__(self):
|
||||
for self.x in IntIterable():
|
||||
pass
|
||||
|
||||
for _, self.y in TupleIterable():
|
||||
pass
|
||||
|
||||
# TODO: Pyright fully supports these, mypy detects the presence of the attributes,
|
||||
# but infers type `Any` for both of them. We should infer `int` and `str` here:
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C().x) # revealed: Unknown
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C().y) # revealed: Unknown
|
||||
```
|
||||
|
||||
#### Conditionally declared / bound attributes
|
||||
|
||||
We currently do not raise a diagnostic or change behavior if an attribute is only conditionally
|
||||
defined. This is consistent with what mypy and pyright do.
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
|
||||
class C:
|
||||
def f(self) -> None:
|
||||
if flag():
|
||||
self.a1: str | None = "a"
|
||||
self.b1 = 1
|
||||
if flag():
|
||||
def f(self) -> None:
|
||||
self.a2: str | None = "a"
|
||||
self.b2 = 1
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.a1) # revealed: str | None
|
||||
reveal_type(c_instance.a2) # revealed: str | None
|
||||
reveal_type(c_instance.b1) # revealed: Unknown | Literal[1]
|
||||
reveal_type(c_instance.b2) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
#### Methods that does not use `self` as a first parameter
|
||||
|
||||
```py
|
||||
class C:
|
||||
# This might trigger a stylistic lint like `invalid-first-argument-name-for-method`, but
|
||||
# it should be supported in general:
|
||||
def __init__(this) -> None:
|
||||
this.declared_and_bound: str | None = "a"
|
||||
|
||||
reveal_type(C().declared_and_bound) # revealed: str | None
|
||||
```
|
||||
|
||||
#### Aliased `self` parameter
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
this = self
|
||||
this.declared_and_bound: str | None = "a"
|
||||
|
||||
# This would ideally be `str | None`, but mypy/pyright don't support this either,
|
||||
# so `Unknown` + a diagnostic is also fine.
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C().declared_and_bound) # revealed: Unknown
|
||||
```
|
||||
|
||||
#### Static methods do not influence implicitly defined attributes
|
||||
|
||||
```py
|
||||
class Other:
|
||||
x: int
|
||||
|
||||
class C:
|
||||
@staticmethod
|
||||
def f(other: Other) -> None:
|
||||
other.x = 1
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.x) # revealed: Unknown
|
||||
|
||||
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
|
||||
reveal_type(C().x) # revealed: Unknown | Literal[1]
|
||||
|
||||
# This also works if `staticmethod` is aliased:
|
||||
|
||||
my_staticmethod = staticmethod
|
||||
|
||||
class D:
|
||||
@my_staticmethod
|
||||
def f(other: Other) -> None:
|
||||
other.x = 1
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(D.x) # revealed: Unknown
|
||||
|
||||
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
|
||||
reveal_type(D().x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
If `staticmethod` is something else, that should not influence the behavior:
|
||||
|
||||
```py
|
||||
def staticmethod(f):
|
||||
return f
|
||||
|
||||
class C:
|
||||
@staticmethod
|
||||
def f(self) -> None:
|
||||
self.x = 1
|
||||
|
||||
reveal_type(C().x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
And if `staticmethod` is fully qualified, that should also be recognized:
|
||||
|
||||
```py
|
||||
import builtins
|
||||
|
||||
class Other:
|
||||
x: int
|
||||
|
||||
class C:
|
||||
@builtins.staticmethod
|
||||
def f(other: Other) -> None:
|
||||
other.x = 1
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.x) # revealed: Unknown
|
||||
|
||||
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
|
||||
reveal_type(C().x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
#### Attributes defined in statically-known-to-be-false branches
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
# We use a "significantly complex" condition here (instead of just `False`)
|
||||
# for a proper comparison with mypy and pyright, which distinguish between
|
||||
# conditions that can be resolved from a simple pattern matching and those
|
||||
# that need proper type inference.
|
||||
if (2 + 3) < 4:
|
||||
self.x: str = "a"
|
||||
|
||||
# TODO: Ideally, this would result in a `unresolved-attribute` error. But mypy and pyright
|
||||
# do not support this either (for conditions that can only be resolved to `False` in type
|
||||
# inference), so it does not seem to be particularly important.
|
||||
reveal_type(C().x) # revealed: str
|
||||
```
|
||||
|
||||
### Pure class variables (`ClassVar`)
|
||||
@@ -175,7 +465,7 @@ class C:
|
||||
|
||||
reveal_type(C.pure_class_variable1) # revealed: str
|
||||
|
||||
# TODO: this should be `Literal[1]`, or `Unknown | Literal[1]`.
|
||||
# TODO: Should be `Unknown | Literal[1]`.
|
||||
reveal_type(C.pure_class_variable2) # revealed: Unknown
|
||||
|
||||
c_instance = C()
|
||||
@@ -191,7 +481,7 @@ c_instance.pure_class_variable1 = "value set on instance"
|
||||
|
||||
C.pure_class_variable1 = "overwritten on class"
|
||||
|
||||
# TODO: should raise an error (incompatible types in assignment)
|
||||
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `pure_class_variable1` of type `str`"
|
||||
C.pure_class_variable1 = 1
|
||||
|
||||
class Subclass(C):
|
||||
@@ -221,13 +511,13 @@ reveal_type(C.pure_class_variable) # revealed: Unknown
|
||||
|
||||
C.pure_class_variable = "overwritten on class"
|
||||
|
||||
# TODO: should be `Literal["overwritten on class"]`
|
||||
# TODO: should be `Unknown | Literal["value set in class method"]` or
|
||||
# Literal["overwritten on class"]`, once/if we support local narrowing.
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_class_variable) # revealed: Unknown
|
||||
|
||||
c_instance = C()
|
||||
# TODO: should be `Literal["overwritten on class"]`
|
||||
reveal_type(c_instance.pure_class_variable) # revealed: @Todo(implicit instance attribute)
|
||||
reveal_type(c_instance.pure_class_variable) # revealed: Unknown | Literal["value set in class method"]
|
||||
|
||||
# TODO: should raise an error.
|
||||
c_instance.pure_class_variable = "value set on instance"
|
||||
@@ -252,8 +542,7 @@ class C:
|
||||
|
||||
reveal_type(C.variable_with_class_default1) # revealed: str
|
||||
|
||||
# TODO: this should be `Unknown | Literal[1]`.
|
||||
reveal_type(C.variable_with_class_default2) # revealed: Literal[1]
|
||||
reveal_type(C.variable_with_class_default2) # revealed: Unknown | Literal[1]
|
||||
|
||||
c_instance = C()
|
||||
|
||||
@@ -278,6 +567,53 @@ reveal_type(C.variable_with_class_default1) # revealed: str
|
||||
reveal_type(c_instance.variable_with_class_default1) # revealed: str
|
||||
```
|
||||
|
||||
### Inheritance of class/instance attributes
|
||||
|
||||
#### Instance variable defined in a base class
|
||||
|
||||
```py
|
||||
class Base:
|
||||
declared_in_body: int | None = 1
|
||||
|
||||
base_class_attribute_1: str | None
|
||||
base_class_attribute_2: str | None
|
||||
base_class_attribute_3: str | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.defined_in_init: str | None = "value in base"
|
||||
|
||||
class Intermediate(Base):
|
||||
# Re-declaring base class attributes with the *same *type is fine:
|
||||
base_class_attribute_1: str | None = None
|
||||
|
||||
# Re-declaring them with a *narrower type* is unsound, because modifications
|
||||
# through a `Base` reference could violate that constraint.
|
||||
#
|
||||
# Mypy does not report an error here, but pyright does: "… overrides symbol
|
||||
# of same name in class "Base". Variable is mutable so its type is invariant"
|
||||
#
|
||||
# We should introduce a diagnostic for this. Whether or not that should be
|
||||
# enabled by default can still be discussed.
|
||||
#
|
||||
# TODO: This should be an error
|
||||
base_class_attribute_2: str
|
||||
|
||||
# Re-declaring attributes with a *wider type* directly violates LSP.
|
||||
#
|
||||
# In this case, both mypy and pyright report an error.
|
||||
#
|
||||
# TODO: This should be an error
|
||||
base_class_attribute_3: str | int | None
|
||||
|
||||
class Derived(Intermediate): ...
|
||||
|
||||
reveal_type(Derived.declared_in_body) # revealed: int | None
|
||||
|
||||
reveal_type(Derived().declared_in_body) # revealed: int | None
|
||||
|
||||
reveal_type(Derived().defined_in_init) # revealed: str | None
|
||||
```
|
||||
|
||||
## Union of attributes
|
||||
|
||||
```py
|
||||
@@ -296,8 +632,8 @@ def _(flag: bool):
|
||||
else:
|
||||
x = 4
|
||||
|
||||
reveal_type(C1.x) # revealed: Literal[1, 2]
|
||||
reveal_type(C2.x) # revealed: Literal[3, 4]
|
||||
reveal_type(C1.x) # revealed: Unknown | Literal[1, 2]
|
||||
reveal_type(C2.x) # revealed: Unknown | Literal[3, 4]
|
||||
```
|
||||
|
||||
## Inherited class attributes
|
||||
@@ -311,7 +647,7 @@ class A:
|
||||
class B(A): ...
|
||||
class C(B): ...
|
||||
|
||||
reveal_type(C.X) # revealed: Literal["foo"]
|
||||
reveal_type(C.X) # revealed: Unknown | Literal["foo"]
|
||||
```
|
||||
|
||||
### Multiple inheritance
|
||||
@@ -334,7 +670,7 @@ class A(B, C): ...
|
||||
reveal_type(A.__mro__)
|
||||
|
||||
# `E` is earlier in the MRO than `F`, so we should use the type of `E.X`
|
||||
reveal_type(A.X) # revealed: Literal[42]
|
||||
reveal_type(A.X) # revealed: Unknown | Literal[42]
|
||||
```
|
||||
|
||||
## Unions with possibly unbound paths
|
||||
@@ -356,7 +692,7 @@ def _(flag1: bool, flag2: bool):
|
||||
C = C1 if flag1 else C2 if flag2 else C3
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C1, C2, C3]` is possibly unbound"
|
||||
reveal_type(C.x) # revealed: Literal[1, 3]
|
||||
reveal_type(C.x) # revealed: Unknown | Literal[1, 3]
|
||||
```
|
||||
|
||||
### Possibly-unbound within a class
|
||||
@@ -379,7 +715,7 @@ def _(flag: bool, flag1: bool, flag2: bool):
|
||||
C = C1 if flag1 else C2 if flag2 else C3
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C1, C2, C3]` is possibly unbound"
|
||||
reveal_type(C.x) # revealed: Literal[1, 2, 3]
|
||||
reveal_type(C.x) # revealed: Unknown | Literal[1, 2, 3]
|
||||
```
|
||||
|
||||
### Unions with all paths unbound
|
||||
@@ -436,6 +772,72 @@ class Foo: ...
|
||||
reveal_type(Foo.__class__) # revealed: Literal[type]
|
||||
```
|
||||
|
||||
## Module attributes
|
||||
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
global_symbol: str = "a"
|
||||
```
|
||||
|
||||
```py
|
||||
import mod
|
||||
|
||||
reveal_type(mod.global_symbol) # revealed: str
|
||||
mod.global_symbol = "b"
|
||||
|
||||
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
|
||||
mod.global_symbol = 1
|
||||
|
||||
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
|
||||
(_, mod.global_symbol) = (..., 1)
|
||||
|
||||
# TODO: this should be an error, but we do not understand list unpackings yet.
|
||||
[_, mod.global_symbol] = [1, 2]
|
||||
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
# error: [invalid-assignment] "Object of type `int` is not assignable to attribute `global_symbol` of type `str`"
|
||||
for mod.global_symbol in IntIterable():
|
||||
pass
|
||||
```
|
||||
|
||||
## Nested attributes
|
||||
|
||||
`outer/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
`outer/nested/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
`outer/nested/inner.py`:
|
||||
|
||||
```py
|
||||
class Outer:
|
||||
class Nested:
|
||||
class Inner:
|
||||
attr: int = 1
|
||||
```
|
||||
|
||||
```py
|
||||
import outer.nested.inner
|
||||
|
||||
reveal_type(outer.nested.inner.Outer.Nested.Inner.attr) # revealed: int
|
||||
|
||||
# error: [invalid-assignment]
|
||||
outer.nested.inner.Outer.Nested.Inner.attr = "a"
|
||||
```
|
||||
|
||||
## Literal types
|
||||
|
||||
### Function-literal attributes
|
||||
@@ -443,7 +845,7 @@ reveal_type(Foo.__class__) # revealed: Literal[type]
|
||||
Most attribute accesses on function-literal types are delegated to `types.FunctionType`, since all
|
||||
functions are instances of that class:
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
def f(): ...
|
||||
|
||||
reveal_type(f.__defaults__) # revealed: @Todo(full tuple[...] support) | None
|
||||
@@ -452,9 +854,7 @@ reveal_type(f.__kwdefaults__) # revealed: @Todo(generics) | None
|
||||
|
||||
Some attributes are special-cased, however:
|
||||
|
||||
```py path=b.py
|
||||
def f(): ...
|
||||
|
||||
```py
|
||||
reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions)
|
||||
reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
|
||||
```
|
||||
@@ -464,14 +864,14 @@ reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
|
||||
Most attribute accesses on int-literal types are delegated to `builtins.int`, since all literal
|
||||
integers are instances of that class:
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
reveal_type((2).bit_length) # revealed: @Todo(bound method)
|
||||
reveal_type((2).denominator) # revealed: @Todo(@property)
|
||||
```
|
||||
|
||||
Some attributes are special-cased, however:
|
||||
|
||||
```py path=b.py
|
||||
```py
|
||||
reveal_type((2).numerator) # revealed: Literal[2]
|
||||
reveal_type((2).real) # revealed: Literal[2]
|
||||
```
|
||||
@@ -481,14 +881,14 @@ reveal_type((2).real) # revealed: Literal[2]
|
||||
Most attribute accesses on bool-literal types are delegated to `builtins.bool`, since all literal
|
||||
bols are instances of that class:
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
reveal_type(True.__and__) # revealed: @Todo(bound method)
|
||||
reveal_type(False.__or__) # revealed: @Todo(bound method)
|
||||
```
|
||||
|
||||
Some attributes are special-cased, however:
|
||||
|
||||
```py path=b.py
|
||||
```py
|
||||
reveal_type(True.numerator) # revealed: Literal[1]
|
||||
reveal_type(False.real) # revealed: Literal[0]
|
||||
```
|
||||
@@ -502,6 +902,90 @@ reveal_type(b"foo".join) # revealed: @Todo(bound method)
|
||||
reveal_type(b"foo".endswith) # revealed: @Todo(bound method)
|
||||
```
|
||||
|
||||
## Instance attribute edge cases
|
||||
|
||||
### Assignment to attribute that does not correspond to the instance
|
||||
|
||||
```py
|
||||
class Other:
|
||||
x: int = 1
|
||||
|
||||
class C:
|
||||
def __init__(self, other: Other) -> None:
|
||||
other.x = 1
|
||||
|
||||
def f(c: C):
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c.x) # revealed: Unknown
|
||||
```
|
||||
|
||||
### Nested classes
|
||||
|
||||
```py
|
||||
class Outer:
|
||||
def __init__(self):
|
||||
self.x: int = 1
|
||||
|
||||
class Middle:
|
||||
# has no 'x' attribute
|
||||
|
||||
class Inner:
|
||||
def __init__(self):
|
||||
self.x: str = "a"
|
||||
|
||||
reveal_type(Outer().x) # revealed: int
|
||||
|
||||
# error: [unresolved-attribute]
|
||||
Outer.Middle().x
|
||||
|
||||
reveal_type(Outer.Middle.Inner().x) # revealed: str
|
||||
```
|
||||
|
||||
### Shadowing of `self`
|
||||
|
||||
```py
|
||||
class Other:
|
||||
x: int = 1
|
||||
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
# Redeclaration of self. `self` does not refer to the instance anymore.
|
||||
self: Other = Other()
|
||||
self.x: int = 1
|
||||
|
||||
# TODO: this should be an error
|
||||
C().x
|
||||
```
|
||||
|
||||
### Assignment to `self` after nested function
|
||||
|
||||
```py
|
||||
class Other:
|
||||
x: str = "a"
|
||||
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
def nested_function(self: Other):
|
||||
self.x = "b"
|
||||
self.x: int = 1
|
||||
|
||||
reveal_type(C().x) # revealed: int
|
||||
```
|
||||
|
||||
### Assignment to `self` from nested function
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
def set_attribute(value: str):
|
||||
self.x: str = value
|
||||
set_attribute("a")
|
||||
|
||||
# TODO: ideally, this would be `str`. Mypy supports this, pyright does not.
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C().x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
Some of the tests in the *Class and instance variables* section draw inspiration from
|
||||
|
||||
@@ -50,46 +50,44 @@ reveal_type(b | b) # revealed: Literal[False]
|
||||
## Arithmetic with a variable
|
||||
|
||||
```py
|
||||
a = True
|
||||
b = False
|
||||
def _(a: bool):
|
||||
def lhs_is_int(x: int):
|
||||
reveal_type(x + a) # revealed: int
|
||||
reveal_type(x - a) # revealed: int
|
||||
reveal_type(x * a) # revealed: int
|
||||
reveal_type(x // a) # revealed: int
|
||||
reveal_type(x / a) # revealed: float
|
||||
reveal_type(x % a) # revealed: int
|
||||
|
||||
def lhs_is_int(x: int):
|
||||
reveal_type(x + a) # revealed: int
|
||||
reveal_type(x - a) # revealed: int
|
||||
reveal_type(x * a) # revealed: int
|
||||
reveal_type(x // a) # revealed: int
|
||||
reveal_type(x / a) # revealed: float
|
||||
reveal_type(x % a) # revealed: int
|
||||
def rhs_is_int(x: int):
|
||||
reveal_type(a + x) # revealed: int
|
||||
reveal_type(a - x) # revealed: int
|
||||
reveal_type(a * x) # revealed: int
|
||||
reveal_type(a // x) # revealed: int
|
||||
reveal_type(a / x) # revealed: float
|
||||
reveal_type(a % x) # revealed: int
|
||||
|
||||
def rhs_is_int(x: int):
|
||||
reveal_type(a + x) # revealed: int
|
||||
reveal_type(a - x) # revealed: int
|
||||
reveal_type(a * x) # revealed: int
|
||||
reveal_type(a // x) # revealed: int
|
||||
reveal_type(a / x) # revealed: float
|
||||
reveal_type(a % x) # revealed: int
|
||||
def lhs_is_bool(x: bool):
|
||||
reveal_type(x + a) # revealed: int
|
||||
reveal_type(x - a) # revealed: int
|
||||
reveal_type(x * a) # revealed: int
|
||||
reveal_type(x // a) # revealed: int
|
||||
reveal_type(x / a) # revealed: float
|
||||
reveal_type(x % a) # revealed: int
|
||||
|
||||
def lhs_is_bool(x: bool):
|
||||
reveal_type(x + a) # revealed: int
|
||||
reveal_type(x - a) # revealed: int
|
||||
reveal_type(x * a) # revealed: int
|
||||
reveal_type(x // a) # revealed: int
|
||||
reveal_type(x / a) # revealed: float
|
||||
reveal_type(x % a) # revealed: int
|
||||
def rhs_is_bool(x: bool):
|
||||
reveal_type(a + x) # revealed: int
|
||||
reveal_type(a - x) # revealed: int
|
||||
reveal_type(a * x) # revealed: int
|
||||
reveal_type(a // x) # revealed: int
|
||||
reveal_type(a / x) # revealed: float
|
||||
reveal_type(a % x) # revealed: int
|
||||
|
||||
def rhs_is_bool(x: bool):
|
||||
reveal_type(a + x) # revealed: int
|
||||
reveal_type(a - x) # revealed: int
|
||||
reveal_type(a * x) # revealed: int
|
||||
reveal_type(a // x) # revealed: int
|
||||
reveal_type(a / x) # revealed: float
|
||||
reveal_type(a % x) # revealed: int
|
||||
|
||||
def both_are_bool(x: bool, y: bool):
|
||||
reveal_type(x + y) # revealed: int
|
||||
reveal_type(x - y) # revealed: int
|
||||
reveal_type(x * y) # revealed: int
|
||||
reveal_type(x // y) # revealed: int
|
||||
reveal_type(x / y) # revealed: float
|
||||
reveal_type(x % y) # revealed: int
|
||||
def both_are_bool(x: bool, y: bool):
|
||||
reveal_type(x + y) # revealed: int
|
||||
reveal_type(x - y) # revealed: int
|
||||
reveal_type(x * y) # revealed: int
|
||||
reveal_type(x // y) # revealed: int
|
||||
reveal_type(x / y) # revealed: float
|
||||
reveal_type(x % y) # revealed: int
|
||||
```
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
## Class instances
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Yes:
|
||||
def __add__(self, other) -> Literal["+"]:
|
||||
return "+"
|
||||
@@ -136,6 +138,8 @@ reveal_type(No() // Yes()) # revealed: Unknown
|
||||
## Subclass reflections override superclass dunders
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Yes:
|
||||
def __add__(self, other) -> Literal["+"]:
|
||||
return "+"
|
||||
@@ -294,6 +298,8 @@ itself. (For these operators to work on the class itself, they would have to be
|
||||
class's type, i.e. `type`.)
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Yes:
|
||||
def __add__(self, other) -> Literal["+"]:
|
||||
return "+"
|
||||
@@ -312,6 +318,8 @@ reveal_type(No + No) # revealed: Unknown
|
||||
## Subclass
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Yes:
|
||||
def __add__(self, other) -> Literal["+"]:
|
||||
return "+"
|
||||
|
||||
@@ -262,7 +262,8 @@ class A:
|
||||
class B:
|
||||
__add__ = A()
|
||||
|
||||
reveal_type(B() + B()) # revealed: int
|
||||
# TODO: this could be `int` if we declare `B.__add__` using a `Callable` type
|
||||
reveal_type(B() + B()) # revealed: Unknown | int
|
||||
```
|
||||
|
||||
## Integration test: numbers from typeshed
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# Boundness and declaredness: public uses
|
||||
|
||||
This document demonstrates how type-inference and diagnostics works for *public* uses of a symbol,
|
||||
This document demonstrates how type-inference and diagnostics work for *public* uses of a symbol,
|
||||
that is, a use of a symbol from another scope. If a symbol has a declared type in its local scope
|
||||
(e.g. `int`), we use that as the symbol's "public type" (the type of the symbol from the perspective
|
||||
of other scopes) even if there is a more precise local inferred type for the symbol (`Literal[1]`).
|
||||
|
||||
If a symbol has no declared type, we use the union of `Unknown` with the inferred type as the public
|
||||
type. If there is no declaration, then the symbol can be reassigned to any type from another scope;
|
||||
the union with `Unknown` reflects that its type must at least be as large as the type of the
|
||||
assigned value, but could be arbitrarily larger.
|
||||
|
||||
We test the whole matrix of possible boundness and declaredness states. The current behavior is
|
||||
summarized in the following table, while the tests below demonstrate each case. Note that some of
|
||||
this behavior is questionable and might change in the future. See the TODOs in `symbol_by_id`
|
||||
@@ -12,11 +17,11 @@ this behavior is questionable and might change in the future. See the TODOs in `
|
||||
In particular, we should raise errors in the "possibly-undeclared-and-unbound" as well as the
|
||||
"undeclared-and-possibly-unbound" cases (marked with a "?").
|
||||
|
||||
| **Public type** | declared | possibly-undeclared | undeclared |
|
||||
| ---------------- | ------------ | -------------------------- | ------------ |
|
||||
| bound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` |
|
||||
| possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` |
|
||||
| unbound | `T_declared` | `T_declared` | `Unknown` |
|
||||
| **Public type** | declared | possibly-undeclared | undeclared |
|
||||
| ---------------- | ------------ | -------------------------- | ----------------------- |
|
||||
| bound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` |
|
||||
| possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` |
|
||||
| unbound | `T_declared` | `T_declared` | `Unknown` |
|
||||
|
||||
| **Diagnostic** | declared | possibly-undeclared | undeclared |
|
||||
| ---------------- | -------- | ------------------------- | ------------------- |
|
||||
@@ -29,20 +34,28 @@ In particular, we should raise errors in the "possibly-undeclared-and-unbound" a
|
||||
### Declared and bound
|
||||
|
||||
If a symbol has a declared type (`int`), we use that even if there is a more precise inferred type
|
||||
(`Literal[1]`), or a conflicting inferred type (`Literal[2]`):
|
||||
(`Literal[1]`), or a conflicting inferred type (`str` vs. `Literal[2]` below):
|
||||
|
||||
```py path=mod.py
|
||||
x: int = 1
|
||||
`mod.py`:
|
||||
|
||||
# error: [invalid-assignment]
|
||||
y: str = 2
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def any() -> Any: ...
|
||||
|
||||
a: int = 1
|
||||
b: str = 2 # error: [invalid-assignment]
|
||||
c: Any = 3
|
||||
d: int = any()
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import x, y
|
||||
from mod import a, b, c, d
|
||||
|
||||
reveal_type(x) # revealed: int
|
||||
reveal_type(y) # revealed: str
|
||||
reveal_type(a) # revealed: int
|
||||
reveal_type(b) # revealed: str
|
||||
reveal_type(c) # revealed: Any
|
||||
reveal_type(d) # revealed: int
|
||||
```
|
||||
|
||||
### Declared and possibly unbound
|
||||
@@ -50,22 +63,33 @@ reveal_type(y) # revealed: str
|
||||
If a symbol is declared and *possibly* unbound, we trust that other module and use the declared type
|
||||
without raising an error.
|
||||
|
||||
```py path=mod.py
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def any() -> Any: ...
|
||||
def flag() -> bool: ...
|
||||
|
||||
x: int
|
||||
y: str
|
||||
a: int
|
||||
b: str
|
||||
c: Any
|
||||
d: int
|
||||
|
||||
if flag:
|
||||
x = 1
|
||||
# error: [invalid-assignment]
|
||||
y = 2
|
||||
a = 1
|
||||
b = 2 # error: [invalid-assignment]
|
||||
c = 3
|
||||
d = any()
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import x, y
|
||||
from mod import a, b, c, d
|
||||
|
||||
reveal_type(x) # revealed: int
|
||||
reveal_type(y) # revealed: str
|
||||
reveal_type(a) # revealed: int
|
||||
reveal_type(b) # revealed: str
|
||||
reveal_type(c) # revealed: Any
|
||||
reveal_type(d) # revealed: int
|
||||
```
|
||||
|
||||
### Declared and unbound
|
||||
@@ -73,14 +97,20 @@ reveal_type(y) # revealed: str
|
||||
Similarly, if a symbol is declared but unbound, we do not raise an error. We trust that this symbol
|
||||
is available somehow and simply use the declared type.
|
||||
|
||||
```py path=mod.py
|
||||
x: int
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
a: int
|
||||
b: Any
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import x
|
||||
from mod import a, b
|
||||
|
||||
reveal_type(x) # revealed: int
|
||||
reveal_type(a) # revealed: int
|
||||
reveal_type(b) # revealed: Any
|
||||
```
|
||||
|
||||
## Possibly undeclared
|
||||
@@ -90,50 +120,70 @@ reveal_type(x) # revealed: int
|
||||
If a symbol is possibly undeclared but definitely bound, we use the union of the declared and
|
||||
inferred types:
|
||||
|
||||
```py path=mod.py
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def any() -> Any: ...
|
||||
def flag() -> bool: ...
|
||||
|
||||
x = 1
|
||||
y = 2
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
d = any()
|
||||
if flag():
|
||||
x: Any
|
||||
# error: [invalid-declaration]
|
||||
y: str
|
||||
a: int
|
||||
b: Any
|
||||
c: str # error: [invalid-declaration]
|
||||
d: int
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import x, y
|
||||
from mod import a, b, c, d
|
||||
|
||||
reveal_type(x) # revealed: Literal[1] | Any
|
||||
reveal_type(y) # revealed: Literal[2] | Unknown
|
||||
reveal_type(a) # revealed: int
|
||||
reveal_type(b) # revealed: Literal[2] | Any
|
||||
reveal_type(c) # revealed: Literal[3] | Unknown
|
||||
reveal_type(d) # revealed: Any | int
|
||||
|
||||
# External modifications of `a` that violate the declared type are not allowed:
|
||||
# error: [invalid-assignment]
|
||||
a = None
|
||||
```
|
||||
|
||||
### Possibly undeclared and possibly unbound
|
||||
|
||||
If a symbol is possibly undeclared and possibly unbound, we also use the union of the declared and
|
||||
inferred types. This case is interesting because the "possibly declared" definition might not be the
|
||||
same as the "possibly bound" definition (symbol `y`). Note that we raise a `possibly-unbound-import`
|
||||
error for both `x` and `y`:
|
||||
same as the "possibly bound" definition (symbol `b`). Note that we raise a `possibly-unbound-import`
|
||||
error for both `a` and `b`:
|
||||
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
```py path=mod.py
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag():
|
||||
x: Any = 1
|
||||
y = 2
|
||||
a: Any = 1
|
||||
b = 2
|
||||
else:
|
||||
y: str
|
||||
b: str
|
||||
```
|
||||
|
||||
```py
|
||||
# error: [possibly-unbound-import]
|
||||
# error: [possibly-unbound-import]
|
||||
from mod import x, y
|
||||
from mod import a, b
|
||||
|
||||
reveal_type(x) # revealed: Literal[1] | Any
|
||||
reveal_type(y) # revealed: Literal[2] | str
|
||||
reveal_type(a) # revealed: Literal[1] | Any
|
||||
reveal_type(b) # revealed: Literal[2] | str
|
||||
|
||||
# External modifications of `b` that violate the declared type are not allowed:
|
||||
# error: [invalid-assignment]
|
||||
b = None
|
||||
```
|
||||
|
||||
### Possibly undeclared and unbound
|
||||
@@ -141,35 +191,53 @@ reveal_type(y) # revealed: Literal[2] | str
|
||||
If a symbol is possibly undeclared and definitely unbound, we currently do not raise an error. This
|
||||
seems inconsistent when compared to the case just above.
|
||||
|
||||
```py path=mod.py
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag():
|
||||
x: int
|
||||
a: int
|
||||
```
|
||||
|
||||
```py
|
||||
# TODO: this should raise an error. Once we fix this, update the section description and the table
|
||||
# on top of this document.
|
||||
from mod import x
|
||||
from mod import a
|
||||
|
||||
reveal_type(x) # revealed: int
|
||||
reveal_type(a) # revealed: int
|
||||
|
||||
# External modifications to `a` that violate the declared type are not allowed:
|
||||
# error: [invalid-assignment]
|
||||
a = None
|
||||
```
|
||||
|
||||
## Undeclared
|
||||
|
||||
### Undeclared but bound
|
||||
|
||||
We use the inferred type as the public type, if a symbol has no declared type.
|
||||
If a symbol is *undeclared*, we use the union of `Unknown` with the inferred type. Note that we
|
||||
treat this case differently from the case where a symbol is implicitly declared with `Unknown`,
|
||||
possibly due to the usage of an unknown name in the annotation:
|
||||
|
||||
```py path=mod.py
|
||||
x = 1
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
# Undeclared:
|
||||
a = 1
|
||||
|
||||
# Implicitly declared with `Unknown`, due to the usage of an unknown name in the annotation:
|
||||
b: SomeUnknownName = 1 # error: [unresolved-reference]
|
||||
```
|
||||
|
||||
```py
|
||||
from mod import x
|
||||
from mod import a, b
|
||||
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(a) # revealed: Unknown | Literal[1]
|
||||
reveal_type(b) # revealed: Unknown
|
||||
|
||||
# All external modifications of `a` are allowed:
|
||||
a = None
|
||||
```
|
||||
|
||||
### Undeclared and possibly unbound
|
||||
@@ -177,33 +245,45 @@ reveal_type(x) # revealed: Literal[1]
|
||||
If a symbol is undeclared and *possibly* unbound, we currently do not raise an error. This seems
|
||||
inconsistent when compared to the "possibly-undeclared-and-possibly-unbound" case.
|
||||
|
||||
```py path=mod.py
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag:
|
||||
x = 1
|
||||
a = 1
|
||||
b: SomeUnknownName = 1 # error: [unresolved-reference]
|
||||
```
|
||||
|
||||
```py
|
||||
# TODO: this should raise an error. Once we fix this, update the section description and the table
|
||||
# on top of this document.
|
||||
from mod import x
|
||||
from mod import a, b
|
||||
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(a) # revealed: Unknown | Literal[1]
|
||||
reveal_type(b) # revealed: Unknown
|
||||
|
||||
# All external modifications of `a` are allowed:
|
||||
a = None
|
||||
```
|
||||
|
||||
### Undeclared and unbound
|
||||
|
||||
If a symbol is undeclared *and* unbound, we infer `Unknown` and raise an error.
|
||||
|
||||
```py path=mod.py
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
if False:
|
||||
x: int = 1
|
||||
a: int = 1
|
||||
```
|
||||
|
||||
```py
|
||||
# error: [unresolved-import]
|
||||
from mod import x
|
||||
from mod import a
|
||||
|
||||
reveal_type(x) # revealed: Unknown
|
||||
reveal_type(a) # revealed: Unknown
|
||||
|
||||
# Modifications allowed in this case:
|
||||
a = None
|
||||
```
|
||||
|
||||
@@ -52,7 +52,7 @@ class NonCallable:
|
||||
__call__ = 1
|
||||
|
||||
a = NonCallable()
|
||||
# error: "Object of type `NonCallable` is not callable"
|
||||
# error: "Object of type `Unknown | Literal[1]` is not callable (due to union element `Literal[1]`)"
|
||||
reveal_type(a()) # revealed: Unknown
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ If we have an intersection type `A & B` and we get a definitive true/false answe
|
||||
types, we can infer that the result for the intersection type is also true/false:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Base: ...
|
||||
|
||||
class Child1(Base):
|
||||
|
||||
@@ -33,7 +33,7 @@ reveal_type(a >= b) # revealed: Literal[False]
|
||||
|
||||
Even when tuples have different lengths, comparisons should be handled appropriately.
|
||||
|
||||
```py path=different_length.py
|
||||
```py
|
||||
a = (1, 2, 3)
|
||||
b = (1, 2, 3, 4)
|
||||
|
||||
@@ -102,7 +102,7 @@ reveal_type(a >= b) # revealed: bool
|
||||
However, if the lexicographic comparison completes without reaching a point where str and int are
|
||||
compared, Python will still produce a result based on the prior elements.
|
||||
|
||||
```py path=short_circuit.py
|
||||
```py
|
||||
a = (1, 2)
|
||||
b = (999999, "hello")
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Comprehensions
|
||||
|
||||
## Basic comprehensions
|
||||
|
||||
```py
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
# revealed: int
|
||||
[reveal_type(x) for x in IntIterable()]
|
||||
|
||||
class IteratorOfIterables:
|
||||
def __next__(self) -> IntIterable:
|
||||
return IntIterable()
|
||||
|
||||
class IterableOfIterables:
|
||||
def __iter__(self) -> IteratorOfIterables:
|
||||
return IteratorOfIterables()
|
||||
|
||||
# revealed: tuple[int, IntIterable]
|
||||
[reveal_type((x, y)) for y in IterableOfIterables() for x in y]
|
||||
|
||||
# revealed: int
|
||||
{reveal_type(x): 0 for x in IntIterable()}
|
||||
|
||||
# revealed: int
|
||||
{0: reveal_type(x) for x in IntIterable()}
|
||||
```
|
||||
|
||||
## Nested comprehension
|
||||
|
||||
```py
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
# TODO: This could be a `tuple[int, int]` if we model that `y` can not be modified in the outer comprehension scope
|
||||
# revealed: tuple[int, Unknown | int]
|
||||
[[reveal_type((x, y)) for x in IntIterable()] for y in IntIterable()]
|
||||
```
|
||||
|
||||
## Comprehension referencing outer comprehension
|
||||
|
||||
```py
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
class IteratorOfIterables:
|
||||
def __next__(self) -> IntIterable:
|
||||
return IntIterable()
|
||||
|
||||
class IterableOfIterables:
|
||||
def __iter__(self) -> IteratorOfIterables:
|
||||
return IteratorOfIterables()
|
||||
|
||||
# TODO: This could be a `tuple[int, int]` (see above)
|
||||
# revealed: tuple[int, Unknown | IntIterable]
|
||||
[[reveal_type((x, y)) for x in y] for y in IterableOfIterables()]
|
||||
```
|
||||
|
||||
## Comprehension with unbound iterable
|
||||
|
||||
Iterating over an unbound iterable yields `Unknown`:
|
||||
|
||||
```py
|
||||
# error: [unresolved-reference] "Name `x` used when not defined"
|
||||
# revealed: Unknown
|
||||
[reveal_type(z) for z in x]
|
||||
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
# error: [not-iterable] "Object of type `int` is not iterable"
|
||||
# revealed: tuple[int, Unknown]
|
||||
[reveal_type((x, z)) for x in IntIterable() for z in x]
|
||||
```
|
||||
|
||||
## Starred expressions
|
||||
|
||||
Starred expressions must be iterable
|
||||
|
||||
```py
|
||||
class NotIterable: ...
|
||||
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self) -> Iterator: ...
|
||||
|
||||
# This is fine:
|
||||
x = [*Iterable()]
|
||||
|
||||
# error: [not-iterable] "Object of type `NotIterable` is not iterable"
|
||||
y = [*NotIterable()]
|
||||
```
|
||||
|
||||
## Async comprehensions
|
||||
|
||||
### Basic
|
||||
|
||||
```py
|
||||
class AsyncIterator:
|
||||
async def __anext__(self) -> int:
|
||||
return 42
|
||||
|
||||
class AsyncIterable:
|
||||
def __aiter__(self) -> AsyncIterator:
|
||||
return AsyncIterator()
|
||||
|
||||
# revealed: @Todo(async iterables/iterators)
|
||||
[reveal_type(x) async for x in AsyncIterable()]
|
||||
```
|
||||
|
||||
### Invalid async comprehension
|
||||
|
||||
This tests that we understand that `async` comprehensions do *not* work according to the synchronous
|
||||
iteration protocol
|
||||
|
||||
```py
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
# revealed: @Todo(async iterables/iterators)
|
||||
[reveal_type(x) async for x in Iterable()]
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# Comprehensions with invalid syntax
|
||||
|
||||
```py
|
||||
class IntIterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class IntIterable:
|
||||
def __iter__(self) -> IntIterator:
|
||||
return IntIterator()
|
||||
|
||||
# Missing 'in' keyword.
|
||||
|
||||
# It's reasonably clear here what they *meant* to write,
|
||||
# so we'll still infer the correct type:
|
||||
|
||||
# error: [invalid-syntax] "Expected 'in', found name"
|
||||
# revealed: int
|
||||
[reveal_type(a) for a IntIterable()]
|
||||
|
||||
|
||||
# Missing iteration variable
|
||||
|
||||
# error: [invalid-syntax] "Expected an identifier, but found a keyword 'in' that cannot be used here"
|
||||
# error: [invalid-syntax] "Expected 'in', found name"
|
||||
# error: [unresolved-reference]
|
||||
# revealed: Unknown
|
||||
[reveal_type(b) for in IntIterable()]
|
||||
|
||||
|
||||
# Missing iterable
|
||||
|
||||
# error: [invalid-syntax] "Expected an expression"
|
||||
# revealed: Unknown
|
||||
[reveal_type(c) for c in]
|
||||
|
||||
|
||||
# Missing 'in' keyword and missing iterable
|
||||
|
||||
# error: [invalid-syntax] "Expected 'in', found ']'"
|
||||
# revealed: Unknown
|
||||
[reveal_type(d) for d]
|
||||
```
|
||||
@@ -0,0 +1,199 @@
|
||||
# Descriptor protocol
|
||||
|
||||
[Descriptors] let objects customize attribute lookup, storage, and deletion.
|
||||
|
||||
A descriptor is an attribute value that has one of the methods in the descriptor protocol. Those
|
||||
methods are `__get__()`, `__set__()`, and `__delete__()`. If any of those methods are defined for an
|
||||
attribute, it is said to be a descriptor.
|
||||
|
||||
## Basic example
|
||||
|
||||
An introductory example, modeled after a [simple example] in the primer on descriptors, involving a
|
||||
descriptor that returns a constant value:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Ten:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal[10]:
|
||||
return 10
|
||||
|
||||
def __set__(self, instance: object, value: Literal[10]) -> None:
|
||||
pass
|
||||
|
||||
class C:
|
||||
ten = Ten()
|
||||
|
||||
c = C()
|
||||
|
||||
# TODO: this should be `Literal[10]`
|
||||
reveal_type(c.ten) # revealed: Unknown | Ten
|
||||
|
||||
# TODO: This should `Literal[10]`
|
||||
reveal_type(C.ten) # revealed: Unknown | Ten
|
||||
|
||||
# These are fine:
|
||||
c.ten = 10
|
||||
C.ten = 10
|
||||
|
||||
# TODO: Both of these should be errors
|
||||
c.ten = 11
|
||||
C.ten = 11
|
||||
```
|
||||
|
||||
## Different types for `__get__` and `__set__`
|
||||
|
||||
The return type of `__get__` and the value type of `__set__` can be different:
|
||||
|
||||
```py
|
||||
class FlexibleInt:
|
||||
def __init__(self):
|
||||
self._value: int | None = None
|
||||
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int | None:
|
||||
return self._value
|
||||
|
||||
def __set__(self, instance: object, value: int | str) -> None:
|
||||
self._value = int(value)
|
||||
|
||||
class C:
|
||||
flexible_int = FlexibleInt()
|
||||
|
||||
c = C()
|
||||
|
||||
# TODO: should be `int | None`
|
||||
reveal_type(c.flexible_int) # revealed: Unknown | FlexibleInt
|
||||
|
||||
c.flexible_int = 42 # okay
|
||||
c.flexible_int = "42" # also okay!
|
||||
|
||||
# TODO: should be `int | None`
|
||||
reveal_type(c.flexible_int) # revealed: Unknown | FlexibleInt
|
||||
|
||||
# TODO: should be an error
|
||||
c.flexible_int = None # not okay
|
||||
|
||||
# TODO: should be `int | None`
|
||||
reveal_type(c.flexible_int) # revealed: Unknown | FlexibleInt
|
||||
```
|
||||
|
||||
## Built-in `property` descriptor
|
||||
|
||||
The built-in `property` decorator creates a descriptor. The names for attribute reads/writes are
|
||||
determined by the return type of the `name` method and the parameter type of the setter,
|
||||
respectively.
|
||||
|
||||
```py
|
||||
class C:
|
||||
_name: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name or "Unset"
|
||||
# TODO: No diagnostic should be emitted here
|
||||
# error: [unresolved-attribute] "Type `Literal[name]` has no attribute `setter`"
|
||||
@name.setter
|
||||
def name(self, value: str | None) -> None:
|
||||
self._value = value
|
||||
|
||||
c = C()
|
||||
|
||||
reveal_type(c._name) # revealed: str | None
|
||||
|
||||
# Should be `str`
|
||||
reveal_type(c.name) # revealed: @Todo(bound method)
|
||||
|
||||
# Should be `builtins.property`
|
||||
reveal_type(C.name) # revealed: Literal[name]
|
||||
|
||||
# This is fine:
|
||||
c.name = "new"
|
||||
|
||||
c.name = None
|
||||
|
||||
# TODO: this should be an error
|
||||
c.name = 42
|
||||
```
|
||||
|
||||
## Built-in `classmethod` descriptor
|
||||
|
||||
Similarly to `property`, `classmethod` decorator creates an implicit descriptor that binds the first
|
||||
argument to the class instead of the instance.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, value: str) -> None:
|
||||
self._name: str = value
|
||||
|
||||
@classmethod
|
||||
def factory(cls, value: str) -> "C":
|
||||
return cls(value)
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
return cls.__name__
|
||||
|
||||
c1 = C.factory("test") # okay
|
||||
|
||||
# TODO: should be `C`
|
||||
reveal_type(c1) # revealed: @Todo(return type)
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(C.get_name()) # revealed: @Todo(return type)
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(C("42").get_name()) # revealed: @Todo(bound method)
|
||||
```
|
||||
|
||||
## Descriptors only work when used as class variables
|
||||
|
||||
From the descriptor guide:
|
||||
|
||||
> Descriptors only work when used as class variables. When put in instances, they have no effect.
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Ten:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal[10]:
|
||||
return 10
|
||||
|
||||
class C:
|
||||
def __init__(self):
|
||||
self.ten = Ten()
|
||||
|
||||
reveal_type(C().ten) # revealed: Unknown | Ten
|
||||
```
|
||||
|
||||
## Descriptors distinguishing between class and instance access
|
||||
|
||||
Overloads can be used to distinguish between when a descriptor is accessed on a class object and
|
||||
when it is accessed on an instance. A real-world example of this is the `__get__` method on
|
||||
`types.FunctionType`.
|
||||
|
||||
```py
|
||||
from typing_extensions import Literal, LiteralString, overload
|
||||
|
||||
class Descriptor:
|
||||
@overload
|
||||
def __get__(self, instance: None, owner: type, /) -> Literal["called on class object"]: ...
|
||||
@overload
|
||||
def __get__(self, instance: object, owner: type | None = None, /) -> Literal["called on instance"]: ...
|
||||
def __get__(self, instance, owner=None, /) -> LiteralString:
|
||||
if instance:
|
||||
return "called on instance"
|
||||
else:
|
||||
return "called on class object"
|
||||
|
||||
class C:
|
||||
d = Descriptor()
|
||||
|
||||
# TODO: should be `Literal["called on class object"]
|
||||
reveal_type(C.d) # revealed: Unknown | Descriptor
|
||||
|
||||
# TODO: should be `Literal["called on instance"]
|
||||
reveal_type(C().d) # revealed: Unknown | Descriptor
|
||||
```
|
||||
|
||||
[descriptors]: https://docs.python.org/3/howto/descriptor.html
|
||||
[simple example]: https://docs.python.org/3/howto/descriptor.html#simple-example-a-descriptor-that-returns-a-constant
|
||||
@@ -0,0 +1,21 @@
|
||||
# Unpacking
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
## Right hand side not iterable
|
||||
|
||||
```py
|
||||
a, b = 1 # error: [not-iterable]
|
||||
```
|
||||
|
||||
## Too many values to unpack
|
||||
|
||||
```py
|
||||
a, b = (1, 2, 3) # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
## Too few values to unpack
|
||||
|
||||
```py
|
||||
a, b = (1,) # error: [invalid-assignment]
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Unresolved import diagnostics
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
## Using `from` with an unresolvable module
|
||||
|
||||
This example demonstrates the diagnostic when a `from` style import is used with a module that could
|
||||
not be found:
|
||||
|
||||
```py
|
||||
from does_not_exist import add # error: [unresolved-import]
|
||||
|
||||
stat = add(10, 15)
|
||||
```
|
||||
|
||||
## Using `from` with too many leading dots
|
||||
|
||||
This example demonstrates the diagnostic when a `from` style import is used with a presumptively
|
||||
valid path, but where there are too many leading dots.
|
||||
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
def add(x, y):
|
||||
return x + y
|
||||
```
|
||||
|
||||
`package/subpackage/subsubpackage/__init__.py`:
|
||||
|
||||
```py
|
||||
from ....foo import add # error: [unresolved-import]
|
||||
|
||||
stat = add(10, 15)
|
||||
```
|
||||
|
||||
## Using `from` with an unknown current module
|
||||
|
||||
This is another case handled separately in Red Knot, where a `.` provokes relative module name
|
||||
resolution, but where the module name is not resolvable.
|
||||
|
||||
```py
|
||||
from .does_not_exist import add # error: [unresolved-import]
|
||||
|
||||
stat = add(10, 15)
|
||||
```
|
||||
|
||||
## Using `from` with an unknown nested module
|
||||
|
||||
Like the previous test, but with sub-modules to ensure the span is correct.
|
||||
|
||||
```py
|
||||
from .does_not_exist.foo.bar import add # error: [unresolved-import]
|
||||
|
||||
stat = add(10, 15)
|
||||
```
|
||||
|
||||
## Using `from` with a resolvable module but unresolvable item
|
||||
|
||||
This ensures that diagnostics for an unresolvable item inside a resolvable import highlight the item
|
||||
and not the entire `from ... import ...` statement.
|
||||
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
does_exist1 = 1
|
||||
does_exist2 = 2
|
||||
```
|
||||
|
||||
```py
|
||||
from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import]
|
||||
```
|
||||
|
||||
## An unresolvable import that does not use `from`
|
||||
|
||||
This ensures that an unresolvable `import ...` statement highlights just the module name and not the
|
||||
entire statement.
|
||||
|
||||
```py
|
||||
import does_not_exist # error: [unresolved-import]
|
||||
|
||||
x = does_not_exist.foo
|
||||
```
|
||||
@@ -78,7 +78,7 @@ def _(a: type[Unknown], b: type[Any]):
|
||||
Tuple types with the same elements are the same.
|
||||
|
||||
```py
|
||||
from typing_extensions import assert_type
|
||||
from typing_extensions import Any, assert_type
|
||||
|
||||
from knot_extensions import Unknown
|
||||
|
||||
|
||||
@@ -124,42 +124,49 @@ def _(e: Exception | type[Exception] | None):
|
||||
## Exception cause is not an exception
|
||||
|
||||
```py
|
||||
try:
|
||||
raise EOFError() from GeneratorExit # fine
|
||||
except:
|
||||
...
|
||||
def _():
|
||||
try:
|
||||
raise EOFError() from GeneratorExit # fine
|
||||
except:
|
||||
...
|
||||
|
||||
try:
|
||||
raise StopIteration from MemoryError() # fine
|
||||
except:
|
||||
...
|
||||
def _():
|
||||
try:
|
||||
raise StopIteration from MemoryError() # fine
|
||||
except:
|
||||
...
|
||||
|
||||
try:
|
||||
raise BufferError() from None # fine
|
||||
except:
|
||||
...
|
||||
def _():
|
||||
try:
|
||||
raise BufferError() from None # fine
|
||||
except:
|
||||
...
|
||||
|
||||
try:
|
||||
raise ZeroDivisionError from False # error: [invalid-raise]
|
||||
except:
|
||||
...
|
||||
def _():
|
||||
try:
|
||||
raise ZeroDivisionError from False # error: [invalid-raise]
|
||||
except:
|
||||
...
|
||||
|
||||
try:
|
||||
raise SystemExit from bool() # error: [invalid-raise]
|
||||
except:
|
||||
...
|
||||
def _():
|
||||
try:
|
||||
raise SystemExit from bool() # error: [invalid-raise]
|
||||
except:
|
||||
...
|
||||
|
||||
try:
|
||||
raise
|
||||
except KeyboardInterrupt as e: # fine
|
||||
reveal_type(e) # revealed: KeyboardInterrupt
|
||||
raise LookupError from e # fine
|
||||
def _():
|
||||
try:
|
||||
raise
|
||||
except KeyboardInterrupt as e: # fine
|
||||
reveal_type(e) # revealed: KeyboardInterrupt
|
||||
raise LookupError from e # fine
|
||||
|
||||
try:
|
||||
raise
|
||||
except int as e: # error: [invalid-exception-caught]
|
||||
reveal_type(e) # revealed: Unknown
|
||||
raise KeyError from e
|
||||
def _():
|
||||
try:
|
||||
raise
|
||||
except int as e: # error: [invalid-exception-caught]
|
||||
reveal_type(e) # revealed: Unknown
|
||||
raise KeyError from e
|
||||
|
||||
def _(e: Exception | type[Exception]):
|
||||
raise ModuleNotFoundError from e # fine
|
||||
|
||||
@@ -29,7 +29,7 @@ completing. The type of `x` at the beginning of the `except` suite in this examp
|
||||
`x = could_raise_returns_str()` redefinition, but we *also* could have jumped to the `except` suite
|
||||
*after* that redefinition.
|
||||
|
||||
```py path=union_type_inferred.py
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
@@ -50,10 +50,7 @@ reveal_type(x) # revealed: str | Literal[2]
|
||||
If `x` has the same type at the end of both branches, however, the branches unify and `x` is not
|
||||
inferred as having a union type following the `try`/`except` block:
|
||||
|
||||
```py path=branches_unify_to_non_union_type.py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
```py
|
||||
x = 1
|
||||
|
||||
try:
|
||||
@@ -133,7 +130,7 @@ the `except` suite:
|
||||
- At the end of `else`, `x == 3`
|
||||
- At the end of `except`, `x == 2`
|
||||
|
||||
```py path=single_except.py
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
@@ -161,9 +158,6 @@ been executed in its entirety, or the `try` suite and the `else` suite must both
|
||||
in their entireties:
|
||||
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
x = 1
|
||||
|
||||
try:
|
||||
@@ -192,7 +186,7 @@ A `finally` suite is *always* executed. As such, if we reach the `reveal_type` c
|
||||
this example, we know that `x` *must* have been reassigned to `2` during the `finally` suite. The
|
||||
type of `x` at the end of the example is therefore `Literal[2]`:
|
||||
|
||||
```py path=redef_in_finally.py
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
@@ -217,10 +211,7 @@ at this point than there were when we were inside the `finally` block.
|
||||
(Our current model does *not* correctly infer the types *inside* `finally` suites, however; this is
|
||||
still a TODO item for us.)
|
||||
|
||||
```py path=no_redef_in_finally.py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
```py
|
||||
x = 1
|
||||
|
||||
try:
|
||||
@@ -249,7 +240,7 @@ suites:
|
||||
exception raised in the `except` suite to cause us to jump to the `finally` suite before the
|
||||
`except` suite ran to completion
|
||||
|
||||
```py path=redef_in_finally.py
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
@@ -286,16 +277,7 @@ itself. (In some control-flow possibilities, some exceptions were merely *suspen
|
||||
`finally` suite; these lead to the scope's termination following the conclusion of the `finally`
|
||||
suite.)
|
||||
|
||||
```py path=no_redef_in_finally.py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
def could_raise_returns_bytes() -> bytes:
|
||||
return b"foo"
|
||||
|
||||
def could_raise_returns_bool() -> bool:
|
||||
return True
|
||||
|
||||
```py
|
||||
x = 1
|
||||
|
||||
try:
|
||||
@@ -317,16 +299,7 @@ reveal_type(x) # revealed: str | bool
|
||||
|
||||
An example with multiple `except` branches and a `finally` branch:
|
||||
|
||||
```py path=multiple_except_branches.py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
def could_raise_returns_bytes() -> bytes:
|
||||
return b"foo"
|
||||
|
||||
def could_raise_returns_bool() -> bool:
|
||||
return True
|
||||
|
||||
```py
|
||||
def could_raise_returns_memoryview() -> memoryview:
|
||||
return memoryview(b"")
|
||||
|
||||
@@ -364,7 +337,7 @@ If the exception handler has an `else` branch, we must also take into account th
|
||||
control flow could have jumped to the `finally` suite from partway through the `else` suite due to
|
||||
an exception raised *there*.
|
||||
|
||||
```py path=single_except_branch.py
|
||||
```py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
@@ -407,22 +380,7 @@ reveal_type(x) # revealed: bool | float
|
||||
|
||||
The same again, this time with multiple `except` branches:
|
||||
|
||||
```py path=multiple_except_branches.py
|
||||
def could_raise_returns_str() -> str:
|
||||
return "foo"
|
||||
|
||||
def could_raise_returns_bytes() -> bytes:
|
||||
return b"foo"
|
||||
|
||||
def could_raise_returns_bool() -> bool:
|
||||
return True
|
||||
|
||||
def could_raise_returns_memoryview() -> memoryview:
|
||||
return memoryview(b"")
|
||||
|
||||
def could_raise_returns_float() -> float:
|
||||
return 3.14
|
||||
|
||||
```py
|
||||
def could_raise_returns_range() -> range:
|
||||
return range(42)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class A:
|
||||
always_bound = 1
|
||||
always_bound: int = 1
|
||||
|
||||
if flag:
|
||||
union = 1
|
||||
@@ -13,14 +13,21 @@ def _(flag: bool):
|
||||
union = "abc"
|
||||
|
||||
if flag:
|
||||
possibly_unbound = "abc"
|
||||
union_declared: int = 1
|
||||
else:
|
||||
union_declared: str = "abc"
|
||||
|
||||
reveal_type(A.always_bound) # revealed: Literal[1]
|
||||
if flag:
|
||||
possibly_unbound: str = "abc"
|
||||
|
||||
reveal_type(A.union) # revealed: Literal[1, "abc"]
|
||||
reveal_type(A.always_bound) # revealed: int
|
||||
|
||||
reveal_type(A.union) # revealed: Unknown | Literal[1, "abc"]
|
||||
|
||||
reveal_type(A.union_declared) # revealed: int | str
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `possibly_unbound` on type `Literal[A]` is possibly unbound"
|
||||
reveal_type(A.possibly_unbound) # revealed: Literal["abc"]
|
||||
reveal_type(A.possibly_unbound) # revealed: str
|
||||
|
||||
# error: [unresolved-attribute] "Type `Literal[A]` has no attribute `non_existent`"
|
||||
reveal_type(A.non_existent) # revealed: Unknown
|
||||
|
||||
@@ -54,8 +54,10 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"]
|
||||
|
||||
## Evaluates to builtin
|
||||
|
||||
```py path=a.py
|
||||
redefined_builtin_bool = bool
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
redefined_builtin_bool: type[bool] = bool
|
||||
|
||||
def my_bool(x) -> bool:
|
||||
return True
|
||||
|
||||
@@ -28,6 +28,8 @@ reveal_type(1 if 0 else 2) # revealed: Literal[2]
|
||||
The test inside an if expression should not affect code outside of the expression.
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def _(flag: bool):
|
||||
x: Literal[42, "hello"] = 42 if flag else "hello"
|
||||
|
||||
|
||||
@@ -172,10 +172,10 @@ class IntUnion:
|
||||
def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]: ...
|
||||
|
||||
reveal_type(len(Auto())) # revealed: int
|
||||
reveal_type(len(Int())) # revealed: Literal[2]
|
||||
reveal_type(len(Int())) # revealed: int
|
||||
reveal_type(len(Str())) # revealed: int
|
||||
reveal_type(len(Tuple())) # revealed: int
|
||||
reveal_type(len(IntUnion())) # revealed: Literal[2, 32]
|
||||
reveal_type(len(IntUnion())) # revealed: int
|
||||
```
|
||||
|
||||
### Negative integers
|
||||
|
||||
@@ -20,7 +20,7 @@ wrong_innards: MyBox[int] = MyBox("five")
|
||||
# TODO reveal int, do not leak the typevar
|
||||
reveal_type(box.data) # revealed: T
|
||||
|
||||
reveal_type(MyBox.box_model_number) # revealed: Literal[695]
|
||||
reveal_type(MyBox.box_model_number) # revealed: Unknown | Literal[695]
|
||||
```
|
||||
|
||||
## Subclassing
|
||||
@@ -51,7 +51,7 @@ In type stubs, classes can reference themselves in their base class definitions.
|
||||
|
||||
This should hold true even with generics at play.
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class Seq[T]: ...
|
||||
|
||||
# TODO not error on the subscripting
|
||||
|
||||
@@ -9,7 +9,9 @@ E = D
|
||||
reveal_type(E) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -22,7 +24,9 @@ D = b.C
|
||||
reveal_type(D) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -34,10 +38,14 @@ import a.b
|
||||
reveal_type(a.b.C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -49,13 +57,19 @@ import a.b.c
|
||||
reveal_type(a.b.c.C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b/__init__.py
|
||||
`a/b/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b/c.py
|
||||
`a/b/c.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -67,10 +81,14 @@ import a.b as b
|
||||
reveal_type(b.C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -82,18 +100,34 @@ import a.b.c as c
|
||||
reveal_type(c.C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b/__init__.py
|
||||
`a/b/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b/c.py
|
||||
`a/b/c.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
## Unresolvable module import
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
|
||||
```
|
||||
|
||||
## Unresolvable submodule imports
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
# Topmost component resolvable, submodule not resolvable:
|
||||
import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
|
||||
@@ -102,5 +136,7 @@ import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
|
||||
import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`"
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
@@ -1,8 +1,78 @@
|
||||
# Importing builtin module
|
||||
# Builtins
|
||||
|
||||
## Importing builtin module
|
||||
|
||||
Builtin symbols can be explicitly imported:
|
||||
|
||||
```py
|
||||
import builtins
|
||||
|
||||
x = builtins.chr
|
||||
reveal_type(x) # revealed: Literal[chr]
|
||||
reveal_type(builtins.chr) # revealed: Literal[chr]
|
||||
```
|
||||
|
||||
## Implicit use of builtin
|
||||
|
||||
Or used implicitly:
|
||||
|
||||
```py
|
||||
reveal_type(chr) # revealed: Literal[chr]
|
||||
reveal_type(str) # revealed: Literal[str]
|
||||
```
|
||||
|
||||
## Builtin symbol from custom typeshed
|
||||
|
||||
If we specify a custom typeshed, we can use the builtin symbol from it, and no longer access the
|
||||
builtins from the "actual" vendored typeshed:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/builtins.pyi`:
|
||||
|
||||
```pyi
|
||||
class Custom: ...
|
||||
|
||||
custom_builtin: Custom
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/typing_extensions.pyi`:
|
||||
|
||||
```pyi
|
||||
def reveal_type(obj, /): ...
|
||||
```
|
||||
|
||||
```py
|
||||
reveal_type(custom_builtin) # revealed: Custom
|
||||
|
||||
# error: [unresolved-reference]
|
||||
reveal_type(str) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Unknown builtin (later defined)
|
||||
|
||||
`foo` has a type of `Unknown` in this example, as it relies on `bar` which has not been defined at
|
||||
that point:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/builtins.pyi`:
|
||||
|
||||
```pyi
|
||||
foo = bar
|
||||
bar = 1
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/typing_extensions.pyi`:
|
||||
|
||||
```pyi
|
||||
def reveal_type(obj, /): ...
|
||||
```
|
||||
|
||||
```py
|
||||
reveal_type(foo) # revealed: Unknown
|
||||
```
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Maybe unbound
|
||||
|
||||
```py path=maybe_unbound.py
|
||||
`maybe_unbound.py`:
|
||||
|
||||
```py
|
||||
def coinflip() -> bool:
|
||||
return True
|
||||
|
||||
@@ -23,13 +25,15 @@ reveal_type(y)
|
||||
# error: [possibly-unbound-import] "Member `y` of module `maybe_unbound` is possibly unbound"
|
||||
from maybe_unbound import x, y
|
||||
|
||||
reveal_type(x) # revealed: Literal[3]
|
||||
reveal_type(y) # revealed: Literal[3]
|
||||
reveal_type(x) # revealed: Unknown | Literal[3]
|
||||
reveal_type(y) # revealed: Unknown | Literal[3]
|
||||
```
|
||||
|
||||
## Maybe unbound annotated
|
||||
|
||||
```py path=maybe_unbound_annotated.py
|
||||
`maybe_unbound_annotated.py`:
|
||||
|
||||
```py
|
||||
def coinflip() -> bool:
|
||||
return True
|
||||
|
||||
@@ -52,7 +56,7 @@ Importing an annotated name prefers the declared type over the inferred type:
|
||||
# error: [possibly-unbound-import] "Member `y` of module `maybe_unbound_annotated` is possibly unbound"
|
||||
from maybe_unbound_annotated import x, y
|
||||
|
||||
reveal_type(x) # revealed: Literal[3]
|
||||
reveal_type(x) # revealed: Unknown | Literal[3]
|
||||
reveal_type(y) # revealed: int
|
||||
```
|
||||
|
||||
@@ -60,7 +64,9 @@ reveal_type(y) # revealed: int
|
||||
|
||||
Importing a possibly undeclared name still gives us its declared type:
|
||||
|
||||
```py path=maybe_undeclared.py
|
||||
`maybe_undeclared.py`:
|
||||
|
||||
```py
|
||||
def coinflip() -> bool:
|
||||
return True
|
||||
|
||||
@@ -76,11 +82,15 @@ reveal_type(x) # revealed: int
|
||||
|
||||
## Reimport
|
||||
|
||||
```py path=c.py
|
||||
`c.py`:
|
||||
|
||||
```py
|
||||
def f(): ...
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
def coinflip() -> bool:
|
||||
return True
|
||||
|
||||
@@ -102,11 +112,15 @@ reveal_type(f) # revealed: Literal[f, f]
|
||||
When we have a declared type in one path and only an inferred-from-definition type in the other, we
|
||||
should still be able to unify those:
|
||||
|
||||
```py path=c.pyi
|
||||
`c.pyi`:
|
||||
|
||||
```pyi
|
||||
x: int
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
def coinflip() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ import a.b
|
||||
reveal_type(a.b) # revealed: <module 'a.b'>
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
b = 42
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
b: int = 42
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
## Via from/import
|
||||
@@ -20,14 +24,18 @@ b = 42
|
||||
```py
|
||||
from a import b
|
||||
|
||||
reveal_type(b) # revealed: Literal[42]
|
||||
reveal_type(b) # revealed: int
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
b = 42
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
b: int = 42
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
## Via both
|
||||
@@ -40,11 +48,15 @@ reveal_type(b) # revealed: <module 'a.b'>
|
||||
reveal_type(a.b) # revealed: <module 'a.b'>
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
b = 42
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
b: int = 42
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
## Via both (backwards)
|
||||
@@ -60,16 +72,20 @@ sees the submodule as the value of `b` instead of the integer.
|
||||
from a import b
|
||||
import a.b
|
||||
|
||||
# Python would say `Literal[42]` for `b`
|
||||
# Python would say `int` for `b`
|
||||
reveal_type(b) # revealed: <module 'a.b'>
|
||||
reveal_type(a.b) # revealed: <module 'a.b'>
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
b = 42
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
b: int = 42
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
[from-import]: https://docs.python.org/3/reference/simple_stmts.html#the-import-statement
|
||||
|
||||
@@ -18,7 +18,9 @@ reveal_type(baz) # revealed: Unknown
|
||||
|
||||
## Unresolved import from resolved module
|
||||
|
||||
```py path=a.py
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py
|
||||
@@ -29,7 +31,9 @@ reveal_type(thing) # revealed: Unknown
|
||||
|
||||
## Resolved import of symbol from unresolved import
|
||||
|
||||
```py path=a.py
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
import foo as foo # error: "Cannot resolve import `foo`"
|
||||
|
||||
reveal_type(foo) # revealed: Unknown
|
||||
@@ -46,7 +50,9 @@ reveal_type(foo) # revealed: Unknown
|
||||
|
||||
## No implicit shadowing
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
x: int
|
||||
```
|
||||
|
||||
@@ -58,7 +64,9 @@ x = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]"
|
||||
|
||||
## Import cycle
|
||||
|
||||
```py path=a.py
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
class A: ...
|
||||
|
||||
reveal_type(A.__mro__) # revealed: tuple[Literal[A], Literal[object]]
|
||||
@@ -69,7 +77,9 @@ class C(b.B): ...
|
||||
reveal_type(C.__mro__) # revealed: tuple[Literal[C], Literal[B], Literal[A], Literal[object]]
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
from a import A
|
||||
|
||||
class B(A): ...
|
||||
|
||||
@@ -20,12 +20,16 @@ from a import b.c
|
||||
|
||||
# TODO: Should these be inferred as Unknown?
|
||||
reveal_type(b) # revealed: <module 'a.b'>
|
||||
reveal_type(b.c) # revealed: Literal[1]
|
||||
reveal_type(b.c) # revealed: int
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
c = 1
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
c: int = 1
|
||||
```
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
## Non-existent
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from .foo import X # error: [unresolved-import]
|
||||
|
||||
reveal_type(X) # revealed: Unknown
|
||||
@@ -13,49 +17,67 @@ reveal_type(X) # revealed: Unknown
|
||||
|
||||
## Simple
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
X = 42
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from .foo import X
|
||||
|
||||
reveal_type(X) # revealed: Literal[42]
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
## Dotted
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo/bar/baz.py
|
||||
X = 42
|
||||
`package/foo/bar/baz.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from .foo.bar.baz import X
|
||||
|
||||
reveal_type(X) # revealed: Literal[42]
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
## Bare to package
|
||||
|
||||
```py path=package/__init__.py
|
||||
X = 42
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from . import X
|
||||
|
||||
reveal_type(X) # revealed: Literal[42]
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
## Non-existent + bare to package
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from . import X # error: [unresolved-import]
|
||||
|
||||
reveal_type(X) # revealed: Unknown
|
||||
@@ -63,19 +85,25 @@ reveal_type(X) # revealed: Unknown
|
||||
|
||||
## Dunder init
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
from .foo import X
|
||||
|
||||
reveal_type(X) # revealed: Literal[42]
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
X = 42
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
## Non-existent + dunder init
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
from .foo import X # error: [unresolved-import]
|
||||
|
||||
reveal_type(X) # revealed: Unknown
|
||||
@@ -83,29 +111,41 @@ reveal_type(X) # revealed: Unknown
|
||||
|
||||
## Long relative import
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
X = 42
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/subpackage/subsubpackage/bar.py
|
||||
`package/subpackage/subsubpackage/bar.py`:
|
||||
|
||||
```py
|
||||
from ...foo import X
|
||||
|
||||
reveal_type(X) # revealed: Literal[42]
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
## Unbound symbol
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
x # error: [unresolved-reference]
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from .foo import x # error: [unresolved-import]
|
||||
|
||||
reveal_type(x) # revealed: Unknown
|
||||
@@ -113,17 +153,23 @@ reveal_type(x) # revealed: Unknown
|
||||
|
||||
## Bare to module
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
X = 42
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from . import foo
|
||||
|
||||
reveal_type(foo.X) # revealed: Literal[42]
|
||||
reveal_type(foo.X) # revealed: int
|
||||
```
|
||||
|
||||
## Non-existent + bare to module
|
||||
@@ -131,10 +177,14 @@ reveal_type(foo.X) # revealed: Literal[42]
|
||||
This test verifies that we emit an error when we try to import a symbol that is neither a submodule
|
||||
nor an attribute of `package`.
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from . import foo # error: [unresolved-import]
|
||||
|
||||
reveal_type(foo) # revealed: Unknown
|
||||
@@ -148,17 +198,53 @@ submodule when that submodule name appears in the `imported_modules` set. That m
|
||||
that are imported via `from...import` are not visible to our type inference if you also access that
|
||||
submodule via the attribute on its parent package.
|
||||
|
||||
```py path=package/__init__.py
|
||||
`package/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=package/foo.py
|
||||
X = 42
|
||||
`package/foo.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
```py path=package/bar.py
|
||||
`package/bar.py`:
|
||||
|
||||
```py
|
||||
from . import foo
|
||||
import package
|
||||
|
||||
# error: [unresolved-attribute] "Type `<module 'package'>` has no attribute `foo`"
|
||||
reveal_type(package.foo.X) # revealed: Unknown
|
||||
```
|
||||
|
||||
## In the src-root
|
||||
|
||||
`parser.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
`__main__.py`:
|
||||
|
||||
```py
|
||||
from .parser import X
|
||||
|
||||
reveal_type(X) # revealed: int
|
||||
```
|
||||
|
||||
## Beyond the src-root
|
||||
|
||||
`parser.py`:
|
||||
|
||||
```py
|
||||
X: int = 42
|
||||
```
|
||||
|
||||
`__main__.py`:
|
||||
|
||||
```py
|
||||
from ..parser import X # error: [unresolved-import]
|
||||
```
|
||||
|
||||
@@ -9,7 +9,9 @@ y = x
|
||||
reveal_type(y) # revealed: int
|
||||
```
|
||||
|
||||
```py path=b.pyi
|
||||
`b.pyi`:
|
||||
|
||||
```pyi
|
||||
x: int
|
||||
```
|
||||
|
||||
@@ -22,6 +24,8 @@ y = x
|
||||
reveal_type(y) # revealed: int
|
||||
```
|
||||
|
||||
```py path=b.py
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
@@ -32,10 +32,14 @@ reveal_type(a.b.C) # revealed: Literal[C]
|
||||
import a.b
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
@@ -55,14 +59,20 @@ reveal_type(a.b) # revealed: <module 'a.b'>
|
||||
reveal_type(a.b.C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
```py path=a/__init__.py
|
||||
`a/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=a/b.py
|
||||
`a/b.py`:
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
```
|
||||
|
||||
```py path=q.py
|
||||
`q.py`:
|
||||
|
||||
```py
|
||||
import a as a
|
||||
import a.b as b
|
||||
```
|
||||
@@ -83,18 +93,26 @@ reveal_type(sub.b) # revealed: <module 'sub.b'>
|
||||
reveal_type(attr.b) # revealed: <module 'attr.b'>
|
||||
```
|
||||
|
||||
```py path=sub/__init__.py
|
||||
`sub/__init__.py`:
|
||||
|
||||
```py
|
||||
b = 1
|
||||
```
|
||||
|
||||
```py path=sub/b.py
|
||||
`sub/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
```py path=attr/__init__.py
|
||||
`attr/__init__.py`:
|
||||
|
||||
```py
|
||||
from . import b as _
|
||||
|
||||
b = 1
|
||||
```
|
||||
|
||||
```py path=attr/b.py
|
||||
`attr/b.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
@@ -808,6 +808,7 @@ Dynamic types do not cancel each other out. Intersecting an unknown set of value
|
||||
of another unknown set of values is not necessarily empty, so we keep the positive contribution:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from knot_extensions import Intersection, Not, Unknown
|
||||
|
||||
def any(
|
||||
@@ -830,6 +831,7 @@ def unknown(
|
||||
We currently do not simplify mixed dynamic types, but might consider doing so in the future:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from knot_extensions import Intersection, Not, Unknown
|
||||
|
||||
def mixed(
|
||||
|
||||
@@ -31,7 +31,9 @@ reveal_type(TC) # revealed: Literal[True]
|
||||
Make sure we only use our special handling for `typing.TYPE_CHECKING` and not for other constants
|
||||
with the same name:
|
||||
|
||||
```py path=constants.py
|
||||
`constants.py`:
|
||||
|
||||
```py
|
||||
TYPE_CHECKING: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ reveal_type(x)
|
||||
def _(flag: bool):
|
||||
class NotIterable:
|
||||
if flag:
|
||||
__iter__ = 1
|
||||
__iter__: int = 1
|
||||
else:
|
||||
__iter__ = None
|
||||
__iter__: None = None
|
||||
|
||||
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
|
||||
pass
|
||||
@@ -135,7 +135,7 @@ for x in nonsense: # error: "Object of type `Literal[123]` is not iterable"
|
||||
class NotIterable:
|
||||
def __getitem__(self, key: int) -> int:
|
||||
return 42
|
||||
__iter__ = None
|
||||
__iter__: None = None
|
||||
|
||||
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
|
||||
pass
|
||||
|
||||
@@ -13,6 +13,8 @@ python-version = "3.10"
|
||||
Here, we simply make sure that we pick up the global configuration from the root section:
|
||||
|
||||
```py
|
||||
import sys
|
||||
|
||||
reveal_type(sys.version_info[:2] == (3, 10)) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -25,6 +27,8 @@ reveal_type(sys.version_info[:2] == (3, 10)) # revealed: Literal[True]
|
||||
The same should work for arbitrarily nested sections:
|
||||
|
||||
```py
|
||||
import sys
|
||||
|
||||
reveal_type(sys.version_info[:2] == (3, 10)) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -38,6 +42,8 @@ python-version = "3.11"
|
||||
```
|
||||
|
||||
```py
|
||||
import sys
|
||||
|
||||
reveal_type(sys.version_info[:2] == (3, 11)) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -46,6 +52,8 @@ reveal_type(sys.version_info[:2] == (3, 11)) # revealed: Literal[True]
|
||||
There is no global state. This section should again use the root configuration:
|
||||
|
||||
```py
|
||||
import sys
|
||||
|
||||
reveal_type(sys.version_info[:2] == (3, 10)) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -63,5 +71,7 @@ python-version = "3.12"
|
||||
### Grandchild
|
||||
|
||||
```py
|
||||
import sys
|
||||
|
||||
reveal_type(sys.version_info[:2] == (3, 12)) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Custom typeshed
|
||||
|
||||
The `environment.typeshed` configuration option can be used to specify a custom typeshed directory
|
||||
for Markdown-based tests. Custom typeshed stubs can then be placed in the specified directory using
|
||||
fenced code blocks with language `pyi`, and will be used instead of the vendored copy of typeshed.
|
||||
|
||||
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, it will be automatically created with
|
||||
entries enabling all specified `<typeshed-root>/stdlib` files for all supported Python versions.
|
||||
|
||||
## Basic example (auto-generated `VERSIONS` file)
|
||||
|
||||
First, we specify `/typeshed` as the custom typeshed directory:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
We can then place custom stub files in `/typeshed/stdlib`, for example:
|
||||
|
||||
`/typeshed/stdlib/builtins.pyi`:
|
||||
|
||||
```pyi
|
||||
class BuiltinClass: ...
|
||||
|
||||
builtin_symbol: BuiltinClass
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/sys/__init__.pyi`:
|
||||
|
||||
```pyi
|
||||
version = "my custom Python"
|
||||
```
|
||||
|
||||
And finally write a normal Python code block that makes use of the custom stubs:
|
||||
|
||||
```py
|
||||
b: BuiltinClass = builtin_symbol
|
||||
|
||||
class OtherClass: ...
|
||||
|
||||
o: OtherClass = builtin_symbol # error: [invalid-assignment]
|
||||
|
||||
# Make sure that 'sys' has a proper entry in the auto-generated 'VERSIONS' file
|
||||
import sys
|
||||
```
|
||||
|
||||
## Custom `VERSIONS` file
|
||||
|
||||
If we want to specify a custom `VERSIONS` file, we can do so by creating a fenced code block with
|
||||
language `text`. In the following test, we set the Python version to `3.10` and then make sure that
|
||||
we can *not* import `new_module` with a version requirement of `3.11-`:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.10"
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/old_module.pyi`:
|
||||
|
||||
```pyi
|
||||
class OldClass: ...
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/new_module.pyi`:
|
||||
|
||||
```pyi
|
||||
class NewClass: ...
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/VERSIONS`:
|
||||
|
||||
```text
|
||||
old_module: 3.0-
|
||||
new_module: 3.11-
|
||||
```
|
||||
|
||||
```py
|
||||
from old_module import OldClass
|
||||
|
||||
# error: [unresolved-import] "Cannot resolve import `new_module`"
|
||||
from new_module import NewClass
|
||||
```
|
||||
|
||||
## Using `reveal_type` with a custom typeshed
|
||||
|
||||
When providing a custom typeshed directory, basic things like `reveal_type` will stop working
|
||||
because we rely on being able to import it from `typing_extensions`. The actual definition of
|
||||
`reveal_type` in typeshed is slightly involved (depends on generics, `TypeVar`, etc.), but a very
|
||||
simple untyped definition is enough to make `reveal_type` work in tests:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/typing_extensions.pyi`:
|
||||
|
||||
```pyi
|
||||
def reveal_type(obj, /): ...
|
||||
```
|
||||
|
||||
```py
|
||||
reveal_type(()) # revealed: tuple[()]
|
||||
```
|
||||
@@ -205,7 +205,7 @@ reveal_type(D.__class__) # revealed: Literal[SignatureMismatch]
|
||||
|
||||
Retrieving the metaclass of a cyclically defined class should not cause an infinite loop.
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class A(B): ... # error: [cyclic-class-definition]
|
||||
class B(C): ... # error: [cyclic-class-definition]
|
||||
class C(A): ... # error: [cyclic-class-definition]
|
||||
|
||||
@@ -347,7 +347,7 @@ reveal_type(unknown_object.__mro__) # revealed: Unknown
|
||||
|
||||
These are invalid, but we need to be able to handle them gracefully without panicking.
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class Foo(Foo): ... # error: [cyclic-class-definition]
|
||||
|
||||
reveal_type(Foo) # revealed: Literal[Foo]
|
||||
@@ -365,7 +365,7 @@ reveal_type(Boz.__mro__) # revealed: tuple[Literal[Boz], Unknown, Literal[objec
|
||||
|
||||
These are similarly unlikely, but we still shouldn't crash:
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class Foo(Bar): ... # error: [cyclic-class-definition]
|
||||
class Bar(Baz): ... # error: [cyclic-class-definition]
|
||||
class Baz(Foo): ... # error: [cyclic-class-definition]
|
||||
@@ -377,7 +377,7 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec
|
||||
|
||||
## Classes with cycles in their MROs, and multiple inheritance
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class Spam: ...
|
||||
class Foo(Bar): ... # error: [cyclic-class-definition]
|
||||
class Bar(Baz): ... # error: [cyclic-class-definition]
|
||||
@@ -390,7 +390,7 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec
|
||||
|
||||
## Classes with cycles in their MRO, and a sub-graph
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class FooCycle(BarCycle): ... # error: [cyclic-class-definition]
|
||||
class Foo: ...
|
||||
class BarCycle(FooCycle): ... # error: [cyclic-class-definition]
|
||||
|
||||
@@ -57,6 +57,8 @@ def _(flag1: bool, flag2: bool, flag3: bool, flag4: bool):
|
||||
## Multiple predicates
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class A: ...
|
||||
x: A | None | Literal[1] = A() if flag1 else None if flag2 else 1
|
||||
@@ -67,6 +69,8 @@ def _(flag1: bool, flag2: bool):
|
||||
## Mix of `and` and `or`
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class A: ...
|
||||
x: A | None | Literal[1] = A() if flag1 else None if flag2 else 1
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
## Value Literals
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def foo() -> Literal[0, -1, True, False, "", "foo", b"", b"bar", None] | tuple[()]:
|
||||
return 0
|
||||
|
||||
@@ -123,6 +125,8 @@ always returns a fixed value.
|
||||
These types can always be fully narrowed in boolean contexts, as shown below:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class T:
|
||||
def __bool__(self) -> Literal[True]:
|
||||
return True
|
||||
@@ -149,6 +153,8 @@ else:
|
||||
## Narrowing Complex Intersection and Union
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class A: ...
|
||||
class B: ...
|
||||
|
||||
@@ -181,6 +187,8 @@ if isinstance(x, str) and not isinstance(x, B):
|
||||
## Narrowing Multiple Variables
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def f(x: Literal[0, 1], y: Literal["", "hello"]):
|
||||
if x and y and not x and not y:
|
||||
reveal_type(x) # revealed: Never
|
||||
@@ -222,6 +230,8 @@ reveal_type(y) # revealed: A
|
||||
## Truthiness of classes
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class MetaAmbiguous(type):
|
||||
def __bool__(self) -> bool: ...
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ def _(x: str | int):
|
||||
class A: ...
|
||||
class B: ...
|
||||
|
||||
alias_for_type = type
|
||||
|
||||
def _(x: A | B):
|
||||
alias_for_type = type
|
||||
|
||||
if alias_for_type(x) is A:
|
||||
reveal_type(x) # revealed: A
|
||||
```
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
Regression test for [this issue](https://github.com/astral-sh/ruff/issues/14334).
|
||||
|
||||
```py path=base.py
|
||||
`base.py`:
|
||||
|
||||
```py
|
||||
# error: [invalid-base]
|
||||
class Base(2): ...
|
||||
```
|
||||
|
||||
```py path=a.py
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
# No error here
|
||||
from base import Base
|
||||
```
|
||||
|
||||
@@ -10,10 +10,10 @@ def returns_bool() -> bool:
|
||||
return True
|
||||
|
||||
if returns_bool():
|
||||
chr = 1
|
||||
chr: int = 1
|
||||
|
||||
def f():
|
||||
reveal_type(chr) # revealed: Literal[chr] | Literal[1]
|
||||
reveal_type(chr) # revealed: int | Literal[chr]
|
||||
```
|
||||
|
||||
## Conditionally global or builtin, with annotation
|
||||
@@ -28,5 +28,5 @@ if returns_bool():
|
||||
chr: int = 1
|
||||
|
||||
def f():
|
||||
reveal_type(chr) # revealed: Literal[chr] | int
|
||||
reveal_type(chr) # revealed: int | Literal[chr]
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ def foo():
|
||||
However, three attributes on `types.ModuleType` are not present as implicit module globals; these
|
||||
are excluded:
|
||||
|
||||
```py path=unbound_dunders.py
|
||||
```py
|
||||
# error: [unresolved-reference]
|
||||
# revealed: Unknown
|
||||
reveal_type(__getattr__)
|
||||
@@ -54,10 +54,10 @@ inside the module:
|
||||
import typing
|
||||
|
||||
reveal_type(typing.__name__) # revealed: str
|
||||
reveal_type(typing.__init__) # revealed: Literal[__init__]
|
||||
reveal_type(typing.__init__) # revealed: @Todo(bound method)
|
||||
|
||||
# These come from `builtins.object`, not `types.ModuleType`:
|
||||
reveal_type(typing.__eq__) # revealed: Literal[__eq__]
|
||||
reveal_type(typing.__eq__) # revealed: @Todo(bound method)
|
||||
|
||||
reveal_type(typing.__class__) # revealed: Literal[ModuleType]
|
||||
|
||||
@@ -70,9 +70,7 @@ Typeshed includes a fake `__getattr__` method in the stub for `types.ModuleType`
|
||||
dynamic imports; but we ignore that for module-literal types where we know exactly which module
|
||||
we're dealing with:
|
||||
|
||||
```py path=__getattr__.py
|
||||
import typing
|
||||
|
||||
```py
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(typing.__getattr__) # revealed: Unknown
|
||||
```
|
||||
@@ -83,13 +81,17 @@ It's impossible to override the `__dict__` attribute of `types.ModuleType` insta
|
||||
module; we should prioritise the attribute in the `types.ModuleType` stub over a variable named
|
||||
`__dict__` in the module's global namespace:
|
||||
|
||||
```py path=foo.py
|
||||
`foo.py`:
|
||||
|
||||
```py
|
||||
__dict__ = "foo"
|
||||
|
||||
reveal_type(__dict__) # revealed: Literal["foo"]
|
||||
```
|
||||
|
||||
```py path=bar.py
|
||||
`bar.py`:
|
||||
|
||||
```py
|
||||
import foo
|
||||
from foo import __dict__ as foo_dict
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
def f():
|
||||
x = 1
|
||||
def g():
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
## Two levels up
|
||||
@@ -16,7 +16,7 @@ def f():
|
||||
x = 1
|
||||
def g():
|
||||
def h():
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
## Skips class scope
|
||||
@@ -28,7 +28,7 @@ def f():
|
||||
class C:
|
||||
x = 2
|
||||
def g():
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
## Skips annotation-only assignment
|
||||
@@ -41,5 +41,16 @@ def f():
|
||||
# name is otherwise not defined; maybe should be an error?
|
||||
x: int
|
||||
def h():
|
||||
reveal_type(x) # revealed: Literal[1]
|
||||
reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
## Implicit global in function
|
||||
|
||||
A name reference to a never-defined symbol in a function is implicitly a global lookup.
|
||||
|
||||
```py
|
||||
x = 1
|
||||
|
||||
def f():
|
||||
reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
@@ -17,8 +17,8 @@ class C:
|
||||
x = 2
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C]` is possibly unbound"
|
||||
reveal_type(C.x) # revealed: Literal[2]
|
||||
reveal_type(C.y) # revealed: Literal[1]
|
||||
reveal_type(C.x) # revealed: Unknown | Literal[2]
|
||||
reveal_type(C.y) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
## Possibly unbound in class and global scope
|
||||
@@ -37,7 +37,7 @@ class C:
|
||||
# error: [possibly-unresolved-reference]
|
||||
y = x
|
||||
|
||||
reveal_type(C.y) # revealed: Literal[1, "abc"]
|
||||
reveal_type(C.y) # revealed: Unknown | Literal[1, "abc"]
|
||||
```
|
||||
|
||||
## Unbound function local
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
Parameter `x` of type `str` is shadowed and reassigned with a new `int` value inside the function.
|
||||
No diagnostics should be generated.
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
def f(x: str):
|
||||
x: int = int(x)
|
||||
```
|
||||
|
||||
## Implicit error
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
def f(): ...
|
||||
|
||||
f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explicit if this is intentional"
|
||||
@@ -20,7 +20,7 @@ f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explici
|
||||
|
||||
## Explicit shadowing
|
||||
|
||||
```py path=a.py
|
||||
```py
|
||||
def f(): ...
|
||||
|
||||
f: int = 1
|
||||
|
||||
@@ -182,3 +182,34 @@ class C(A, B): ...
|
||||
# False negative: [incompatible-slots]
|
||||
class A(int, str): ...
|
||||
```
|
||||
|
||||
### Diagnostic if `__slots__` is externally modified
|
||||
|
||||
We special-case type inference for `__slots__` and return the pure inferred type, even if the symbol
|
||||
is not declared — a case in which we union with `Unknown` for other public symbols. The reason for
|
||||
this is that `__slots__` has a special handling in the runtime. Modifying it externally is actually
|
||||
allowed, but those changes do not take effect. If you have a class `C` with `__slots__ = ("foo",)`
|
||||
and externally set `C.__slots__ = ("bar",)`, you still can't access `C.bar`. And you can still
|
||||
access `C.foo`. We therefore issue a diagnostic for such assignments:
|
||||
|
||||
```py
|
||||
class A:
|
||||
__slots__ = ("a",)
|
||||
|
||||
# Modifying `__slots__` from within the class body is fine:
|
||||
__slots__ = ("a", "b")
|
||||
|
||||
# No `Unknown` here:
|
||||
reveal_type(A.__slots__) # revealed: tuple[Literal["a"], Literal["b"]]
|
||||
|
||||
# But modifying it externally is not:
|
||||
|
||||
# error: [invalid-assignment]
|
||||
A.__slots__ = ("a",)
|
||||
|
||||
# error: [invalid-assignment]
|
||||
A.__slots__ = ("a", "b_new")
|
||||
|
||||
# error: [invalid-assignment]
|
||||
A.__slots__ = ("a", "b", "c")
|
||||
```
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: basic.md - Structures - Unresolvable module import
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:8
|
||||
|
|
||||
1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `zqzqzqzqzqzqzq`
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: basic.md - Structures - Unresolvable submodule imports
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | # Topmost component resolvable, submodule not resolvable:
|
||||
2 | import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
|
||||
3 |
|
||||
4 | # Topmost component unresolvable:
|
||||
5 | import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`"
|
||||
```
|
||||
|
||||
## a/__init__.py
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:2:8
|
||||
|
|
||||
1 | # Topmost component resolvable, submodule not resolvable:
|
||||
2 | import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
|
||||
| ^^^^^ Cannot resolve import `a.foo`
|
||||
3 |
|
||||
4 | # Topmost component unresolvable:
|
||||
|
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:5:8
|
||||
|
|
||||
4 | # Topmost component unresolvable:
|
||||
5 | import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`"
|
||||
| ^^^^^ Cannot resolve import `b.foo`
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unpacking.md - Unpacking - Right hand side not iterable
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | a, b = 1 # error: [not-iterable]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:not-iterable
|
||||
--> /src/mdtest_snippet.py:1:8
|
||||
|
|
||||
1 | a, b = 1 # error: [not-iterable]
|
||||
| ^ Object of type `Literal[1]` is not iterable
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unpacking.md - Unpacking - Too few values to unpack
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | a, b = (1,) # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:invalid-assignment
|
||||
--> /src/mdtest_snippet.py:1:1
|
||||
|
|
||||
1 | a, b = (1,) # error: [invalid-assignment]
|
||||
| ^^^^ Not enough values to unpack (expected 2, got 1)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unpacking.md - Unpacking - Too many values to unpack
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unpacking.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | a, b = (1, 2, 3) # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:invalid-assignment
|
||||
--> /src/mdtest_snippet.py:1:1
|
||||
|
|
||||
1 | a, b = (1, 2, 3) # error: [invalid-assignment]
|
||||
| ^^^^ Too many values to unpack (expected 2, got 3)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - An unresolvable import that does not use `from`
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | import does_not_exist # error: [unresolved-import]
|
||||
2 |
|
||||
3 | x = does_not_exist.foo
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:8
|
||||
|
|
||||
1 | import does_not_exist # error: [unresolved-import]
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
||||
2 |
|
||||
3 | x = does_not_exist.foo
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - Using `from` with a resolvable module but unresolvable item
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## a.py
|
||||
|
||||
```
|
||||
1 | does_exist1 = 1
|
||||
2 | does_exist2 = 2
|
||||
```
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import]
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:28
|
||||
|
|
||||
1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import]
|
||||
| ^^^^^^^^^^^^^^ Module `a` has no member `does_not_exist`
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - Using `from` with an unknown current module
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from .does_not_exist import add # error: [unresolved-import]
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:7
|
||||
|
|
||||
1 | from .does_not_exist import add # error: [unresolved-import]
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist`
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - Using `from` with an unknown nested module
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from .does_not_exist.foo.bar import add # error: [unresolved-import]
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:7
|
||||
|
|
||||
1 | from .does_not_exist.foo.bar import add # error: [unresolved-import]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist.foo.bar`
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - Using `from` with an unresolvable module
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from does_not_exist import add # error: [unresolved-import]
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/mdtest_snippet.py:1:6
|
||||
|
|
||||
1 | from does_not_exist import add # error: [unresolved-import]
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: unresolved_import.md - Unresolved import diagnostics - Using `from` with too many leading dots
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unresolved_import.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## package/__init__.py
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## package/foo.py
|
||||
|
||||
```
|
||||
1 | def add(x, y):
|
||||
2 | return x + y
|
||||
```
|
||||
|
||||
## package/subpackage/subsubpackage/__init__.py
|
||||
|
||||
```
|
||||
1 | from ....foo import add # error: [unresolved-import]
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:unresolved-import
|
||||
--> /src/package/subpackage/subsubpackage/__init__.py:1:10
|
||||
|
|
||||
1 | from ....foo import add # error: [unresolved-import]
|
||||
| ^^^ Cannot resolve import `....foo`
|
||||
2 |
|
||||
3 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
```
|
||||
@@ -7,35 +7,36 @@ branches whose conditions we can statically determine to be always true or alway
|
||||
useful for `sys.version_info` branches, which can make new features available based on the Python
|
||||
version:
|
||||
|
||||
```py path=module1.py
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
SomeFeature = "available"
|
||||
```
|
||||
|
||||
If we can statically determine that the condition is always true, then we can also understand that
|
||||
`SomeFeature` is always bound, without raising any errors:
|
||||
|
||||
```py path=test1.py
|
||||
from module1 import SomeFeature
|
||||
```py
|
||||
import sys
|
||||
|
||||
# SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
|
||||
reveal_type(SomeFeature) # revealed: Literal["available"]
|
||||
class C:
|
||||
if sys.version_info >= (3, 9):
|
||||
SomeFeature: str = "available"
|
||||
|
||||
# C.SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
|
||||
reveal_type(C.SomeFeature) # revealed: str
|
||||
```
|
||||
|
||||
Another scenario where this is useful is for `typing.TYPE_CHECKING` branches, which are often used
|
||||
for conditional imports:
|
||||
|
||||
```py path=module2.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
class SomeType: ...
|
||||
```
|
||||
|
||||
```py path=test2.py
|
||||
`main.py`:
|
||||
|
||||
```py
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from module2 import SomeType
|
||||
from module import SomeType
|
||||
|
||||
# `SomeType` is unconditionally available here for type checkers:
|
||||
def f(s: SomeType) -> None: ...
|
||||
@@ -167,7 +168,11 @@ statically known conditions, but here, we show that the results are truly based
|
||||
not some special handling of specific conditions in semantic index building. We use two modules to
|
||||
demonstrate this, since semantic index building is inherently single-module:
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class AlwaysTrue:
|
||||
def __bool__(self) -> Literal[True]:
|
||||
return True
|
||||
@@ -1424,7 +1429,9 @@ def f():
|
||||
|
||||
#### Always false, unbound
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
if False:
|
||||
symbol = 1
|
||||
```
|
||||
@@ -1436,7 +1443,9 @@ from module import symbol
|
||||
|
||||
#### Always true, bound
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
if True:
|
||||
symbol = 1
|
||||
```
|
||||
@@ -1448,7 +1457,9 @@ from module import symbol
|
||||
|
||||
#### Ambiguous, possibly unbound
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
|
||||
@@ -1463,7 +1474,9 @@ from module import symbol
|
||||
|
||||
#### Always false, undeclared
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
if False:
|
||||
symbol: int
|
||||
```
|
||||
@@ -1477,7 +1490,9 @@ reveal_type(symbol) # revealed: Unknown
|
||||
|
||||
#### Always true, declared
|
||||
|
||||
```py path=module.py
|
||||
`module.py`:
|
||||
|
||||
```py
|
||||
if True:
|
||||
symbol: int
|
||||
```
|
||||
@@ -1487,37 +1502,6 @@ if True:
|
||||
from module import symbol
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
We currently have a limitation in the complexity (depth) of the visibility constraints that are
|
||||
supported. This is to avoid pathological cases that would require us to recurse deeply.
|
||||
|
||||
```py
|
||||
x = 1
|
||||
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or (x := 2) # fmt: skip
|
||||
|
||||
# This still works fine:
|
||||
reveal_type(x) # revealed: Literal[2]
|
||||
|
||||
y = 1
|
||||
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or False or \
|
||||
False or False or False or (y := 2) # fmt: skip
|
||||
|
||||
# TODO: This should ideally be `Literal[2]` as well:
|
||||
reveal_type(y) # revealed: Literal[1, 2]
|
||||
```
|
||||
|
||||
## Unsupported features
|
||||
|
||||
We do not support full unreachable code analysis yet. We also raise diagnostics from
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
In type stubs, classes can reference themselves in their base class definitions. For example, in
|
||||
`typeshed`, we have `class str(Sequence[str]): ...`.
|
||||
|
||||
```py path=a.pyi
|
||||
```pyi
|
||||
class Foo[T]: ...
|
||||
|
||||
# TODO: actually is subscriptable
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user