Compare commits

..

1 Commits

Author SHA1 Message Date
Micha Reiser
fcde25773d Avoid cloning live declarations 2025-01-11 11:23:25 +01:00
1907 changed files with 27176 additions and 42603 deletions

View File

@@ -6,10 +6,3 @@ 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"

4
.gitattributes vendored
View File

@@ -14,7 +14,5 @@ crates/ruff_python_parser/resources/invalid/re_lex_logical_token_mac_eol.py text
crates/ruff_python_parser/resources/inline linguist-generated=true
ruff.schema.json -diff linguist-generated=true text=auto eol=lf
crates/ruff_python_ast/src/generated.rs -diff linguist-generated=true text=auto eol=lf
crates/ruff_python_formatter/src/generated.rs -diff linguist-generated=true text=auto eol=lf
ruff.schema.json linguist-generated=true text=auto eol=lf
*.md.snap linguist-language=Markdown

1
.github/CODEOWNERS vendored
View File

@@ -9,7 +9,6 @@
/crates/ruff_formatter/ @MichaReiser
/crates/ruff_python_formatter/ @MichaReiser
/crates/ruff_python_parser/ @MichaReiser @dhruvmanila
/crates/ruff_annotate_snippets/ @BurntSushi
# flake8-pyi
/crates/ruff_linter/src/rules/flake8_pyi/ @AlexWaygood

12
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,12 @@
<!--
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`).
-->

View File

@@ -1,2 +0,0 @@
# This file cannot use the extension `.yaml`.
blank_issues_enabled: false

View File

@@ -1,22 +0,0 @@
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

View File

@@ -6,5 +6,4 @@ self-hosted-runner:
labels:
- depot-ubuntu-latest-8
- depot-ubuntu-22.04-16
- github-windows-2025-x86_64-8
- github-windows-2025-x86_64-16
- windows-latest-xlarge

View File

@@ -23,8 +23,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
PACKAGE_NAME: ruff
MODULE_NAME: ruff

View File

@@ -51,7 +51,7 @@ jobs:
env:
TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }}
run: |
version=$(grep -m 1 "^version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g')
version=$(grep "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

View File

@@ -1,7 +1,5 @@
name: CI
permissions: {}
on:
push:
branches: [main]
@@ -61,7 +59,6 @@ jobs:
- Cargo.toml
- Cargo.lock
- crates/**
- "!crates/red_knot*/**"
- "!crates/ruff_python_formatter/**"
- "!crates/ruff_formatter/**"
- "!crates/ruff_dev/**"
@@ -119,11 +116,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)"
@@ -133,13 +130,12 @@ jobs:
name: "cargo test (linux)"
runs-on: depot-ubuntu-22.04-16
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ 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"
@@ -152,6 +148,7 @@ jobs:
uses: taiki-e/install-action@v2
with:
tool: cargo-insta
- uses: Swatinem/rust-cache@v2
- name: "Run tests"
shell: bash
env:
@@ -179,13 +176,12 @@ jobs:
name: "cargo test (linux, release)"
runs-on: depot-ubuntu-22.04-16
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ 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"
@@ -198,6 +194,7 @@ jobs:
uses: taiki-e/install-action@v2
with:
tool: cargo-insta
- uses: Swatinem/rust-cache@v2
- name: "Run tests"
shell: bash
env:
@@ -206,25 +203,24 @@ jobs:
cargo-test-windows:
name: "cargo test (windows)"
runs-on: github-windows-2025-x86_64-16
runs-on: windows-latest-xlarge
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ 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: |
@@ -235,13 +231,12 @@ jobs:
name: "cargo test (wasm)"
runs-on: ubuntu-latest
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ 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
@@ -252,6 +247,7 @@ 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
@@ -270,11 +266,11 @@ 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
@@ -282,7 +278,7 @@ jobs:
name: "cargo build (msrv)"
runs-on: ubuntu-latest
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -293,7 +289,6 @@ 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 }}
@@ -308,6 +303,7 @@ jobs:
uses: taiki-e/install-action@v2
with:
tool: cargo-insta
- uses: Swatinem/rust-cache@v2
- name: "Run tests"
shell: bash
env:
@@ -325,11 +321,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:
@@ -345,7 +341,7 @@ jobs:
needs:
- cargo-test-linux
- determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && needs.determine_changes.outputs.parser == 'true' }}
if: ${{ needs.determine_changes.outputs.parser == 'true' }}
timeout-minutes: 20
env:
FORCE_COLOR: 1
@@ -381,21 +377,15 @@ jobs:
name: "test scripts"
runs-on: ubuntu-latest
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ 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
# Run all code generation scripts, and verify that the current output is
# already checked into git.
- run: python crates/ruff_python_ast/generate.py
- run: python crates/ruff_python_formatter/generate.py
- run: test -z "$(git status --porcelain)"
# Verify that adding a plugin or rule produces clean code.
- uses: Swatinem/rust-cache@v2
- run: ./scripts/add_rule.py --name DoTheThing --prefix F --code 999 --linter pyflakes
- run: cargo check
- run: cargo fmt --all --check
@@ -413,7 +403,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: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && github.event_name == 'pull_request' && needs.determine_changes.outputs.code == 'true' }}
if: ${{ github.event_name == 'pull_request' && needs.determine_changes.outputs.code == 'true' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -430,7 +420,7 @@ jobs:
name: ruff
path: target/debug
- uses: dawidd6/action-download-artifact@v8
- uses: dawidd6/action-download-artifact@v7
name: Download baseline Ruff binary
with:
name: ruff
@@ -547,7 +537,6 @@ 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:
@@ -582,9 +571,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"
@@ -616,7 +605,6 @@ 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
@@ -626,6 +614,7 @@ 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
@@ -649,15 +638,16 @@ jobs:
name: "formatter instabilities and black similarity"
runs-on: ubuntu-latest
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.formatter == 'true' || github.ref == 'refs/heads/main') }}
if: 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"
@@ -672,7 +662,7 @@ jobs:
needs:
- cargo-test-linux
- determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main' }}
steps:
- uses: extractions/setup-just@v2
env:
@@ -714,7 +704,7 @@ jobs:
benchmarks:
runs-on: ubuntu-22.04
needs: determine_changes
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') }}
if: ${{ github.repository == 'astral-sh/ruff' && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
timeout-minutes: 20
steps:
- name: "Checkout Branch"
@@ -722,8 +712,6 @@ jobs:
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: "Install Rust toolchain"
run: rustup show
@@ -732,6 +720,8 @@ jobs:
with:
tool: cargo-codspeed
- uses: Swatinem/rust-cache@v2
- name: "Build benchmarks"
run: cargo codspeed build --features codspeed -p ruff_benchmark

View File

@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: dawidd6/action-download-artifact@v8
- uses: dawidd6/action-download-artifact@v7
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@v8
- uses: dawidd6/action-download-artifact@v7
name: "Download ecosystem results"
id: download-ecosystem-result
if: steps.pr-number.outputs.pr-number

View File

@@ -49,7 +49,7 @@ jobs:
working-directory: playground
- name: "Deploy to Cloudflare Pages"
if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }}
uses: cloudflare/wrangler-action@v3.13.1
uses: cloudflare/wrangler-action@v3.13.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}

View File

@@ -78,6 +78,5 @@ jobs:
owner: "astral-sh",
repo: "ruff",
title: `Automated typeshed sync failed on ${new Date().toDateString()}`,
body: "Run listed here: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}",
labels: ["bug", "red-knot"],
body: "Runs are listed here: https://github.com/astral-sh/ruff/actions/workflows/sync_typeshed.yaml",
})

7
.github/zizmor.yml vendored
View File

@@ -10,10 +10,3 @@ 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
View File

@@ -29,10 +29,6 @@ tracing.folded
tracing-flamechart.svg
tracing-flamegraph.svg
# insta
*.rs.pending-snap
###
# Rust.gitignore
###

View File

@@ -4,7 +4,7 @@ exclude: |
(?x)^(
.github/workflows/release.yml|
crates/red_knot_vendored/vendor/.*|
crates/red_knot_project/resources/.*|
crates/red_knot_workspace/resources/.*|
crates/ruff_linter/resources/.*|
crates/ruff_linter/src/rules/.*/snapshots/.*|
crates/ruff_notebook/resources/.*|
@@ -36,7 +36,7 @@ repos:
)$
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0
rev: v0.43.0
hooks:
- id: markdownlint-fix
exclude: |
@@ -73,7 +73,7 @@ repos:
pass_filenames: false # This makes it a lot faster
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.3
rev: v0.8.6
hooks:
- id: ruff-format
- id: ruff
@@ -91,19 +91,19 @@ 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.2.2
rev: v1.0.0
hooks:
- id: zizmor
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.31.0
rev: 0.30.0
hooks:
- id: check-github-workflows
# `actionlint` hook, for verifying correct syntax in GitHub Actions workflows.
# Some additional configuration for `actionlint` can be found in `.github/actionlint.yaml`.
- repo: https://github.com/rhysd/actionlint
rev: v1.7.7
rev: v1.7.6
hooks:
- id: actionlint
stages:

View File

@@ -1,103 +1,5 @@
# Changelog
## 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
- \[`airflow`\] Fix typo "security_managr" to "security_manager" (`AIR303`) ([#15463](https://github.com/astral-sh/ruff/pull/15463))
- \[`airflow`\] extend and fix AIR302 rules ([#15525](https://github.com/astral-sh/ruff/pull/15525))
- \[`fastapi`\] Handle parameters with `Depends` correctly (`FAST003`) ([#15364](https://github.com/astral-sh/ruff/pull/15364))
- \[`flake8-pytest-style`\] Implement pytest.warns diagnostics (`PT029`, `PT030`, `PT031`) ([#15444](https://github.com/astral-sh/ruff/pull/15444))
- \[`flake8-pytest-style`\] Test function parameters with default arguments (`PT028`) ([#15449](https://github.com/astral-sh/ruff/pull/15449))
- \[`flake8-type-checking`\] Avoid false positives for `|` in `TC008` ([#15201](https://github.com/astral-sh/ruff/pull/15201))
### Rule changes
- \[`flake8-todos`\] Allow VSCode GitHub PR extension style links in `missing-todo-link` (`TD003`) ([#15519](https://github.com/astral-sh/ruff/pull/15519))
- \[`pyflakes`\] Show syntax error message for `F722` ([#15523](https://github.com/astral-sh/ruff/pull/15523))
### Formatter
- Fix curly bracket spacing around f-string expressions containing curly braces ([#15471](https://github.com/astral-sh/ruff/pull/15471))
- Fix joining of f-strings with different quotes when using quote style `Preserve` ([#15524](https://github.com/astral-sh/ruff/pull/15524))
### Server
- Avoid indexing the same workspace multiple times ([#15495](https://github.com/astral-sh/ruff/pull/15495))
- Display context for `ruff.configuration` errors ([#15452](https://github.com/astral-sh/ruff/pull/15452))
### Configuration
- Remove `flatten` to improve deserialization error messages ([#15414](https://github.com/astral-sh/ruff/pull/15414))
### Bug fixes
- Parse triple-quoted string annotations as if parenthesized ([#15387](https://github.com/astral-sh/ruff/pull/15387))
- \[`fastapi`\] Update `Annotated` fixes (`FAST002`) ([#15462](https://github.com/astral-sh/ruff/pull/15462))
- \[`flake8-bandit`\] Check for `builtins` instead of `builtin` (`S102`, `PTH123`) ([#15443](https://github.com/astral-sh/ruff/pull/15443))
- \[`flake8-pathlib`\] Fix `--select` for `os-path-dirname` (`PTH120`) ([#15446](https://github.com/astral-sh/ruff/pull/15446))
- \[`ruff`\] Fix false positive on global keyword (`RUF052`) ([#15235](https://github.com/astral-sh/ruff/pull/15235))
## 0.9.1
### Preview features

1107
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ license = "MIT"
[workspace.dependencies]
ruff = { path = "crates/ruff" }
ruff_annotate_snippets = { path = "crates/ruff_annotate_snippets" }
ruff_cache = { path = "crates/ruff_cache" }
ruff_db = { path = "crates/ruff_db", default-features = false }
ruff_diagnostics = { path = "crates/ruff_diagnostics" }
@@ -41,11 +40,10 @@ ruff_workspace = { path = "crates/ruff_workspace" }
red_knot_python_semantic = { path = "crates/red_knot_python_semantic" }
red_knot_server = { path = "crates/red_knot_server" }
red_knot_test = { path = "crates/red_knot_test" }
red_knot_project = { path = "crates/red_knot_project", default-features = false }
red_knot_workspace = { path = "crates/red_knot_workspace", default-features = false }
aho-corasick = { version = "1.1.3" }
anstream = { version = "0.6.18" }
anstyle = { version = "1.0.10" }
annotate-snippets = { version = "0.9.2", features = ["color"] }
anyhow = { version = "1.0.80" }
assert_fs = { version = "1.1.0" }
argfile = { version = "0.2.0" }
@@ -59,7 +57,7 @@ clap = { version = "4.5.3", features = ["derive"] }
clap_complete_command = { version = "0.6.0" }
clearscreen = { version = "4.0.0" }
codspeed-criterion-compat = { version = "2.6.0", default-features = false }
colored = { version = "3.0.0" }
colored = { version = "2.1.0" }
console_error_panic_hook = { version = "0.1.7" }
console_log = { version = "1.0.0" }
countme = { version = "3.0.1" }
@@ -105,7 +103,7 @@ matchit = { version = "0.8.1" }
memchr = { version = "2.7.1" }
mimalloc = { version = "0.1.39" }
natord = { version = "1.0.9" }
notify = { version = "8.0.0" }
notify = { version = "7.0.0" }
ordermap = { version = "0.5.0" }
path-absolutize = { version = "3.1.1" }
path-slash = { version = "0.2.1" }
@@ -134,7 +132,6 @@ 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"] }
static_assertions = "1.1.0"
strum = { version = "0.26.0", features = ["strum_macros"] }
strum_macros = { version = "0.26.0" }
@@ -152,7 +149,6 @@ tracing-subscriber = { version = "0.3.18", default-features = false, features =
"fmt",
] }
tracing-tree = { version = "0.4.0" }
tryfn = { version = "0.2.1" }
typed-arena = { version = "2.0.2" }
unic-ucd-category = { version = "0.9" }
unicode-ident = { version = "1.0.12" }

View File

@@ -149,8 +149,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh
powershell -c "irm https://astral.sh/ruff/install.ps1 | iex"
# For a specific version.
curl -LsSf https://astral.sh/ruff/0.9.3/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.9.3/install.ps1 | iex"
curl -LsSf https://astral.sh/ruff/0.9.1/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.9.1/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.3
rev: v0.9.1
hooks:
# Run the linter.
- id: ruff

View File

@@ -1,9 +1,10 @@
[files]
# https://github.com/crate-ci/typos/issues/868
extend-exclude = [
"crates/red_knot_vendored/vendor/**/*",
"**/resources/**/*",
"**/snapshots/**/*",
"crates/red_knot_vendored/vendor/**/*",
"**/resources/**/*",
"**/snapshots/**/*",
"crates/red_knot_workspace/src/workspace/pyproject/package_name.rs"
]
[default.extend-words]
@@ -20,10 +21,7 @@ Numer = "Numer" # Library name 'NumerBlox' in "Who's Using Ruff?"
[default]
extend-ignore-re = [
# Line ignore with trailing "spellchecker:disable-line"
"(?Rm)^.*#\\s*spellchecker:disable-line$",
"LICENSEs",
# Line ignore with trailing "spellchecker:disable-line"
"(?Rm)^.*#\\s*spellchecker:disable-line$",
"LICENSEs",
]
[default.extend-identifiers]
"FrIeNdLy" = "FrIeNdLy"

View File

@@ -13,7 +13,7 @@ license.workspace = true
[dependencies]
red_knot_python_semantic = { workspace = true }
red_knot_project = { workspace = true, features = ["zstd"] }
red_knot_workspace = { workspace = true, features = ["zstd"] }
red_knot_server = { workspace = true }
ruff_db = { workspace = true, features = ["os", "cache"] }
@@ -32,15 +32,9 @@ tracing-flame = { workspace = true }
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 }
filetime = { workspace = true }
regex = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
ruff_db = { workspace = true, features = ["testing"] }
[lints]
workspace = true

View File

@@ -1,190 +0,0 @@
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,
}
#[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,
/// 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)
}
}

View File

@@ -1,26 +1,110 @@
use std::process::{ExitCode, Termination};
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 red_knot_project::metadata::options::Options;
use red_knot_project::watch;
use red_knot_project::watch::ProjectWatcher;
use red_knot_project::{ProjectDatabase, ProjectMetadata};
use python_version::PythonVersion;
use red_knot_python_semantic::SitePackages;
use red_knot_server::run_server;
use red_knot_workspace::db::RootDatabase;
use red_knot_workspace::watch;
use red_knot_workspace::watch::WorkspaceWatcher;
use red_knot_workspace::workspace::settings::Configuration;
use red_knot_workspace::workspace::WorkspaceMetadata;
use ruff_db::diagnostic::Diagnostic;
use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf};
use salsa::plumbing::ZalsaDatabase;
mod args;
use crate::logging::{setup_tracing, Verbosity};
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_configuration(&self, cli_cwd: &SystemPath) -> Configuration {
let mut configuration = Configuration::default();
if let Some(python_version) = self.python_version {
configuration.python_version = Some(python_version.into());
}
if let Some(venv_path) = &self.venv_path {
configuration.search_paths.site_packages = Some(SitePackages::Derived {
venv_path: SystemPath::absolute(venv_path, cli_cwd),
});
}
if let Some(typeshed) = &self.typeshed {
configuration.search_paths.typeshed = Some(SystemPath::absolute(typeshed, cli_cwd));
}
if let Some(extra_search_paths) = &self.extra_search_path {
configuration.search_paths.extra_paths = extra_search_paths
.iter()
.map(|path| Some(SystemPath::absolute(path, cli_cwd)))
.collect();
}
configuration
}
}
#[derive(Debug, clap::Subcommand)]
pub enum Command {
/// Start the language server
Server,
}
#[allow(clippy::print_stdout, clippy::unnecessary_wraps, clippy::print_stderr)]
pub fn main() -> ExitStatus {
run().unwrap_or_else(|error| {
@@ -46,13 +130,10 @@ pub fn main() -> ExitStatus {
fn run() -> anyhow::Result<ExitStatus> {
let args = Args::parse_from(std::env::args());
match args.command {
Command::Server => run_server().map(|()| ExitStatus::Success),
Command::Check(check_args) => run_check(check_args),
if matches!(args.command, Some(Command::Server)) {
return run_server().map(|()| ExitStatus::Success);
}
}
fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
let verbosity = args.verbosity.level();
countme::enable(verbosity.is_trace());
let _guard = setup_tracing(verbosity)?;
@@ -82,15 +163,19 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
.transpose()?
.unwrap_or_else(|| cli_base_path.clone());
let system = OsSystem::new(cwd);
let watch = args.watch;
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 system = OsSystem::new(cwd.clone());
let cli_configuration = args.to_configuration(&cwd);
let workspace_metadata = WorkspaceMetadata::discover(
system.current_directory(),
&system,
Some(&cli_configuration),
)?;
let mut db = ProjectDatabase::new(workspace_metadata, system)?;
// TODO: Use the `program_settings` to compute the key for the database's persistent
// cache and load the cache if it exists.
let mut db = RootDatabase::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_configuration);
// Listen to Ctrl+C and abort the watch mode.
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
@@ -102,7 +187,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
}
})?;
let exit_status = if watch {
let exit_status = if args.watch {
main_loop.watch(&mut db)?
} else {
main_loop.run(&mut db)
@@ -141,13 +226,13 @@ struct MainLoop {
receiver: crossbeam_channel::Receiver<MainLoopMessage>,
/// The file system watcher, if running in watch mode.
watcher: Option<ProjectWatcher>,
watcher: Option<WorkspaceWatcher>,
cli_options: Options,
cli_configuration: Configuration,
}
impl MainLoop {
fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) {
fn new(cli_configuration: Configuration) -> (Self, MainLoopCancellationToken) {
let (sender, receiver) = crossbeam_channel::bounded(10);
(
@@ -155,27 +240,27 @@ impl MainLoop {
sender: sender.clone(),
receiver,
watcher: None,
cli_options,
cli_configuration,
},
MainLoopCancellationToken { sender },
)
}
fn watch(mut self, db: &mut ProjectDatabase) -> anyhow::Result<ExitStatus> {
fn watch(mut self, db: &mut RootDatabase) -> anyhow::Result<ExitStatus> {
tracing::debug!("Starting watch mode");
let sender = self.sender.clone();
let watcher = watch::directory_watcher(move |event| {
sender.send(MainLoopMessage::ApplyChanges(event)).unwrap();
})?;
self.watcher = Some(ProjectWatcher::new(watcher, db));
self.watcher = Some(WorkspaceWatcher::new(watcher, db));
self.run(db);
Ok(ExitStatus::Success)
}
fn run(mut self, db: &mut ProjectDatabase) -> ExitStatus {
fn run(mut self, db: &mut RootDatabase) -> ExitStatus {
self.sender.send(MainLoopMessage::CheckWorkspace).unwrap();
let result = self.main_loop(db);
@@ -185,7 +270,7 @@ impl MainLoop {
result
}
fn main_loop(&mut self, db: &mut ProjectDatabase) -> ExitStatus {
fn main_loop(&mut self, db: &mut RootDatabase) -> ExitStatus {
// Schedule the first check.
tracing::debug!("Starting main loop");
@@ -197,7 +282,7 @@ impl MainLoop {
let db = db.clone();
let sender = self.sender.clone();
// Spawn a new task that checks the project. This needs to be done in a separate thread
// Spawn a new task that checks the workspace. This needs to be done in a separate thread
// to prevent blocking the main loop here.
rayon::spawn(move || {
if let Ok(result) = db.check() {
@@ -239,7 +324,7 @@ impl MainLoop {
MainLoopMessage::ApplyChanges(changes) => {
revision += 1;
// Automatically cancels any pending queries and waits for them to complete.
db.apply_changes(changes, Some(&self.cli_options));
db.apply_changes(changes, Some(&self.cli_configuration));
if let Some(watcher) = self.watcher.as_mut() {
watcher.update(db);
}

View File

@@ -1,441 +0,0 @@
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 config_override() -> anyhow::Result<()> {
let case = TestCase::with_files([
(
"pyproject.toml",
r#"
[tool.knot.environment]
python-version = "3.11"
"#,
),
(
"test.py",
r#"
import sys
# Access `sys.last_exc` that was only added in Python 3.12
print(sys.last_exc)
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @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!(case.command().arg("--python-version").arg("3.12"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
Ok(())
}
/// 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:1 Cannot resolve import `utils`
----- 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(())
}
/// 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 Cannot divide object of type `Literal[4]` by zero
warning[lint:possibly-unresolved-reference] <temp_dir>/test.py:7:7 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: false
exit_code: 1
----- stdout -----
warning[lint:division-by-zero] <temp_dir>/test.py:2:5 Cannot divide object of type `Literal[4]` by zero
----- 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 Cannot resolve import `does_not_exit`
error[lint:division-by-zero] <temp_dir>/test.py:4:5 Cannot divide object of type `Literal[4]` by zero
warning[lint:possibly-unresolved-reference] <temp_dir>/test.py:9:7 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: false
exit_code: 1
----- stdout -----
warning[lint:unresolved-import] <temp_dir>/test.py:2:8 Cannot resolve import `does_not_exit`
warning[lint:division-by-zero] <temp_dir>/test.py:4:5 Cannot divide object of type `Literal[4]` by zero
----- 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 Cannot divide object of type `Literal[4]` by zero
warning[lint:possibly-unresolved-reference] <temp_dir>/test.py:7:7 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: false
exit_code: 1
----- stdout -----
warning[lint:division-by-zero] <temp_dir>/test.py:2:5 Cannot divide object of type `Literal[4]` by zero
----- 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: false
exit_code: 1
----- stdout -----
warning[unknown-rule] <temp_dir>/pyproject.toml:3:1 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: false
exit_code: 1
----- stdout -----
warning[unknown-rule] Unknown lint rule `division-by-zer`
----- 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()))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,190 +0,0 @@
use std::{collections::HashMap, hash::BuildHasher};
use red_knot_python_semantic::{PythonPlatform, PythonVersion, SitePackages};
use ruff_db::system::SystemPathBuf;
/// Combine two values, preferring the values in `self`.
///
/// The logic should follow that of Cargo's `config.toml`:
///
/// > If a key is specified in multiple config files, the values will get merged together.
/// > Numbers, strings, and booleans will use the value in the deeper config directory taking
/// > precedence over ancestor directories, where the home directory is the lowest priority.
/// > Arrays will be joined together with higher precedence items being placed later in the
/// > merged array.
///
/// ## uv Compatibility
///
/// The merging behavior differs from uv in that values with higher precedence in arrays
/// are placed later in the merged array. This is because we want to support overriding
/// earlier values and values from other configurations, including unsetting them.
/// For example: patterns coming last in file inclusion and exclusion patterns
/// allow overriding earlier patterns, matching the `gitignore` behavior.
/// Generally speaking, it feels more intuitive if later values override earlier values
/// than the other way around: `knot --exclude png --exclude "!important.png"`.
///
/// The main downside of this approach is that the ordering can be surprising in cases
/// where the option has a "first match" semantic and not a "last match" wins.
/// One such example is `extra-paths` where the semantics is given by Python:
/// the module on the first matching search path wins.
///
/// ```toml
/// [environment]
/// extra-paths = ["b", "c"]
/// ```
///
/// ```bash
/// knot --extra-paths a
/// ```
///
/// That's why a user might expect that this configuration results in `["a", "b", "c"]`,
/// because the CLI has higher precedence. However, the current implementation results in a
/// resolved extra search path of `["b", "c", "a"]`, which means `a` will be tried last.
///
/// There's an argument here that the user should be able to specify the order of the paths,
/// because only then is the user in full control of where to insert the path when specyifing `extra-paths`
/// in multiple sources.
///
/// ## Macro
/// You can automatically derive `Combine` for structs with named fields by using `derive(ruff_macros::Combine)`.
pub trait Combine {
#[must_use]
fn combine(mut self, other: Self) -> Self
where
Self: Sized,
{
self.combine_with(other);
self
}
fn combine_with(&mut self, other: Self);
}
impl<T> Combine for Option<T>
where
T: Combine,
{
fn combine(self, other: Self) -> Self
where
Self: Sized,
{
match (self, other) {
(Some(a), Some(b)) => Some(a.combine(b)),
(None, Some(b)) => Some(b),
(a, _) => a,
}
}
fn combine_with(&mut self, other: Self) {
match (self, other) {
(Some(a), Some(b)) => {
a.combine_with(b);
}
(a @ None, Some(b)) => {
*a = Some(b);
}
_ => {}
}
}
}
impl<T> Combine for Vec<T> {
fn combine_with(&mut self, mut other: Self) {
// `self` takes precedence over `other` but values with higher precedence must be placed after.
// Swap the vectors so that `other` is the one that gets extended, so that the values of `self` come after.
std::mem::swap(self, &mut other);
self.extend(other);
}
}
impl<K, V, S> Combine for HashMap<K, V, S>
where
K: Eq + std::hash::Hash,
S: BuildHasher,
{
fn combine_with(&mut self, mut other: Self) {
// `self` takes precedence over `other` but `extend` overrides existing values.
// Swap the hash maps so that `self` is the one that gets extended.
std::mem::swap(self, &mut other);
self.extend(other);
}
}
/// Implements [`Combine`] for a value that always returns `self` when combined with another value.
macro_rules! impl_noop_combine {
($name:ident) => {
impl Combine for $name {
#[inline(always)]
fn combine_with(&mut self, _other: Self) {}
#[inline(always)]
fn combine(self, _other: Self) -> Self {
self
}
}
};
}
impl_noop_combine!(SystemPathBuf);
impl_noop_combine!(PythonPlatform);
impl_noop_combine!(SitePackages);
impl_noop_combine!(PythonVersion);
// std types
impl_noop_combine!(bool);
impl_noop_combine!(usize);
impl_noop_combine!(u8);
impl_noop_combine!(u16);
impl_noop_combine!(u32);
impl_noop_combine!(u64);
impl_noop_combine!(u128);
impl_noop_combine!(isize);
impl_noop_combine!(i8);
impl_noop_combine!(i16);
impl_noop_combine!(i32);
impl_noop_combine!(i64);
impl_noop_combine!(i128);
impl_noop_combine!(String);
#[cfg(test)]
mod tests {
use crate::combine::Combine;
use std::collections::HashMap;
#[test]
fn combine_option() {
assert_eq!(Some(1).combine(Some(2)), Some(1));
assert_eq!(None.combine(Some(2)), Some(2));
assert_eq!(Some(1).combine(None), Some(1));
}
#[test]
fn combine_vec() {
assert_eq!(None.combine(Some(vec![1, 2, 3])), Some(vec![1, 2, 3]));
assert_eq!(Some(vec![1, 2, 3]).combine(None), Some(vec![1, 2, 3]));
assert_eq!(
Some(vec![1, 2, 3]).combine(Some(vec![4, 5, 6])),
Some(vec![4, 5, 6, 1, 2, 3])
);
}
#[test]
fn combine_map() {
let a: HashMap<u32, _> = HashMap::from_iter([(1, "a"), (2, "a"), (3, "a")]);
let b: HashMap<u32, _> = HashMap::from_iter([(0, "b"), (2, "b"), (5, "b")]);
assert_eq!(None.combine(Some(b.clone())), Some(b.clone()));
assert_eq!(Some(a.clone()).combine(None), Some(a.clone()));
assert_eq!(
Some(a).combine(Some(b)),
Some(HashMap::from_iter([
(0, "b"),
// The value from `a` takes precedence
(1, "a"),
(2, "a"),
(3, "a"),
(5, "b")
]))
);
}
}

View File

@@ -1,510 +0,0 @@
#![allow(clippy::ref_option)]
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};
use ruff_db::files::{system_path_to_file, File};
use ruff_db::parsed::parsed_module;
use ruff_db::source::{source_text, SourceTextError};
use ruff_db::system::walk_directory::WalkState;
use ruff_db::system::{FileType, SystemPath};
use ruff_python_ast::PySourceType;
use ruff_text_size::TextRange;
use rustc_hash::{FxBuildHasher, FxHashSet};
use salsa::Durability;
use salsa::Setter;
use std::borrow::Cow;
use std::sync::Arc;
pub mod combine;
mod db;
mod files;
pub mod metadata;
pub mod watch;
pub static DEFAULT_LINT_REGISTRY: std::sync::LazyLock<LintRegistry> =
std::sync::LazyLock::new(default_lints_registry);
pub fn default_lints_registry() -> LintRegistry {
let mut builder = LintRegistryBuilder::default();
register_lints(&mut builder);
builder.build()
}
/// The project as a Salsa ingredient.
///
/// ## How is a project different from a program?
/// There are two (related) motivations:
///
/// 1. Program is defined in `ruff_db` and it can't reference the settings types for the linter and formatter
/// without introducing a cyclic dependency. The project is defined in a higher level crate
/// where it can reference these setting types.
/// 2. Running `ruff check` with different target versions results in different programs (settings) but
/// it remains the same project. That's why program is a narrowed view of the project only
/// holding on to the most fundamental settings required for checking.
#[salsa::input]
pub struct Project {
/// The files that are open in the project.
///
/// Setting the open files to a non-`None` value changes `check` to only check the
/// open files rather than all files in the project.
#[return_ref]
#[default]
open_fileset: Option<Arc<FxHashSet<File>>>,
/// The first-party files of this project.
#[default]
#[return_ref]
file_set: IndexedFiles,
/// The metadata describing the project, including the unresolved options.
#[return_ref]
pub metadata: ProjectMetadata,
}
#[salsa::tracked]
impl Project {
pub fn from_metadata(db: &dyn Db, metadata: ProjectMetadata) -> Self {
Project::builder(metadata)
.durability(Durability::MEDIUM)
.open_fileset_durability(Durability::LOW)
.file_set_durability(Durability::LOW)
.new(db)
}
pub fn root(self, db: &dyn Db) -> &SystemPath {
self.metadata(db).root()
}
pub fn name(self, db: &dyn Db) -> &str {
self.metadata(db).name()
}
pub fn reload(self, db: &mut dyn Db, metadata: ProjectMetadata) {
tracing::debug!("Reloading project");
assert_eq!(self.root(db), metadata.root());
if &metadata != self.metadata(db) {
self.set_metadata(db).to(metadata);
}
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(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 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();
let project_span = project_span.clone();
rayon::scope(move |scope| {
let files = ProjectFiles::new(&db, self);
for file in &files {
let result = inner_result.clone();
let db = db.clone();
let project_span = project_span.clone();
scope.spawn(move |_| {
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_impl(&db, file);
result.lock().unwrap().extend(file_diagnostics);
});
}
});
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.
pub fn open_file(self, db: &mut dyn Db, file: File) {
tracing::debug!("Opening file `{}`", file.path(db));
let mut open_files = self.take_open_files(db);
open_files.insert(file);
self.set_open_files(db, open_files);
}
/// Closes a file in the project.
pub fn close_file(self, db: &mut dyn Db, file: File) -> bool {
tracing::debug!("Closing file `{}`", file.path(db));
let mut open_files = self.take_open_files(db);
let removed = open_files.remove(&file);
if removed {
self.set_open_files(db, open_files);
}
removed
}
/// Returns the open files in the project or `None` if the entire project should be checked.
pub fn open_files(self, db: &dyn Db) -> Option<&FxHashSet<File>> {
self.open_fileset(db).as_deref()
}
/// Sets the open files in the project.
///
/// This changes the behavior of `check` to only check the open files rather than all files in the project.
#[tracing::instrument(level = "debug", skip(self, db))]
pub fn set_open_files(self, db: &mut dyn Db, open_files: FxHashSet<File>) {
tracing::debug!("Set open project files (count: {})", open_files.len());
self.set_open_fileset(db).to(Some(Arc::new(open_files)));
}
/// This takes the open files from the project and returns them.
///
/// This changes the behavior of `check` to check all files in the project instead of just the open files.
fn take_open_files(self, db: &mut dyn Db) -> FxHashSet<File> {
tracing::debug!("Take open project files");
// Salsa will cancel any pending queries and remove its own reference to `open_files`
// so that the reference counter to `open_files` now drops to 1.
let open_files = self.set_open_fileset(db).to(None);
if let Some(open_files) = open_files {
Arc::try_unwrap(open_files).unwrap()
} else {
FxHashSet::default()
}
}
/// Returns `true` if the file is open in the project.
///
/// A file is considered open when:
/// * explicitly set as an open file using [`open_file`](Self::open_file)
/// * It has a [`SystemPath`] and belongs to a package's `src` files
/// * It has a [`SystemVirtualPath`](ruff_db::system::SystemVirtualPath)
pub fn is_file_open(self, db: &dyn Db, file: File) -> bool {
if let Some(open_files) = self.open_files(db) {
open_files.contains(&file)
} else if file.path(db).is_system_path() {
self.contains_file(db, file)
} else {
file.path(db).is_system_virtual_path()
}
}
/// Returns `true` if `file` is a first-party file part of this package.
pub fn contains_file(self, db: &dyn Db, file: File) -> bool {
self.files(db).contains(&file)
}
#[tracing::instrument(level = "debug", skip(db))]
pub fn remove_file(self, db: &mut dyn Db, file: File) {
tracing::debug!(
"Removing file `{}` from project `{}`",
file.path(db),
self.name(db)
);
let Some(mut index) = IndexedFiles::indexed_mut(db, self) else {
return;
};
index.remove(file);
}
pub fn add_file(self, db: &mut dyn Db, file: File) {
tracing::debug!(
"Adding file `{}` to project `{}`",
file.path(db),
self.name(db)
);
let Some(mut index) = IndexedFiles::indexed_mut(db, self) else {
return;
};
index.insert(file);
}
/// Returns the files belonging to this project.
pub fn files(self, db: &dyn Db) -> Indexed<'_> {
let files = self.file_set(db);
let indexed = match files.get() {
Index::Lazy(vacant) => {
let _entered =
tracing::debug_span!("Project::index_files", package = %self.name(db))
.entered();
let files = discover_project_files(db, self);
tracing::info!("Found {} files in project `{}`", files.len(), self.name(db));
vacant.set(files)
}
Index::Indexed(indexed) => indexed,
};
indexed
}
pub fn reload_files(self, db: &mut dyn Db) {
tracing::debug!("Reloading files for project `{}`", self.name(db));
if !self.file_set(db).is_lazy() {
// Force a re-index of the files in the next revision.
self.set_file_set(db).to(IndexedFiles::lazy());
}
}
}
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);
if let Some(read_error) = source.read_error() {
diagnostics.push(Box::new(IOErrorDiagnostic {
file,
error: read_error.clone(),
}));
return diagnostics;
}
let parsed = parsed_module(db.upcast(), file);
diagnostics.extend(parsed.errors().iter().map(|error| {
let diagnostic: Box<dyn Diagnostic> = Box::new(ParseDiagnostic::new(file, error.clone()));
diagnostic
}));
diagnostics.extend(check_types(db.upcast(), file).iter().map(|diagnostic| {
let boxed: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
boxed
}));
diagnostics.sort_unstable_by_key(|diagnostic| diagnostic.range().unwrap_or_default().start());
diagnostics
}
fn discover_project_files(db: &dyn Db, project: Project) -> FxHashSet<File> {
let paths = std::sync::Mutex::new(Vec::new());
db.system().walk_directory(project.root(db)).run(|| {
Box::new(|entry| {
match entry {
Ok(entry) => {
// Skip over any non python files to avoid creating too many entries in `Files`.
match entry.file_type() {
FileType::File => {
if entry
.path()
.extension()
.and_then(PySourceType::try_from_extension)
.is_some()
{
let mut paths = paths.lock().unwrap();
paths.push(entry.into_path());
}
}
FileType::Directory | FileType::Symlink => {}
}
}
Err(error) => {
// TODO Handle error
tracing::error!("Failed to walk path: {error}");
}
}
WalkState::Continue
})
});
let paths = paths.into_inner().unwrap();
let mut files = FxHashSet::with_capacity_and_hasher(paths.len(), FxBuildHasher);
for path in paths {
// If this returns `None`, then the file was deleted between the `walk_directory` call and now.
// We can ignore this.
if let Ok(file) = system_path_to_file(db.upcast(), &path) {
files.insert(file);
}
}
files
}
#[derive(Debug)]
enum ProjectFiles<'a> {
OpenFiles(&'a FxHashSet<File>),
Indexed(files::Indexed<'a>),
}
impl<'a> ProjectFiles<'a> {
fn new(db: &'a dyn Db, project: Project) -> Self {
if let Some(open_files) = project.open_files(db) {
ProjectFiles::OpenFiles(open_files)
} else {
ProjectFiles::Indexed(project.files(db))
}
}
}
impl<'a> IntoIterator for &'a ProjectFiles<'a> {
type Item = File;
type IntoIter = ProjectFilesIter<'a>;
fn into_iter(self) -> Self::IntoIter {
match self {
ProjectFiles::OpenFiles(files) => ProjectFilesIter::OpenFiles(files.iter()),
ProjectFiles::Indexed(indexed) => ProjectFilesIter::Indexed {
files: indexed.into_iter(),
},
}
}
}
enum ProjectFilesIter<'db> {
OpenFiles(std::collections::hash_set::Iter<'db, File>),
Indexed { files: files::IndexedIter<'db> },
}
impl Iterator for ProjectFilesIter<'_> {
type Item = File;
fn next(&mut self) -> Option<Self::Item> {
match self {
ProjectFilesIter::OpenFiles(files) => files.next().copied(),
ProjectFilesIter::Indexed { files } => files.next(),
}
}
}
#[derive(Debug)]
pub struct IOErrorDiagnostic {
file: File,
error: SourceTextError,
}
impl Diagnostic for IOErrorDiagnostic {
fn id(&self) -> DiagnosticId {
DiagnosticId::Io
}
fn message(&self) -> Cow<str> {
self.error.to_string().into()
}
fn file(&self) -> Option<File> {
Some(self.file)
}
fn range(&self) -> Option<TextRange> {
None
}
fn severity(&self) -> Severity {
Severity::Error
}
}
#[cfg(test)]
mod tests {
use crate::db::tests::TestDb;
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;
use ruff_db::source::source_text;
use ruff_db::system::{DbWithTestSystem, SystemPath, SystemPathBuf};
use ruff_db::testing::assert_function_query_was_not_run;
use ruff_python_ast::name::Name;
#[test]
fn check_file_skips_type_checking_when_file_cant_be_read() -> ruff_db::system::Result<()> {
let project = ProjectMetadata::new(Name::new_static("test"), SystemPathBuf::from("/"));
let mut db = TestDb::new(project);
let path = SystemPath::new("test.py");
db.write_file(path, "x = 10")?;
let file = system_path_to_file(&db, path).unwrap();
// Now the file gets deleted before we had a chance to read its source text.
db.memory_file_system().remove_file(path)?;
file.sync(&mut db);
assert_eq!(source_text(&db, file).as_str(), "");
assert_eq!(
check_file_impl(&db, file)
.into_iter()
.map(|diagnostic| diagnostic.message().into_owned())
.collect::<Vec<_>>(),
vec!["Failed to read file: No such file or directory".to_string()]
);
let events = db.take_salsa_events();
assert_function_query_was_not_run(&db, check_types, file, &events);
// The user now creates a new file with an empty text. The source text
// content returned by `source_text` remains unchanged, but the diagnostics should get updated.
db.write_file(path, "").unwrap();
assert_eq!(source_text(&db, file).as_str(), "");
assert_eq!(
check_file_impl(&db, file)
.into_iter()
.map(|diagnostic| diagnostic.message().into_owned())
.collect::<Vec<_>>(),
vec![] as Vec<String>
);
Ok(())
}
}

View File

@@ -1,531 +0,0 @@
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))]
pub struct ProjectMetadata {
pub(super) name: Name,
pub(super) root: SystemPathBuf,
/// The raw options
pub(super) options: Options,
}
impl ProjectMetadata {
/// Creates a project with the given name and root that uses the default options.
pub fn new(name: Name, root: SystemPathBuf) -> Self {
Self {
name,
root,
options: Options::default(),
}
}
/// Loads a project from a `pyproject.toml` file.
pub(crate) fn from_pyproject(pyproject: PyProject, root: SystemPathBuf) -> Self {
Self::from_options(
pyproject
.tool
.and_then(|tool| tool.knot)
.unwrap_or_default(),
root,
pyproject.project.as_ref(),
)
}
/// Loads a project from a set of options with an optional pyproject-project table.
pub(crate) fn from_options(
options: Options,
root: SystemPathBuf,
project: Option<&Project>,
) -> Self {
let name = project
.and_then(|project| project.name.as_ref())
.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
Self {
name,
root,
options,
}
}
/// Discovers the closest project at `path` and returns its metadata.
///
/// The algorithm traverses upwards in the `path`'s ancestor chain and uses the following precedence
/// the resolve the project's root.
///
/// 1. The closest `pyproject.toml` with a `tool.knot` section or `knot.toml`.
/// 1. The closest `pyproject.toml`.
/// 1. Fallback to use `path` as the root and use the default settings.
pub fn discover(
path: &SystemPath,
system: &dyn System,
) -> Result<ProjectMetadata, ProjectDiscoveryError> {
tracing::debug!("Searching for a project in '{path}'");
if !system.is_directory(path) {
return Err(ProjectDiscoveryError::NotADirectory(path.to_path_buf()));
}
let mut closest_project: Option<ProjectMetadata> = None;
for project_root in path.ancestors() {
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,
ValueSource::File(Arc::new(pyproject_path.clone())),
) {
Ok(pyproject) => Some(pyproject),
Err(error) => {
return Err(ProjectDiscoveryError::InvalidPyProject {
path: pyproject_path,
source: Box::new(error),
})
}
}
} else {
None
};
// 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,
ValueSource::File(Arc::new(knot_toml_path.clone())),
) {
Ok(options) => options,
Err(error) => {
return Err(ProjectDiscoveryError::InvalidKnotToml {
path: knot_toml_path,
source: Box::new(error),
})
}
};
if pyproject
.as_ref()
.is_some_and(|project| project.knot().is_some())
{
// TODO: Consider using a diagnostic here
tracing::warn!("Ignoring the `tool.knot` section in `{pyproject_path}` because `{knot_toml_path}` takes precedence.");
}
tracing::debug!("Found project at '{}'", project_root);
return Ok(ProjectMetadata::from_options(
options,
project_root.to_path_buf(),
pyproject
.as_ref()
.and_then(|pyproject| pyproject.project.as_ref()),
));
}
if let Some(pyproject) = pyproject {
let has_knot_section = pyproject.knot().is_some();
let metadata =
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf());
if has_knot_section {
tracing::debug!("Found project at '{}'", project_root);
return Ok(metadata);
}
// Not a project itself, keep looking for an enclosing project.
if closest_project.is_none() {
closest_project = Some(metadata);
}
}
}
// No project found, but maybe a pyproject.toml was found.
let metadata = if let Some(closest_project) = closest_project {
tracing::debug!(
"Project without `tool.knot` section: '{}'",
closest_project.root()
);
closest_project
} else {
tracing::debug!("The ancestor directories contain no `pyproject.toml`. Falling back to a virtual project.");
// Create a project with a default configuration
Self::new(
path.file_name().unwrap_or("root").into(),
path.to_path_buf(),
)
};
Ok(metadata)
}
pub fn root(&self) -> &SystemPath {
&self.root
}
pub fn name(&self) -> &str {
&self.name
}
pub fn options(&self) -> &Options {
&self.options
}
pub fn to_program_settings(&self, system: &dyn System) -> ProgramSettings {
self.options.to_program_settings(self.root(), system)
}
/// Combine the project options with the CLI options where the CLI options take precedence.
pub fn apply_cli_options(&mut self, options: Options) {
self.options = options.combine(std::mem::take(&mut self.options));
}
/// Combine the project options with the user options where project options take precedence.
pub fn apply_user_options(&mut self, options: Options) {
self.options.combine_with(options);
}
}
#[derive(Debug, Error)]
pub enum ProjectDiscoveryError {
#[error("project path '{0}' is not a directory")]
NotADirectory(SystemPathBuf),
#[error("{path} is not a valid `pyproject.toml`: {source}")]
InvalidPyProject {
source: Box<PyProjectError>,
path: SystemPathBuf,
},
#[error("{path} is not a valid `knot.toml`: {source}")]
InvalidKnotToml {
source: Box<KnotTomlError>,
path: SystemPathBuf,
},
}
#[cfg(test)]
mod tests {
//! Integration tests for project discovery
use crate::snapshot_project;
use anyhow::{anyhow, Context};
use insta::assert_ron_snapshot;
use ruff_db::system::{SystemPathBuf, TestSystem};
use crate::{ProjectDiscoveryError, ProjectMetadata};
#[test]
fn project_without_pyproject() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([(root.join("foo.py"), ""), (root.join("bar.py"), "")])
.context("Failed to write files")?;
let project =
ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;
assert_eq!(project.root(), &*root);
snapshot_project!(project);
Ok(())
}
#[test]
fn project_with_pyproject() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "backend"
"#,
),
(root.join("db/__init__.py"), ""),
])
.context("Failed to write files")?;
let project =
ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;
assert_eq!(project.root(), &*root);
snapshot_project!(project);
// Discovering the same package from a subdirectory should give the same result
let from_src = ProjectMetadata::discover(&root.join("db"), &system)
.context("Failed to discover project from src sub-directory")?;
assert_eq!(from_src, project);
Ok(())
}
#[test]
fn project_with_invalid_pyproject() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "backend"
[tool.knot
"#,
),
(root.join("db/__init__.py"), ""),
])
.context("Failed to write files")?;
let Err(error) = ProjectMetadata::discover(&root, &system) else {
return Err(anyhow!("Expected project discovery to fail because of invalid syntax in the pyproject.toml"));
};
assert_error_eq(
&error,
r#"/app/pyproject.toml is not a valid `pyproject.toml`: TOML parse error at line 5, column 31
|
5 | [tool.knot
| ^
invalid table header
expected `.`, `]`
"#,
);
Ok(())
}
#[test]
fn nested_projects_in_sub_project() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "project-root"
[tool.knot.src]
root = "src"
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "nested-project"
[tool.knot.src]
root = "src"
"#,
),
])
.context("Failed to write files")?;
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
snapshot_project!(sub_project);
Ok(())
}
#[test]
fn nested_projects_in_root_project() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "project-root"
[tool.knot.src]
root = "src"
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "nested-project"
[tool.knot.src]
root = "src"
"#,
),
])
.context("Failed to write files")?;
let root = ProjectMetadata::discover(&root, &system)?;
snapshot_project!(root);
Ok(())
}
#[test]
fn nested_projects_without_knot_sections() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "project-root"
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "nested-project"
"#,
),
])
.context("Failed to write files")?;
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
snapshot_project!(sub_project);
Ok(())
}
#[test]
fn nested_projects_with_outer_knot_section() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "project-root"
[tool.knot.environment]
python-version = "3.10"
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "nested-project"
"#,
),
])
.context("Failed to write files")?;
let root = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
snapshot_project!(root);
Ok(())
}
/// A `knot.toml` takes precedence over any `pyproject.toml`.
///
/// However, the `pyproject.toml` is still loaded to get the project name and, in the future,
/// the requires-python constraint.
#[test]
fn project_with_knot_and_pyproject_toml() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "super-app"
requires-python = ">=3.12"
[tool.knot.src]
root = "this_option_is_ignored"
"#,
),
(
root.join("knot.toml"),
r#"
[src]
root = "src"
"#,
),
])
.context("Failed to write files")?;
let root = ProjectMetadata::discover(&root, &system)?;
snapshot_project!(root);
Ok(())
}
#[track_caller]
fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) {
assert_eq!(error.to_string().replace('\\', "/"), message);
}
/// Snapshots a project but with all paths using unix separators.
#[macro_export]
macro_rules! snapshot_project {
($project:expr) => {{
assert_ron_snapshot!($project,{
".root" => insta::dynamic_redaction(|content, _content_path| {
content.as_str().unwrap().replace("\\", "/")
}),
});
}};
}
}

View File

@@ -1,288 +0,0 @@
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::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, source: ValueSource) -> Result<Self, KnotTomlError> {
let _guard = ValueSourceGuard::new(source);
let options = toml::from_str(content)?;
Ok(options)
}
pub(crate) fn to_program_settings(
&self,
project_root: &SystemPath,
system: &dyn System,
) -> ProgramSettings {
let (python_version, python_platform) = self
.environment
.as_ref()
.map(|env| {
(
env.python_version.as_deref().copied(),
env.python_platform.as_deref(),
)
})
.unwrap_or_default();
ProgramSettings {
python_version: python_version.unwrap_or_default(),
python_platform: python_platform.cloned().unwrap_or_default(),
search_paths: self.to_search_path_settings(project_root, system),
}
}
fn to_search_path_settings(
&self,
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_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()]
}
};
let (extra_paths, python, typeshed) = self
.environment
.as_ref()
.map(|env| {
(
env.extra_paths.clone(),
env.venv_path.clone(),
env.typeshed.clone(),
)
})
.unwrap_or_default();
SearchPathSettings {
extra_paths: extra_paths
.unwrap_or_default()
.into_iter()
.map(|path| path.absolute(project_root, system))
.collect(),
src_roots,
custom_typeshed: typeshed.map(|path| path.absolute(project_root, system)),
site_packages: python
.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 {
#[serde(skip_serializing_if = "Option::is_none")]
pub python_version: Option<RangedValue<PythonVersion>>,
#[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.
#[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
#[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.
#[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.
#[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)]
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
}
}

View File

@@ -1,337 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: root
---
ProjectMetadata(
name: Name("project-root"),
root: "/app",
options: Options(
src: Some(SrcOptions(
root: Some("src"),
)),
),
)

View File

@@ -1,13 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: sub_project
---
ProjectMetadata(
name: Name("nested-project"),
root: "/app/packages/a",
options: Options(
src: Some(SrcOptions(
root: Some("src"),
)),
),
)

View File

@@ -1,13 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: root
---
ProjectMetadata(
name: Name("project-root"),
root: "/app",
options: Options(
environment: Some(EnvironmentOptions(
r#python-version: Some("3.10"),
)),
),
)

View File

@@ -1,9 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: sub_project
---
ProjectMetadata(
name: Name("nested-project"),
root: "/app/packages/a",
options: Options(),
)

View File

@@ -1,13 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: root
---
ProjectMetadata(
name: Name("super-app"),
root: "/app",
options: Options(
src: Some(SrcOptions(
root: Some("src"),
)),
),
)

View File

@@ -1,9 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: project
---
ProjectMetadata(
name: Name("backend"),
root: "/app",
options: Options(),
)

View File

@@ -1,9 +0,0 @@
---
source: crates/red_knot_project/src/metadata.rs
expression: project
---
ProjectMetadata(
name: Name("app"),
root: "/app",
options: Options(),
)

View File

@@ -1,220 +0,0 @@
"""A runner for Markdown-based tests for Red Knot"""
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich",
# "watchfiles",
# ]
# ///
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Final, Literal, Never, assert_never
from rich.console import Console
from watchfiles import Change, watch
CRATE_NAME: Final = "red_knot_python_semantic"
CRATE_ROOT: Final = Path(__file__).resolve().parent
MDTEST_DIR: Final = CRATE_ROOT / "resources" / "mdtest"
class MDTestRunner:
mdtest_executable: Path | None
console: Console
def __init__(self) -> None:
self.mdtest_executable = None
self.console = Console()
def _run_cargo_test(self, *, message_format: Literal["human", "json"]) -> str:
return subprocess.check_output(
[
"cargo",
"test",
"--package",
CRATE_NAME,
"--no-run",
"--color=always",
"--message-format",
message_format,
],
cwd=CRATE_ROOT,
env=dict(os.environ, CLI_COLOR="1"),
stderr=subprocess.STDOUT,
text=True,
)
def _recompile_tests(
self, status_message: str, *, message_on_success: bool = True
) -> bool:
with self.console.status(status_message):
# Run it with 'human' format in case there are errors:
try:
self._run_cargo_test(message_format="human")
except subprocess.CalledProcessError as e:
print(e.output)
return False
# Run it again with 'json' format to find the mdtest executable:
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)
if message_on_success:
self.console.print("[dim]Tests compiled successfully[/dim]")
return True
def _get_executable_path_from_json(self, json_output: str) -> None:
for json_line in json_output.splitlines():
try:
data = json.loads(json_line)
except json.JSONDecodeError:
continue
if data.get("target", {}).get("name") == "mdtest":
self.mdtest_executable = Path(data["executable"])
break
else:
raise RuntimeError(
"Could not find mdtest executable after successful compilation"
)
def _run_mdtest(
self, arguments: list[str] | None = None, *, capture_output: bool = False
) -> subprocess.CompletedProcess:
assert self.mdtest_executable is not None
arguments = arguments or []
return subprocess.run(
[self.mdtest_executable, *arguments],
cwd=CRATE_ROOT,
env=dict(os.environ, CLICOLOR_FORCE="1"),
capture_output=capture_output,
text=True,
check=False,
)
def _run_mdtests_for_file(self, markdown_file: Path) -> None:
path_mangled = (
markdown_file.as_posix()
.replace("/", "_")
.replace("-", "_")
.removesuffix(".md")
)
test_name = f"mdtest__{path_mangled}"
output = self._run_mdtest(["--exact", test_name], capture_output=True)
if output.returncode == 0:
if "running 0 tests\n" in output.stdout:
self.console.log(
f"[yellow]Warning[/yellow]: No tests were executed with filter '{test_name}'"
)
else:
self.console.print(
f"Test for [bold green]{markdown_file}[/bold green] succeeded"
)
else:
self.console.print()
self.console.rule(
f"Test for [bold red]{markdown_file}[/bold red] failed",
style="gray",
)
self._print_trimmed_cargo_test_output(
output.stdout + output.stderr, test_name
)
def _print_trimmed_cargo_test_output(self, output: str, test_name: str) -> None:
# Skip 'cargo test' boilerplate at the beginning:
lines = output.splitlines()
start_index = 0
for i, line in enumerate(lines):
if f"{test_name} stdout" in line:
start_index = i
break
for line in lines[start_index + 1 :]:
if "MDTEST_TEST_FILTER" in line:
continue
if line.strip() == "-" * 50:
# Skip 'cargo test' boilerplate at the end
break
print(line)
def watch(self) -> Never:
self._recompile_tests("Compiling tests...", message_on_success=False)
self.console.print("[dim]Ready to watch for changes...[/dim]")
for changes in watch(CRATE_ROOT):
new_md_files = set()
changed_md_files = set()
rust_code_has_changed = False
for change, path_str in changes:
path = Path(path_str)
if path.suffix == ".rs":
rust_code_has_changed = True
continue
if path.suffix != ".md":
continue
try:
relative_path = Path(path).relative_to(MDTEST_DIR)
except ValueError:
continue
match change:
case Change.added:
# When saving a file, some editors (looking at you, Vim) might first
# save the file with a temporary name (e.g. `file.md~`) and then rename
# it to the final name. This creates a `deleted` and `added` change.
# We treat those files as `changed` here.
if (Change.deleted, path_str) in changes:
changed_md_files.add(relative_path)
else:
new_md_files.add(relative_path)
case Change.modified:
changed_md_files.add(relative_path)
case Change.deleted:
# No need to do anything when a Markdown test is deleted
pass
case _ as unreachable:
assert_never(unreachable)
if rust_code_has_changed:
if self._recompile_tests("Rust code has changed, recompiling tests..."):
self._run_mdtest()
elif new_md_files:
files = " ".join(file.as_posix() for file in new_md_files)
self._recompile_tests(
f"New Markdown test [yellow]{files}[/yellow] detected, recompiling tests..."
)
for path in new_md_files | changed_md_files:
self._run_mdtests_for_file(path)
def main() -> None:
try:
runner = MDTestRunner()
runner.watch()
except KeyboardInterrupt:
print()
if __name__ == "__main__":
main()

View File

@@ -1,141 +0,0 @@
version = 1
requires-python = ">=3.11"
[manifest]
requirements = [
{ name = "rich" },
{ name = "watchfiles" },
]
[[package]]
name = "anyio"
version = "4.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "watchfiles"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/26/c705fc77d0a9ecdb9b66f1e2976d95b81df3cae518967431e7dbf9b5e219/watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205", size = 94625 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/bb/8461adc4b1fed009546fb797fc0d5698dcfe5e289cb37e1b8f16a93cdc30/watchfiles-1.0.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2a9f93f8439639dc244c4d2902abe35b0279102bca7bbcf119af964f51d53c19", size = 394869 },
{ url = "https://files.pythonhosted.org/packages/55/88/9ebf36b3547176d1709c320de78c1fa3263a46be31b5b1267571d9102686/watchfiles-1.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eea33ad8c418847dd296e61eb683cae1c63329b6d854aefcd412e12d94ee235", size = 384905 },
{ url = "https://files.pythonhosted.org/packages/03/8a/04335ce23ef78d8c69f0913e8b20cf7d9233e3986543aeef95ef2d6e43d2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31f1a379c9dcbb3f09cf6be1b7e83b67c0e9faabed0471556d9438a4a4e14202", size = 449944 },
{ url = "https://files.pythonhosted.org/packages/17/4e/c8d5dcd14fe637f4633616dabea8a4af0a10142dccf3b43e0f081ba81ab4/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab594e75644421ae0a2484554832ca5895f8cab5ab62de30a1a57db460ce06c6", size = 456020 },
{ url = "https://files.pythonhosted.org/packages/5e/74/3e91e09e1861dd7fbb1190ce7bd786700dc0fbc2ccd33bb9fff5de039229/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc2eb5d14a8e0d5df7b36288979176fbb39672d45184fc4b1c004d7c3ce29317", size = 482983 },
{ url = "https://files.pythonhosted.org/packages/a1/3d/e64de2d1ce4eb6a574fd78ce3a28c279da263be9ef3cfcab6f708df192f2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f68d8e9d5a321163ddacebe97091000955a1b74cd43724e346056030b0bacee", size = 520320 },
{ url = "https://files.pythonhosted.org/packages/2c/bd/52235f7063b57240c66a991696ed27e2a18bd6fcec8a1ea5a040b70d0611/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9ce064e81fe79faa925ff03b9f4c1a98b0bbb4a1b8c1b015afa93030cb21a49", size = 500988 },
{ url = "https://files.pythonhosted.org/packages/3a/b0/ff04194141a5fe650c150400dd9e42667916bc0f52426e2e174d779b8a74/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b77d5622ac5cc91d21ae9c2b284b5d5c51085a0bdb7b518dba263d0af006132c", size = 452573 },
{ url = "https://files.pythonhosted.org/packages/3d/9d/966164332c5a178444ae6d165082d4f351bd56afd9c3ec828eecbf190e6a/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1941b4e39de9b38b868a69b911df5e89dc43767feeda667b40ae032522b9b5f1", size = 615114 },
{ url = "https://files.pythonhosted.org/packages/94/df/f569ae4c1877f96ad4086c153a8eee5a19a3b519487bf5c9454a3438c341/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f8c4998506241dedf59613082d1c18b836e26ef2a4caecad0ec41e2a15e4226", size = 613076 },
{ url = "https://files.pythonhosted.org/packages/15/ae/8ce5f29e65d5fa5790e3c80c289819c55e12be2e1b9f5b6a0e55e169b97d/watchfiles-1.0.4-cp311-cp311-win32.whl", hash = "sha256:4ebbeca9360c830766b9f0df3640b791be569d988f4be6c06d6fae41f187f105", size = 271013 },
{ url = "https://files.pythonhosted.org/packages/a4/c6/79dc4a7c598a978e5fafa135090aaf7bbb03b8dec7bada437dfbe578e7ed/watchfiles-1.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:05d341c71f3d7098920f8551d4df47f7b57ac5b8dad56558064c3431bdfc0b74", size = 284229 },
{ url = "https://files.pythonhosted.org/packages/37/3d/928633723211753f3500bfb138434f080363b87a1b08ca188b1ce54d1e05/watchfiles-1.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:32b026a6ab64245b584acf4931fe21842374da82372d5c039cba6bf99ef722f3", size = 276824 },
{ url = "https://files.pythonhosted.org/packages/5b/1a/8f4d9a1461709756ace48c98f07772bc6d4519b1e48b5fa24a4061216256/watchfiles-1.0.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:229e6ec880eca20e0ba2f7e2249c85bae1999d330161f45c78d160832e026ee2", size = 391345 },
{ url = "https://files.pythonhosted.org/packages/bc/d2/6750b7b3527b1cdaa33731438432e7238a6c6c40a9924049e4cebfa40805/watchfiles-1.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5717021b199e8353782dce03bd8a8f64438832b84e2885c4a645f9723bf656d9", size = 381515 },
{ url = "https://files.pythonhosted.org/packages/4e/17/80500e42363deef1e4b4818729ed939aaddc56f82f4e72b2508729dd3c6b/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0799ae68dfa95136dde7c472525700bd48777875a4abb2ee454e3ab18e9fc712", size = 449767 },
{ url = "https://files.pythonhosted.org/packages/10/37/1427fa4cfa09adbe04b1e97bced19a29a3462cc64c78630787b613a23f18/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43b168bba889886b62edb0397cab5b6490ffb656ee2fcb22dec8bfeb371a9e12", size = 455677 },
{ url = "https://files.pythonhosted.org/packages/c5/7a/39e9397f3a19cb549a7d380412fd9e507d4854eddc0700bfad10ef6d4dba/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb2c46e275fbb9f0c92e7654b231543c7bbfa1df07cdc4b99fa73bedfde5c844", size = 482219 },
{ url = "https://files.pythonhosted.org/packages/45/2d/7113931a77e2ea4436cad0c1690c09a40a7f31d366f79c6f0a5bc7a4f6d5/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:857f5fc3aa027ff5e57047da93f96e908a35fe602d24f5e5d8ce64bf1f2fc733", size = 518830 },
{ url = "https://files.pythonhosted.org/packages/f9/1b/50733b1980fa81ef3c70388a546481ae5fa4c2080040100cd7bf3bf7b321/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55ccfd27c497b228581e2838d4386301227fc0cb47f5a12923ec2fe4f97b95af", size = 497997 },
{ url = "https://files.pythonhosted.org/packages/2b/b4/9396cc61b948ef18943e7c85ecfa64cf940c88977d882da57147f62b34b1/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c11ea22304d17d4385067588123658e9f23159225a27b983f343fcffc3e796a", size = 452249 },
{ url = "https://files.pythonhosted.org/packages/fb/69/0c65a5a29e057ad0dc691c2fa6c23b2983c7dabaa190ba553b29ac84c3cc/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:74cb3ca19a740be4caa18f238298b9d472c850f7b2ed89f396c00a4c97e2d9ff", size = 614412 },
{ url = "https://files.pythonhosted.org/packages/7f/b9/319fcba6eba5fad34327d7ce16a6b163b39741016b1996f4a3c96b8dd0e1/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7cce76c138a91e720d1df54014a047e680b652336e1b73b8e3ff3158e05061e", size = 611982 },
{ url = "https://files.pythonhosted.org/packages/f1/47/143c92418e30cb9348a4387bfa149c8e0e404a7c5b0585d46d2f7031b4b9/watchfiles-1.0.4-cp312-cp312-win32.whl", hash = "sha256:b045c800d55bc7e2cadd47f45a97c7b29f70f08a7c2fa13241905010a5493f94", size = 271822 },
{ url = "https://files.pythonhosted.org/packages/ea/94/b0165481bff99a64b29e46e07ac2e0df9f7a957ef13bec4ceab8515f44e3/watchfiles-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:c2acfa49dd0ad0bf2a9c0bb9a985af02e89345a7189be1efc6baa085e0f72d7c", size = 285441 },
{ url = "https://files.pythonhosted.org/packages/11/de/09fe56317d582742d7ca8c2ca7b52a85927ebb50678d9b0fa8194658f536/watchfiles-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:22bb55a7c9e564e763ea06c7acea24fc5d2ee5dfc5dafc5cfbedfe58505e9f90", size = 277141 },
{ url = "https://files.pythonhosted.org/packages/08/98/f03efabec64b5b1fa58c0daab25c68ef815b0f320e54adcacd0d6847c339/watchfiles-1.0.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8012bd820c380c3d3db8435e8cf7592260257b378b649154a7948a663b5f84e9", size = 390954 },
{ url = "https://files.pythonhosted.org/packages/16/09/4dd49ba0a32a45813debe5fb3897955541351ee8142f586303b271a02b40/watchfiles-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa216f87594f951c17511efe5912808dfcc4befa464ab17c98d387830ce07b60", size = 381133 },
{ url = "https://files.pythonhosted.org/packages/76/59/5aa6fc93553cd8d8ee75c6247763d77c02631aed21551a97d94998bf1dae/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c9953cf85529c05b24705639ffa390f78c26449e15ec34d5339e8108c7c407", size = 449516 },
{ url = "https://files.pythonhosted.org/packages/4c/aa/df4b6fe14b6317290b91335b23c96b488d365d65549587434817e06895ea/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cf684aa9bba4cd95ecb62c822a56de54e3ae0598c1a7f2065d51e24637a3c5d", size = 454820 },
{ url = "https://files.pythonhosted.org/packages/5e/71/185f8672f1094ce48af33252c73e39b48be93b761273872d9312087245f6/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44a39aee3cbb9b825285ff979ab887a25c5d336e5ec3574f1506a4671556a8d", size = 481550 },
{ url = "https://files.pythonhosted.org/packages/85/d7/50ebba2c426ef1a5cb17f02158222911a2e005d401caf5d911bfca58f4c4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38320582736922be8c865d46520c043bff350956dfc9fbaee3b2df4e1740a4b", size = 518647 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/4c009342e393c545d68987e8010b937f72f47937731225b2b29b7231428f/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39f4914548b818540ef21fd22447a63e7be6e24b43a70f7642d21f1e73371590", size = 497547 },
{ url = "https://files.pythonhosted.org/packages/0f/7c/1cf50b35412d5c72d63b2bf9a4fffee2e1549a245924960dd087eb6a6de4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f12969a3765909cf5dc1e50b2436eb2c0e676a3c75773ab8cc3aa6175c16e902", size = 452179 },
{ url = "https://files.pythonhosted.org/packages/d6/a9/3db1410e1c1413735a9a472380e4f431ad9a9e81711cda2aaf02b7f62693/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0986902677a1a5e6212d0c49b319aad9cc48da4bd967f86a11bde96ad9676ca1", size = 614125 },
{ url = "https://files.pythonhosted.org/packages/f2/e1/0025d365cf6248c4d1ee4c3d2e3d373bdd3f6aff78ba4298f97b4fad2740/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:308ac265c56f936636e3b0e3f59e059a40003c655228c131e1ad439957592303", size = 611911 },
{ url = "https://files.pythonhosted.org/packages/55/55/035838277d8c98fc8c917ac9beeb0cd6c59d675dc2421df5f9fcf44a0070/watchfiles-1.0.4-cp313-cp313-win32.whl", hash = "sha256:aee397456a29b492c20fda2d8961e1ffb266223625346ace14e4b6d861ba9c80", size = 271152 },
{ url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216 },
]

View File

@@ -1,44 +0,0 @@
# Deferred annotations
## Deferred annotations in stubs always resolve
```pyi path=mod.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
```

View File

@@ -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: Unknown | Literal[0]
reveal_type(b1) # revealed: Literal[0]
# error: [invalid-type-form]
invalid1: Literal[3 + 4]

View File

@@ -100,7 +100,7 @@ def _(flag: bool):
foo_3: LiteralString = "foo" * 1_000_000_000
bar_3: str = foo_2 # fine
baz_1: str = repr(object())
baz_1: str = str()
qux_1: LiteralString = baz_1 # error: [invalid-assignment]
baz_2: LiteralString = "baz" * 1_000_000_000

View File

@@ -173,40 +173,3 @@ p: "call()"
r: "[1, 2]"
s: "(1, 2)"
```
## Multi line annotation
Quoted type annotations should be parsed as if surrounded by parentheses.
```py
def valid(
a1: """(
int |
str
)
""",
a2: """
int |
str
""",
):
reveal_type(a1) # revealed: int | str
reveal_type(a2) # revealed: int | str
def invalid(
# error: [invalid-syntax-in-forward-annotation]
a1: """
int |
str)
""",
# error: [invalid-syntax-in-forward-annotation]
a2: """
int) |
str
""",
# error: [invalid-syntax-in-forward-annotation]
a3: """
(int)) """,
):
pass
```

View File

@@ -6,11 +6,14 @@ Several type qualifiers are unsupported by red-knot currently. However, we also
false-positive errors if you use one in an annotation:
```py
from typing_extensions import Final, Required, NotRequired, ReadOnly, TypedDict
from typing_extensions import Final, ClassVar, Required, NotRequired, ReadOnly, TypedDict
X: Final = 42
Y: Final[int] = 42
class Foo:
A: ClassVar[int] = 42
# TODO: `TypedDict` is actually valid as a base
# error: [invalid-base]
class Bar(TypedDict):

View File

@@ -2,281 +2,6 @@
Tests for attribute access on various kinds of types.
## Class and instance variables
### Pure instance variables
#### Variable only declared/bound in `__init__`
Variables only declared and/or bound in `__init__` are pure instance variables. They cannot be
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
if flag:
self.pure_instance_variable5: 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)
# TODO: should be `int`
reveal_type(c_instance.pure_instance_variable2) # revealed: @Todo(implicit instance attribute)
# TODO: should be `bytes`
reveal_type(c_instance.pure_instance_variable3) # revealed: @Todo(implicit instance attribute)
# TODO: should be `bool`
reveal_type(c_instance.pure_instance_variable4) # revealed: @Todo(implicit instance attribute)
# 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)
# 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"
# TODO: this should be an error (incompatible types in assignment)
c_instance.pure_instance_variable2 = "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
# 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_instance.pure_instance_variable4 = 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)
```
#### Variable declared in class body and declared/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
def __init__(self) -> None:
self.pure_instance_variable = "value set in __init__"
c_instance = C()
reveal_type(c_instance.pure_instance_variable) # revealed: str
# 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
# 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"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `pure_instance_variable` of type `str`"
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"
```
#### Variable declared in class body and not bound anywhere
If a variable is declared in the class body but not bound anywhere, we still consider it a pure
instance variable and allow access to it via instances.
```py
class C:
pure_instance_variable: str
c_instance = C()
reveal_type(c_instance.pure_instance_variable) # 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
# TODO: mypy and pyright do not show an error here, but we plan to emit one.
C.pure_instance_variable = "overwritten on class"
```
### Pure class variables (`ClassVar`)
#### Annotated with `ClassVar` type qualifier
Class variables annotated with the [`typing.ClassVar`] type qualifier are pure class variables. They
cannot be overwritten on instances, but they can be accessed on instances.
For more details, see the [typing spec on `ClassVar`].
```py
from typing import ClassVar
class C:
pure_class_variable1: ClassVar[str] = "value in class body"
pure_class_variable2: ClassVar = 1
def method(self):
# TODO: this should be an error
self.pure_class_variable1 = "value set through instance"
reveal_type(C.pure_class_variable1) # revealed: str
# TODO: Should be `Unknown | Literal[1]`.
reveal_type(C.pure_class_variable2) # revealed: Unknown
c_instance = C()
# It is okay to access a pure class variable on an instance.
reveal_type(c_instance.pure_class_variable1) # revealed: str
# TODO: Should be `Unknown | Literal[1]`.
reveal_type(c_instance.pure_class_variable2) # revealed: Unknown
# error: [invalid-attribute-access] "Cannot assign to ClassVar `pure_class_variable1` from an instance of type `C`"
c_instance.pure_class_variable1 = "value set on instance"
C.pure_class_variable1 = "overwritten on class"
# 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):
pure_class_variable1: ClassVar[str] = "overwritten on subclass"
reveal_type(Subclass.pure_class_variable1) # revealed: str
```
#### Variable only mentioned in a class method
We also consider a class variable to be a pure class variable if it is only mentioned in a class
method.
```py
class C:
@classmethod
def class_method(cls):
cls.pure_class_variable = "value set in class method"
# for a more realistic example, let's actually call the method
C.class_method()
# TODO: We currently plan to support this and show no error here.
# mypy shows an error here, pyright does not.
# error: [unresolved-attribute]
reveal_type(C.pure_class_variable) # revealed: Unknown
C.pure_class_variable = "overwritten on class"
# TODO: should be `Literal["overwritten on class"]`
# 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)
# TODO: should raise an error.
c_instance.pure_class_variable = "value set on instance"
```
### Instance variables with class-level default values
These are instance attributes, but the fact that we can see that they have a binding (not a
declaration) in the class body means that reading the value from the class directly is also
permitted. This is the only difference for these attributes as opposed to "pure" instance
attributes.
#### Basic
```py
class C:
variable_with_class_default1: str = "value in class body"
variable_with_class_default2 = 1
def instance_method(self):
self.variable_with_class_default1 = "value set in instance method"
reveal_type(C.variable_with_class_default1) # revealed: str
reveal_type(C.variable_with_class_default2) # revealed: Unknown | Literal[1]
c_instance = C()
reveal_type(c_instance.variable_with_class_default1) # revealed: str
reveal_type(c_instance.variable_with_class_default2) # revealed: Unknown | Literal[1]
c_instance.variable_with_class_default1 = "value set on instance"
reveal_type(C.variable_with_class_default1) # revealed: str
# TODO: Could be Literal["value set on instance"], or still `str` if we choose not to
# narrow the type.
reveal_type(c_instance.variable_with_class_default1) # revealed: str
C.variable_with_class_default1 = "overwritten on class"
# TODO: Could be `Literal["overwritten on class"]`, or still `str` if we choose not to
# narrow the type.
reveal_type(C.variable_with_class_default1) # revealed: str
# TODO: should still be `Literal["value set on instance"]`, or `str`.
reveal_type(c_instance.variable_with_class_default1) # revealed: str
```
## Union of attributes
```py
@@ -295,13 +20,11 @@ def _(flag: bool):
else:
x = 4
reveal_type(C1.x) # revealed: Unknown | Literal[1, 2]
reveal_type(C2.x) # revealed: Unknown | Literal[3, 4]
reveal_type(C1.x) # revealed: Literal[1, 2]
reveal_type(C2.x) # revealed: Literal[3, 4]
```
## Inherited class attributes
### Basic
## Inherited attributes
```py
class A:
@@ -310,10 +33,10 @@ class A:
class B(A): ...
class C(B): ...
reveal_type(C.X) # revealed: Unknown | Literal["foo"]
reveal_type(C.X) # revealed: Literal["foo"]
```
### Multiple inheritance
## Inherited attributes (multiple inheritance)
```py
class O: ...
@@ -333,7 +56,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: Unknown | Literal[42]
reveal_type(A.X) # revealed: Literal[42]
```
## Unions with possibly unbound paths
@@ -355,7 +78,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: Unknown | Literal[1, 3]
reveal_type(C.x) # revealed: Literal[1, 3]
```
### Possibly-unbound within a class
@@ -378,10 +101,10 @@ 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: Unknown | Literal[1, 2, 3]
reveal_type(C.x) # revealed: Literal[1, 2, 3]
```
### Unions with all paths unbound
## Unions with all paths unbound
If the symbol is unbound in all elements of the union, we detect that:
@@ -435,67 +158,7 @@ class Foo: ...
reveal_type(Foo.__class__) # revealed: Literal[type]
```
## Module attributes
```py path=mod.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
```py path=outer/__init__.py
```
```py path=outer/nested/__init__.py
```
```py path=outer/nested/inner.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
## Function-literal attributes
Most attribute accesses on function-literal types are delegated to `types.FunctionType`, since all
functions are instances of that class:
@@ -503,8 +166,8 @@ functions are instances of that class:
```py path=a.py
def f(): ...
reveal_type(f.__defaults__) # revealed: @Todo(full tuple[...] support) | None
reveal_type(f.__kwdefaults__) # revealed: @Todo(generics) | None
reveal_type(f.__defaults__) # revealed: @Todo(instance attributes)
reveal_type(f.__kwdefaults__) # revealed: @Todo(instance attributes)
```
Some attributes are special-cased, however:
@@ -516,14 +179,14 @@ reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions)
reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
```
### Int-literal attributes
## Int-literal attributes
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
reveal_type((2).bit_length) # revealed: @Todo(bound method)
reveal_type((2).denominator) # revealed: @Todo(@property)
reveal_type((2).bit_length) # revealed: @Todo(instance attributes)
reveal_type((2).denominator) # revealed: @Todo(instance attributes)
```
Some attributes are special-cased, however:
@@ -533,14 +196,14 @@ reveal_type((2).numerator) # revealed: Literal[2]
reveal_type((2).real) # revealed: Literal[2]
```
### Bool-literal attributes
## Literal `bool` attributes
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
reveal_type(True.__and__) # revealed: @Todo(bound method)
reveal_type(False.__or__) # revealed: @Todo(bound method)
reveal_type(True.__and__) # revealed: @Todo(instance attributes)
reveal_type(False.__or__) # revealed: @Todo(instance attributes)
```
Some attributes are special-cased, however:
@@ -550,20 +213,11 @@ reveal_type(True.numerator) # revealed: Literal[1]
reveal_type(False.real) # revealed: Literal[0]
```
### Bytes-literal attributes
## Bytes-literal attributes
All attribute access on literal `bytes` types is currently delegated to `buitins.bytes`:
```py
reveal_type(b"foo".join) # revealed: @Todo(bound method)
reveal_type(b"foo".endswith) # revealed: @Todo(bound method)
reveal_type(b"foo".join) # revealed: @Todo(instance attributes)
reveal_type(b"foo".endswith) # revealed: @Todo(instance attributes)
```
## References
Some of the tests in the *Class and instance variables* section draw inspiration from
[pyright's documentation] on this topic.
[pyright's documentation]: https://microsoft.github.io/pyright/#/type-concepts-advanced?id=class-and-instance-variables
[typing spec on `classvar`]: https://typing.readthedocs.io/en/latest/spec/class-compat.html#classvar
[`typing.classvar`]: https://docs.python.org/3/library/typing.html#typing.ClassVar

View File

@@ -50,44 +50,46 @@ reveal_type(b | b) # revealed: Literal[False]
## Arithmetic with a variable
```py
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
a = True
b = False
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_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_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_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_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 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 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 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
```

View File

@@ -262,8 +262,7 @@ class A:
class B:
__add__ = A()
# TODO: this could be `int` if we declare `B.__add__` using a `Callable` type
reveal_type(B() + B()) # revealed: Unknown | int
reveal_type(B() + B()) # revealed: int
```
## Integration test: numbers from typeshed

View File

@@ -1,272 +0,0 @@
# Boundness and declaredness: public uses
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`
(`types.rs`) and [this issue](https://github.com/astral-sh/ruff/issues/14297) for more information.
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` | `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 |
| ---------------- | -------- | ------------------------- | ------------------- |
| bound | | | |
| possibly-unbound | | `possibly-unbound-import` | ? |
| unbound | | ? | `unresolved-import` |
## Declared
### 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 (`str` vs. `Literal[2]` below):
```py path=mod.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 a, b, c, d
reveal_type(a) # revealed: int
reveal_type(b) # revealed: str
reveal_type(c) # revealed: Any
reveal_type(d) # revealed: int
```
### Declared and possibly unbound
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
from typing import Any
def any() -> Any: ...
def flag() -> bool: ...
a: int
b: str
c: Any
d: int
if flag:
a = 1
b = 2 # error: [invalid-assignment]
c = 3
d = any()
```
```py
from mod import a, b, c, d
reveal_type(a) # revealed: int
reveal_type(b) # revealed: str
reveal_type(c) # revealed: Any
reveal_type(d) # revealed: int
```
### Declared and unbound
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
from typing import Any
a: int
b: Any
```
```py
from mod import a, b
reveal_type(a) # revealed: int
reveal_type(b) # revealed: Any
```
## Possibly undeclared
### Possibly undeclared and bound
If a symbol is possibly undeclared but definitely bound, we use the union of the declared and
inferred types:
```py path=mod.py
from typing import Any
def any() -> Any: ...
def flag() -> bool: ...
a = 1
b = 2
c = 3
d = any()
if flag():
a: int
b: Any
c: str # error: [invalid-declaration]
d: int
```
```py
from mod import a, b, c, d
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 `b`). Note that we raise a `possibly-unbound-import`
error for both `a` and `b`:
```py path=mod.py
def flag() -> bool: ...
if flag():
a: Any = 1
b = 2
else:
b: str
```
```py
# error: [possibly-unbound-import]
# error: [possibly-unbound-import]
from mod import a, b
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
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
def flag() -> bool: ...
if flag():
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 a
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
If a symbols is undeclared, we use the union of `Unknown` with the inferred type. Note that we treat
symbols that are undeclared differently from symbols that are explicitly declared as `Unknown`:
```py path=mod.py
from knot_extensions import Unknown
# Undeclared:
a = 1
# Declared with `Unknown`:
b: Unknown = 1
```
```py
from mod import a, b
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
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
from knot_extensions import Unknown
def flag() -> bool: ...
if flag:
a = 1
b: Unknown = 1
```
```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 a, b
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
if False:
a: int = 1
```
```py
# error: [unresolved-import]
from mod import a
reveal_type(a) # revealed: Unknown
# Modifications allowed in this case:
a = None
```

View File

@@ -52,7 +52,7 @@ class NonCallable:
__call__ = 1
a = NonCallable()
# error: "Object of type `Unknown | Literal[1]` is not callable (due to union element `Literal[1]`)"
# error: "Object of type `NonCallable` is not callable"
reveal_type(a()) # revealed: Unknown
```

View File

@@ -169,15 +169,6 @@ def f(*args: int) -> int:
reveal_type(f(1, 2, 3)) # revealed: int
```
### Multiple keyword arguments map to keyword variadic parameter
```py
def f(**kwargs: int) -> int:
return 1
reveal_type(f(foo=1, bar=2)) # revealed: int
```
## Missing arguments
### No defaults or variadic

View File

@@ -92,7 +92,8 @@ def _(o: object):
n = None
if o is not None:
reveal_type(o) # revealed: ~None
reveal_type(o) # revealed: object & ~None
reveal_type(o is n) # revealed: Literal[False]
reveal_type(o is not n) # revealed: Literal[True]
```

View File

@@ -1,151 +0,0 @@
# 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()]
```

View File

@@ -1,43 +0,0 @@
# 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]
```

View File

@@ -110,8 +110,10 @@ python-version = "3.10"
from typing_extensions import assert_type
def _(a: str | int):
assert_type(a, str | int)
assert_type(a, int | str)
assert_type(a, str | int) # fine
# TODO: Order-independent union handling in type equivalence
assert_type(a, int | str) # error: [type-assertion-failure]
```
## Intersections
@@ -133,6 +135,8 @@ def _(a: A):
if isinstance(a, B) and not isinstance(a, C) and not isinstance(a, D):
reveal_type(a) # revealed: A & B & ~C & ~D
assert_type(a, Intersection[A, B, Not[C], Not[D]])
assert_type(a, Intersection[B, A, Not[D], Not[C]])
assert_type(a, Intersection[A, B, Not[C], Not[D]]) # fine
# TODO: Order-independent intersection handling in type equivalence
assert_type(a, Intersection[B, A, Not[D], Not[C]]) # error: [type-assertion-failure]
```

View File

@@ -1,27 +0,0 @@
# `cast`
`cast()` takes two arguments, one type and one value, and returns a value of the given type.
The (inferred) type of the value and the given type do not need to have any correlation.
```py
from typing import Literal, cast
reveal_type(True) # revealed: Literal[True]
reveal_type(cast(str, True)) # revealed: str
reveal_type(cast("str", True)) # revealed: str
reveal_type(cast(int | str, 1)) # revealed: int | str
# error: [invalid-type-form]
reveal_type(cast(Literal, True)) # revealed: Unknown
# TODO: These should be errors
cast(1)
cast(str)
cast(str, b"ar", "foo")
# TODO: Either support keyword arguments properly,
# or give a comprehensible error message saying they're unsupported
cast(val="foo", typ=int) # error: [unresolved-reference] "Name `foo` used when not defined"
```

View File

@@ -455,9 +455,9 @@ else:
reveal_type(x) # revealed: slice
finally:
# TODO: should be `Literal[1] | str | bytes | bool | memoryview | float | range | slice`
reveal_type(x) # revealed: bool | slice | float
reveal_type(x) # revealed: bool | float | slice
reveal_type(x) # revealed: bool | slice | float
reveal_type(x) # revealed: bool | float | slice
```
## Nested `try`/`except` blocks
@@ -534,7 +534,7 @@ try:
reveal_type(x) # revealed: slice
finally:
# TODO: should be `Literal[1] | str | bytes | bool | memoryview | float | range | slice`
reveal_type(x) # revealed: bool | slice | float
reveal_type(x) # revealed: bool | float | slice
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: Literal[2]

View File

@@ -5,7 +5,7 @@
```py
def _(flag: bool):
class A:
always_bound: int = 1
always_bound = 1
if flag:
union = 1
@@ -13,21 +13,14 @@ def _(flag: bool):
union = "abc"
if flag:
union_declared: int = 1
else:
union_declared: str = "abc"
possibly_unbound = "abc"
if flag:
possibly_unbound: str = "abc"
reveal_type(A.always_bound) # revealed: Literal[1]
reveal_type(A.always_bound) # revealed: int
reveal_type(A.union) # revealed: Unknown | Literal[1, "abc"]
reveal_type(A.union_declared) # revealed: int | str
reveal_type(A.union) # revealed: Literal[1, "abc"]
# error: [possibly-unbound-attribute] "Attribute `possibly_unbound` on type `Literal[A]` is possibly unbound"
reveal_type(A.possibly_unbound) # revealed: str
reveal_type(A.possibly_unbound) # revealed: Literal["abc"]
# error: [unresolved-attribute] "Type `Literal[A]` has no attribute `non_existent`"
reveal_type(A.non_existent) # revealed: Unknown

View File

@@ -55,7 +55,7 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"]
## Evaluates to builtin
```py path=a.py
redefined_builtin_bool: type[bool] = bool
redefined_builtin_bool = bool
def my_bool(x) -> bool:
return True

View File

@@ -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: int
reveal_type(len(Int())) # revealed: Literal[2]
reveal_type(len(Str())) # revealed: int
reveal_type(len(Tuple())) # revealed: int
reveal_type(len(IntUnion())) # revealed: int
reveal_type(len(IntUnion())) # revealed: Literal[2, 32]
```
### Negative integers

View File

@@ -17,10 +17,10 @@ box: MyBox[int] = MyBox(5)
# TODO should emit a diagnostic here (str is not assignable to int)
wrong_innards: MyBox[int] = MyBox("five")
# TODO reveal int, do not leak the typevar
reveal_type(box.data) # revealed: T
# TODO reveal int
reveal_type(box.data) # revealed: @Todo(instance attributes)
reveal_type(MyBox.box_model_number) # revealed: Unknown | Literal[695]
reveal_type(MyBox.box_model_number) # revealed: Literal[695]
```
## Subclassing
@@ -39,9 +39,7 @@ class MySecureBox[T](MyBox[T]): ...
secure_box: MySecureBox[int] = MySecureBox(5)
reveal_type(secure_box) # revealed: MySecureBox
# TODO reveal int
# The @Todo(…) is misleading here. We currently treat `MyBox[T]` as a dynamic base class because we
# don't understand generics and therefore infer `Unknown` for the `MyBox[T]` base of `MySecureBox[T]`.
reveal_type(secure_box.data) # revealed: @Todo(instance attribute on class with dynamic base)
reveal_type(secure_box.data) # revealed: @Todo(instance attributes)
```
## Cyclical class definition

View File

@@ -1,70 +1,8 @@
# Builtins
## Importing builtin module
Builtin symbols can be explicitly imported:
# Importing builtin module
```py
import builtins
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"
```
```pyi path=/typeshed/stdlib/builtins.pyi
class Custom: ...
custom_builtin: Custom
```
```pyi path=/typeshed/stdlib/typing_extensions.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"
```
```pyi path=/typeshed/stdlib/builtins.pyi
foo = bar
bar = 1
```
```pyi path=/typeshed/stdlib/typing_extensions.pyi
def reveal_type(obj, /): ...
```
```py
reveal_type(foo) # revealed: Unknown
x = builtins.chr
reveal_type(x) # revealed: Literal[chr]
```

View File

@@ -23,8 +23,8 @@ 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: Unknown | Literal[3]
reveal_type(y) # revealed: Unknown | Literal[3]
reveal_type(x) # revealed: Literal[3]
reveal_type(y) # revealed: Literal[3]
```
## Maybe unbound annotated
@@ -52,7 +52,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: Unknown | Literal[3]
reveal_type(x) # revealed: Literal[3]
reveal_type(y) # revealed: int
```

View File

@@ -9,7 +9,7 @@ reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
b: int = 42
b = 42
```
```py path=a/b.py
@@ -20,11 +20,11 @@ b: int = 42
```py
from a import b
reveal_type(b) # revealed: int
reveal_type(b) # revealed: Literal[42]
```
```py path=a/__init__.py
b: int = 42
b = 42
```
```py path=a/b.py
@@ -41,7 +41,7 @@ reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
b: int = 42
b = 42
```
```py path=a/b.py
@@ -60,13 +60,13 @@ sees the submodule as the value of `b` instead of the integer.
from a import b
import a.b
# Python would say `int` for `b`
# Python would say `Literal[42]` for `b`
reveal_type(b) # revealed: <module 'a.b'>
reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
b: int = 42
b = 42
```
```py path=a/b.py

View File

@@ -20,12 +20,12 @@ from a import b.c
# TODO: Should these be inferred as Unknown?
reveal_type(b) # revealed: <module 'a.b'>
reveal_type(b.c) # revealed: int
reveal_type(b.c) # revealed: Literal[1]
```
```py path=a/__init__.py
```
```py path=a/b.py
c: int = 1
c = 1
```

View File

@@ -17,13 +17,13 @@ reveal_type(X) # revealed: Unknown
```
```py path=package/foo.py
X: int = 42
X = 42
```
```py path=package/bar.py
from .foo import X
reveal_type(X) # revealed: int
reveal_type(X) # revealed: Literal[42]
```
## Dotted
@@ -32,25 +32,25 @@ reveal_type(X) # revealed: int
```
```py path=package/foo/bar/baz.py
X: int = 42
X = 42
```
```py path=package/bar.py
from .foo.bar.baz import X
reveal_type(X) # revealed: int
reveal_type(X) # revealed: Literal[42]
```
## Bare to package
```py path=package/__init__.py
X: int = 42
X = 42
```
```py path=package/bar.py
from . import X
reveal_type(X) # revealed: int
reveal_type(X) # revealed: Literal[42]
```
## Non-existent + bare to package
@@ -66,11 +66,11 @@ reveal_type(X) # revealed: Unknown
```py path=package/__init__.py
from .foo import X
reveal_type(X) # revealed: int
reveal_type(X) # revealed: Literal[42]
```
```py path=package/foo.py
X: int = 42
X = 42
```
## Non-existent + dunder init
@@ -87,13 +87,13 @@ reveal_type(X) # revealed: Unknown
```
```py path=package/foo.py
X: int = 42
X = 42
```
```py path=package/subpackage/subsubpackage/bar.py
from ...foo import X
reveal_type(X) # revealed: int
reveal_type(X) # revealed: Literal[42]
```
## Unbound symbol
@@ -117,13 +117,13 @@ reveal_type(x) # revealed: Unknown
```
```py path=package/foo.py
X: int = 42
X = 42
```
```py path=package/bar.py
from . import foo
reveal_type(foo.X) # revealed: int
reveal_type(foo.X) # revealed: Literal[42]
```
## Non-existent + bare to module
@@ -152,7 +152,7 @@ submodule via the attribute on its parent package.
```
```py path=package/foo.py
X: int = 42
X = 42
```
```py path=package/bar.py

View File

@@ -283,21 +283,6 @@ def _(
reveal_type(not_object) # revealed: Never
```
### `object & ~T` is equivalent to `~T`
A second consequence of the fact that `object` is the top type is that `object` is always redundant
in intersections, and can be eagerly simplified out. `object & P` is equivalent to `P`;
`object & ~P` is equivalent to `~P` for any type `P`.
```py
from knot_extensions import Intersection, Not, is_equivalent_to, static_assert
class P: ...
static_assert(is_equivalent_to(Intersection[object, P], P))
static_assert(is_equivalent_to(Intersection[object, Not[P]], Not[P]))
```
### Intersection of a type and its negation
Continuing with more [complement laws], if we see both `P` and `~P` in an intersection, we can
@@ -650,91 +635,6 @@ def _(
reveal_type(i8) # revealed: Never
```
### Simplifications of `bool`, `AlwaysTruthy` and `AlwaysFalsy`
In general, intersections with `AlwaysTruthy` and `AlwaysFalsy` cannot be simplified. Naively, you
might think that `int & AlwaysFalsy` could simplify to `Literal[0]`, but this is not the case: for
example, the `False` constant inhabits the type `int & AlwaysFalsy` (due to the fact that
`False.__class__` is `bool` at runtime, and `bool` subclasses `int`), but `False` does not inhabit
the type `Literal[0]`.
Nonetheless, intersections of `AlwaysFalsy` or `AlwaysTruthy` with `bool` _can_ be simplified, due
to the fact that `bool` is a `@final` class at runtime that cannot be subclassed.
```py
from knot_extensions import Intersection, Not, AlwaysTruthy, AlwaysFalsy
class P: ...
def f(
a: Intersection[bool, AlwaysTruthy],
b: Intersection[bool, AlwaysFalsy],
c: Intersection[bool, Not[AlwaysTruthy]],
d: Intersection[bool, Not[AlwaysFalsy]],
e: Intersection[bool, AlwaysTruthy, P],
f: Intersection[bool, AlwaysFalsy, P],
g: Intersection[bool, Not[AlwaysTruthy], P],
h: Intersection[bool, Not[AlwaysFalsy], P],
):
reveal_type(a) # revealed: Literal[True]
reveal_type(b) # revealed: Literal[False]
reveal_type(c) # revealed: Literal[False]
reveal_type(d) # revealed: Literal[True]
# `bool & AlwaysTruthy & P` -> `Literal[True] & P` -> `Never`
reveal_type(e) # revealed: Never
reveal_type(f) # revealed: Never
reveal_type(g) # revealed: Never
reveal_type(h) # revealed: Never
```
## Simplification of `LiteralString`, `AlwaysTruthy` and `AlwaysFalsy`
Similarly, intersections between `LiteralString`, `AlwaysTruthy` and `AlwaysFalsy` can be
simplified, due to the fact that a `LiteralString` inhabitant is known to have `__class__` set to
exactly `str` (and not a subclass of `str`):
```py
from knot_extensions import Intersection, Not, AlwaysTruthy, AlwaysFalsy, Unknown
from typing_extensions import LiteralString
def f(
a: Intersection[LiteralString, AlwaysTruthy],
b: Intersection[LiteralString, AlwaysFalsy],
c: Intersection[LiteralString, Not[AlwaysTruthy]],
d: Intersection[LiteralString, Not[AlwaysFalsy]],
e: Intersection[AlwaysFalsy, LiteralString],
f: Intersection[Not[AlwaysTruthy], LiteralString],
g: Intersection[AlwaysTruthy, LiteralString],
h: Intersection[Not[AlwaysFalsy], LiteralString],
i: Intersection[Unknown, LiteralString, AlwaysFalsy],
j: Intersection[Not[AlwaysTruthy], Unknown, LiteralString],
):
reveal_type(a) # revealed: LiteralString & ~Literal[""]
reveal_type(b) # revealed: Literal[""]
reveal_type(c) # revealed: Literal[""]
reveal_type(d) # revealed: LiteralString & ~Literal[""]
reveal_type(e) # revealed: Literal[""]
reveal_type(f) # revealed: Literal[""]
reveal_type(g) # revealed: LiteralString & ~Literal[""]
reveal_type(h) # revealed: LiteralString & ~Literal[""]
reveal_type(i) # revealed: Unknown & Literal[""]
reveal_type(j) # revealed: Unknown & Literal[""]
```
## Addition of a type to an intersection with many non-disjoint types
This slightly strange-looking test is a regression test for a mistake that was nearly made in a PR:
<https://github.com/astral-sh/ruff/pull/15475#discussion_r1915041987>.
```py
from knot_extensions import AlwaysFalsy, Intersection, Unknown
from typing_extensions import Literal
def _(x: Intersection[str, Unknown, AlwaysFalsy, Literal[""]]):
reveal_type(x) # revealed: Unknown & Literal[""]
```
## Non fully-static types
### Negation of dynamic types

View File

@@ -3,16 +3,17 @@
## Expression
```py
from typing_extensions import Literal
x = 0
y = str()
z = False
def _(x: Literal[0], y: str, z: Literal[False]):
reveal_type(f"hello") # revealed: Literal["hello"]
reveal_type(f"h {x}") # revealed: Literal["h 0"]
reveal_type("one " f"single " f"literal") # revealed: Literal["one single literal"]
reveal_type("first " f"second({x})" f" third") # revealed: Literal["first second(0) third"]
reveal_type(f"-{y}-") # revealed: str
reveal_type(f"-{y}-" f"--" "--") # revealed: str
reveal_type(f"{z} == {False} is {True}") # revealed: Literal["False == False is True"]
reveal_type(f"hello") # revealed: Literal["hello"]
reveal_type(f"h {x}") # revealed: Literal["h 0"]
reveal_type("one " f"single " f"literal") # revealed: Literal["one single literal"]
reveal_type("first " f"second({x})" f" third") # revealed: Literal["first second(0) third"]
reveal_type(f"-{y}-") # revealed: str
reveal_type(f"-{y}-" f"--" "--") # revealed: str
reveal_type(f"{z} == {False} is {True}") # revealed: Literal["False == False is True"]
```
## Conversion Flags

View File

@@ -109,9 +109,9 @@ reveal_type(x)
def _(flag: bool):
class NotIterable:
if flag:
__iter__: int = 1
__iter__ = 1
else:
__iter__: None = None
__iter__ = 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 = None
__iter__ = None
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
pass

View File

@@ -1,95 +0,0 @@
# 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:
```pyi path=/typeshed/stdlib/builtins.pyi
class BuiltinClass: ...
builtin_symbol: BuiltinClass
```
```pyi path=/typeshed/stdlib/sys/__init__.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"
```
```pyi path=/typeshed/stdlib/old_module.pyi
class OldClass: ...
```
```pyi path=/typeshed/stdlib/new_module.pyi
class NewClass: ...
```
```text path=/typeshed/stdlib/VERSIONS
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"
```
```pyi path=/typeshed/stdlib/typing_extensions.pyi
def reveal_type(obj, /): ...
```
```py
reveal_type(()) # revealed: tuple[()]
```

View File

@@ -396,10 +396,11 @@ class Foo: ...
class BarCycle(FooCycle): ... # error: [cyclic-class-definition]
class Bar(Foo): ...
# Avoid emitting the errors for these. The classes have cyclic superclasses,
# TODO: can we avoid emitting the errors for these?
# The classes have cyclic superclasses,
# but are not themselves cyclic...
class Baz(Bar, BarCycle): ...
class Spam(Baz): ...
class Baz(Bar, BarCycle): ... # error: [cyclic-class-definition]
class Spam(Baz): ... # error: [cyclic-class-definition]
reveal_type(FooCycle.__mro__) # revealed: tuple[Literal[FooCycle], Unknown, Literal[object]]
reveal_type(BarCycle.__mro__) # revealed: tuple[Literal[BarCycle], Unknown, Literal[object]]

View File

@@ -91,7 +91,8 @@ if isinstance(x, (A, B)):
elif isinstance(x, (A, C)):
reveal_type(x) # revealed: C & ~A & ~B
else:
reveal_type(x) # revealed: ~A & ~B & ~C
# TODO: Should be simplified to ~A & ~B & ~C
reveal_type(x) # revealed: object & ~A & ~B & ~C
```
## No narrowing for instances of `builtins.type`
@@ -180,39 +181,3 @@ def _(x: object, y: type[int]):
if isinstance(x, y):
reveal_type(x) # revealed: int
```
## Adding a disjoint element to an existing intersection
We used to incorrectly infer `Literal` booleans for some of these.
```py
from knot_extensions import Not, Intersection, AlwaysTruthy, AlwaysFalsy
class P: ...
def f(
a: Intersection[P, AlwaysTruthy],
b: Intersection[P, AlwaysFalsy],
c: Intersection[P, Not[AlwaysTruthy]],
d: Intersection[P, Not[AlwaysFalsy]],
):
if isinstance(a, bool):
reveal_type(a) # revealed: Never
else:
reveal_type(a) # revealed: P & AlwaysTruthy
if isinstance(b, bool):
reveal_type(b) # revealed: Never
else:
reveal_type(b) # revealed: P & AlwaysFalsy
if isinstance(c, bool):
reveal_type(c) # revealed: Never
else:
reveal_type(c) # revealed: P & ~AlwaysTruthy
if isinstance(d, bool):
reveal_type(d) # revealed: Never
else:
reveal_type(d) # revealed: P & ~AlwaysFalsy
```

View File

@@ -246,31 +246,3 @@ def _(x: type, y: type[int]):
if issubclass(x, y):
reveal_type(x) # revealed: type[int]
```
### Disjoint `type[]` types are narrowed to `Never`
Here, `type[UsesMeta1]` and `type[UsesMeta2]` are disjoint because a common subclass of `UsesMeta1`
and `UsesMeta2` could only exist if a common subclass of their metaclasses could exist. This is
known to be impossible due to the fact that `Meta1` is marked as `@final`.
```py
from typing import final
@final
class Meta1(type): ...
class Meta2(type): ...
class UsesMeta1(metaclass=Meta1): ...
class UsesMeta2(metaclass=Meta2): ...
def _(x: type[UsesMeta1], y: type[UsesMeta2]):
if issubclass(x, y):
reveal_type(x) # revealed: Never
else:
reveal_type(x) # revealed: type[UsesMeta1]
if issubclass(y, x):
reveal_type(y) # revealed: Never
else:
reveal_type(y) # revealed: type[UsesMeta2]
```

View File

@@ -11,37 +11,37 @@ x = foo()
if x:
reveal_type(x) # revealed: Literal[-1, True, "foo", b"bar"]
else:
reveal_type(x) # revealed: Literal[0, False, "", b""] | tuple[()] | None
reveal_type(x) # revealed: Literal[0, False, "", b""] | None | tuple[()]
if not x:
reveal_type(x) # revealed: Literal[0, False, "", b""] | tuple[()] | None
reveal_type(x) # revealed: Literal[0, False, "", b""] | None | tuple[()]
else:
reveal_type(x) # revealed: Literal[-1, True, "foo", b"bar"]
if x and not x:
reveal_type(x) # revealed: Never
else:
reveal_type(x) # revealed: Literal[0, -1, b"bar", "", "foo", b""] | bool | tuple[()] | None
reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]
if not (x and not x):
reveal_type(x) # revealed: Literal[0, -1, b"bar", "", "foo", b""] | bool | tuple[()] | None
reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]
else:
reveal_type(x) # revealed: Never
if x or not x:
reveal_type(x) # revealed: Literal[0, -1, b"bar", "", "foo", b""] | bool | tuple[()] | None
reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]
else:
reveal_type(x) # revealed: Never
if not (x or not x):
reveal_type(x) # revealed: Never
else:
reveal_type(x) # revealed: Literal[0, -1, b"bar", "", "foo", b""] | bool | tuple[()] | None
reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]
if (isinstance(x, int) or isinstance(x, str)) and x:
reveal_type(x) # revealed: Literal[-1, True, "foo"]
else:
reveal_type(x) # revealed: tuple[()] | None | Literal[b"", b"bar", 0, False, ""]
reveal_type(x) # revealed: Literal[b"", b"bar", 0, False, ""] | None | tuple[()]
```
## Function Literals
@@ -199,7 +199,7 @@ def f(x: Literal[0, 1], y: Literal["", "hello"]):
reveal_type(y) # revealed: Literal["", "hello"]
```
## Control Flow Merging
## ControlFlow Merging
After merging control flows, when we take the union of all constraints applied in each branch, we
should return to the original state.
@@ -312,20 +312,3 @@ def _(x: type[FalsyClass] | type[TruthyClass]):
reveal_type(x or A()) # revealed: type[TruthyClass] | A
reveal_type(x and A()) # revealed: type[FalsyClass] | A
```
## Truthiness narrowing for `LiteralString`
```py
from typing_extensions import LiteralString
def _(x: LiteralString):
if x:
reveal_type(x) # revealed: LiteralString & ~Literal[""]
else:
reveal_type(x) # revealed: Literal[""]
if not x:
reveal_type(x) # revealed: Literal[""]
else:
reveal_type(x) # revealed: LiteralString & ~Literal[""]
```

View File

@@ -99,9 +99,9 @@ def _(x: str | int):
class A: ...
class B: ...
def _(x: A | B):
alias_for_type = type
alias_for_type = type
def _(x: A | B):
if alias_for_type(x) is A:
reveal_type(x) # revealed: A
```

View File

@@ -31,7 +31,7 @@ type IntOrStr = int | str
# TODO: This should either fall back to the specified type from typeshed,
# which is `Any`, or be the actual type of the runtime value expression
# `int | str`, i.e. `types.UnionType`.
reveal_type(IntOrStr.__value__) # revealed: @Todo(@property)
reveal_type(IntOrStr.__value__) # revealed: @Todo(instance attributes)
```
## Invalid assignment
@@ -74,22 +74,5 @@ type ListOrSet[T] = list[T] | set[T]
# TODO: Should be `tuple[typing.TypeVar | typing.ParamSpec | typing.TypeVarTuple, ...]`,
# as specified in the `typeshed` stubs.
reveal_type(ListOrSet.__type_params__) # revealed: @Todo(@property)
```
## `TypeAliasType` properties
Two `TypeAliasType`s are distinct and disjoint, even if they refer to the same type
```py
from knot_extensions import static_assert, is_equivalent_to, is_disjoint_from, TypeOf
type Alias1 = int
type Alias2 = int
type TypeAliasType1 = TypeOf[Alias1]
type TypeAliasType2 = TypeOf[Alias2]
static_assert(not is_equivalent_to(TypeAliasType1, TypeAliasType2))
static_assert(is_disjoint_from(TypeAliasType1, TypeAliasType2))
reveal_type(ListOrSet.__type_params__) # revealed: @Todo(instance attributes)
```

View File

@@ -10,10 +10,10 @@ def returns_bool() -> bool:
return True
if returns_bool():
chr: int = 1
chr = 1
def f():
reveal_type(chr) # revealed: Literal[chr] | int
reveal_type(chr) # revealed: Literal[chr] | Literal[1]
```
## Conditionally global or builtin, with annotation

View File

@@ -63,7 +63,7 @@ reveal_type(typing.__class__) # revealed: Literal[ModuleType]
# TODO: needs support for attribute access on instances, properties and generics;
# should be `dict[str, Any]`
reveal_type(typing.__dict__) # revealed: @Todo(@property)
reveal_type(typing.__dict__) # revealed: @Todo(instance attributes)
```
Typeshed includes a fake `__getattr__` method in the stub for `types.ModuleType` to help out with
@@ -95,8 +95,8 @@ from foo import __dict__ as foo_dict
# TODO: needs support for attribute access on instances, properties, and generics;
# should be `dict[str, Any]` for both of these:
reveal_type(foo.__dict__) # revealed: @Todo(@property)
reveal_type(foo_dict) # revealed: @Todo(@property)
reveal_type(foo.__dict__) # revealed: @Todo(instance attributes)
reveal_type(foo_dict) # revealed: @Todo(instance attributes)
```
## Conditionally global or `ModuleType` attribute

View File

@@ -6,7 +6,7 @@
def f():
x = 1
def g():
reveal_type(x) # revealed: Unknown | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
## Two levels up
@@ -16,7 +16,7 @@ def f():
x = 1
def g():
def h():
reveal_type(x) # revealed: Unknown | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
## Skips class scope
@@ -28,7 +28,7 @@ def f():
class C:
x = 2
def g():
reveal_type(x) # revealed: Unknown | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
## Skips annotation-only assignment
@@ -41,16 +41,5 @@ def f():
# name is otherwise not defined; maybe should be an error?
x: int
def h():
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]
reveal_type(x) # revealed: Literal[1]
```

View File

@@ -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: Unknown | Literal[2]
reveal_type(C.y) # revealed: Unknown | Literal[1]
reveal_type(C.x) # revealed: Literal[2]
reveal_type(C.y) # revealed: 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: Unknown | Literal[1, "abc"]
reveal_type(C.y) # revealed: Literal[1, "abc"]
```
## Unbound function local

View File

@@ -182,34 +182,3 @@ 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")
```

View File

@@ -11,7 +11,7 @@ version:
import sys
if sys.version_info >= (3, 9):
SomeFeature: str = "available"
SomeFeature = "available"
```
If we can statically determine that the condition is always true, then we can also understand that
@@ -21,7 +21,7 @@ If we can statically determine that the condition is always true, then we can al
from module1 import SomeFeature
# SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
reveal_type(SomeFeature) # revealed: str
reveal_type(SomeFeature) # revealed: Literal["available"]
```
Another scenario where this is useful is for `typing.TYPE_CHECKING` branches, which are often used

View File

@@ -31,7 +31,7 @@ def _(n: int):
## Slices
```py
b: bytes = b"\x00abc\xff"
b = b"\x00abc\xff"
reveal_type(b[0:2]) # revealed: Literal[b"\x00a"]
reveal_type(b[-3:]) # revealed: Literal[b"bc\xff"]

View File

@@ -14,8 +14,7 @@ a = NotSubscriptable()[0] # error: "Cannot subscript object of type `NotSubscri
class NotSubscriptable:
__getitem__ = None
# error: "Method `__getitem__` of type `Unknown | None` is not callable on object of type `NotSubscriptable`"
a = NotSubscriptable()[0]
a = NotSubscriptable()[0] # error: "Method `__getitem__` of type `None` is not callable on object of type `NotSubscriptable`"
```
## Valid getitem

View File

@@ -28,52 +28,52 @@ def _(n: int):
## Slices
```py
s = "abcde"
reveal_type(s[0:0]) # revealed: Literal[""]
reveal_type(s[0:1]) # revealed: Literal["a"]
reveal_type(s[0:2]) # revealed: Literal["ab"]
reveal_type(s[0:5]) # revealed: Literal["abcde"]
reveal_type(s[0:6]) # revealed: Literal["abcde"]
reveal_type(s[1:3]) # revealed: Literal["bc"]
reveal_type(s[-3:5]) # revealed: Literal["cde"]
reveal_type(s[-4:-2]) # revealed: Literal["bc"]
reveal_type(s[-10:10]) # revealed: Literal["abcde"]
reveal_type(s[0:]) # revealed: Literal["abcde"]
reveal_type(s[2:]) # revealed: Literal["cde"]
reveal_type(s[5:]) # revealed: Literal[""]
reveal_type(s[:2]) # revealed: Literal["ab"]
reveal_type(s[:0]) # revealed: Literal[""]
reveal_type(s[:2]) # revealed: Literal["ab"]
reveal_type(s[:10]) # revealed: Literal["abcde"]
reveal_type(s[:]) # revealed: Literal["abcde"]
reveal_type(s[::-1]) # revealed: Literal["edcba"]
reveal_type(s[::2]) # revealed: Literal["ace"]
reveal_type(s[-2:-5:-1]) # revealed: Literal["dcb"]
reveal_type(s[::-2]) # revealed: Literal["eca"]
reveal_type(s[-1::-3]) # revealed: Literal["eb"]
reveal_type(s[None:2:None]) # revealed: Literal["ab"]
reveal_type(s[1:None:1]) # revealed: Literal["bcde"]
reveal_type(s[None:None:None]) # revealed: Literal["abcde"]
start = 1
stop = None
step = 2
reveal_type(s[start:stop:step]) # revealed: Literal["bd"]
reveal_type(s[False:True]) # revealed: Literal["a"]
reveal_type(s[True:3]) # revealed: Literal["bc"]
s[0:4:0] # error: [zero-stepsize-in-slice]
s[:4:0] # error: [zero-stepsize-in-slice]
s[0::0] # error: [zero-stepsize-in-slice]
s[::0] # error: [zero-stepsize-in-slice]
def _(m: int, n: int, s2: str):
s = "abcde"
reveal_type(s[0:0]) # revealed: Literal[""]
reveal_type(s[0:1]) # revealed: Literal["a"]
reveal_type(s[0:2]) # revealed: Literal["ab"]
reveal_type(s[0:5]) # revealed: Literal["abcde"]
reveal_type(s[0:6]) # revealed: Literal["abcde"]
reveal_type(s[1:3]) # revealed: Literal["bc"]
reveal_type(s[-3:5]) # revealed: Literal["cde"]
reveal_type(s[-4:-2]) # revealed: Literal["bc"]
reveal_type(s[-10:10]) # revealed: Literal["abcde"]
reveal_type(s[0:]) # revealed: Literal["abcde"]
reveal_type(s[2:]) # revealed: Literal["cde"]
reveal_type(s[5:]) # revealed: Literal[""]
reveal_type(s[:2]) # revealed: Literal["ab"]
reveal_type(s[:0]) # revealed: Literal[""]
reveal_type(s[:2]) # revealed: Literal["ab"]
reveal_type(s[:10]) # revealed: Literal["abcde"]
reveal_type(s[:]) # revealed: Literal["abcde"]
reveal_type(s[::-1]) # revealed: Literal["edcba"]
reveal_type(s[::2]) # revealed: Literal["ace"]
reveal_type(s[-2:-5:-1]) # revealed: Literal["dcb"]
reveal_type(s[::-2]) # revealed: Literal["eca"]
reveal_type(s[-1::-3]) # revealed: Literal["eb"]
reveal_type(s[None:2:None]) # revealed: Literal["ab"]
reveal_type(s[1:None:1]) # revealed: Literal["bcde"]
reveal_type(s[None:None:None]) # revealed: Literal["abcde"]
start = 1
stop = None
step = 2
reveal_type(s[start:stop:step]) # revealed: Literal["bd"]
reveal_type(s[False:True]) # revealed: Literal["a"]
reveal_type(s[True:3]) # revealed: Literal["bc"]
s[0:4:0] # error: [zero-stepsize-in-slice]
s[:4:0] # error: [zero-stepsize-in-slice]
s[0::0] # error: [zero-stepsize-in-slice]
s[::0] # error: [zero-stepsize-in-slice]
substring1 = s[m:n]
# TODO: Support overloads... Should be `LiteralString`
reveal_type(substring1) # revealed: @Todo(return type)

View File

@@ -23,51 +23,51 @@ reveal_type(b) # revealed: Unknown
## Slices
```py
t = (1, "a", None, b"b")
reveal_type(t[0:0]) # revealed: tuple[()]
reveal_type(t[0:1]) # revealed: tuple[Literal[1]]
reveal_type(t[0:2]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[0:4]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[0:5]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[1:3]) # revealed: tuple[Literal["a"], None]
reveal_type(t[-2:4]) # revealed: tuple[None, Literal[b"b"]]
reveal_type(t[-3:-1]) # revealed: tuple[Literal["a"], None]
reveal_type(t[-10:10]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[0:]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[2:]) # revealed: tuple[None, Literal[b"b"]]
reveal_type(t[4:]) # revealed: tuple[()]
reveal_type(t[:0]) # revealed: tuple[()]
reveal_type(t[:2]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[:10]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[:]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[::-1]) # revealed: tuple[Literal[b"b"], None, Literal["a"], Literal[1]]
reveal_type(t[::2]) # revealed: tuple[Literal[1], None]
reveal_type(t[-2:-5:-1]) # revealed: tuple[None, Literal["a"], Literal[1]]
reveal_type(t[::-2]) # revealed: tuple[Literal[b"b"], Literal["a"]]
reveal_type(t[-1::-3]) # revealed: tuple[Literal[b"b"], Literal[1]]
reveal_type(t[None:2:None]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[1:None:1]) # revealed: tuple[Literal["a"], None, Literal[b"b"]]
reveal_type(t[None:None:None]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
start = 1
stop = None
step = 2
reveal_type(t[start:stop:step]) # revealed: tuple[Literal["a"], Literal[b"b"]]
reveal_type(t[False:True]) # revealed: tuple[Literal[1]]
reveal_type(t[True:3]) # revealed: tuple[Literal["a"], None]
t[0:4:0] # error: [zero-stepsize-in-slice]
t[:4:0] # error: [zero-stepsize-in-slice]
t[0::0] # error: [zero-stepsize-in-slice]
t[::0] # error: [zero-stepsize-in-slice]
def _(m: int, n: int):
t = (1, "a", None, b"b")
reveal_type(t[0:0]) # revealed: tuple[()]
reveal_type(t[0:1]) # revealed: tuple[Literal[1]]
reveal_type(t[0:2]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[0:4]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[0:5]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[1:3]) # revealed: tuple[Literal["a"], None]
reveal_type(t[-2:4]) # revealed: tuple[None, Literal[b"b"]]
reveal_type(t[-3:-1]) # revealed: tuple[Literal["a"], None]
reveal_type(t[-10:10]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[0:]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[2:]) # revealed: tuple[None, Literal[b"b"]]
reveal_type(t[4:]) # revealed: tuple[()]
reveal_type(t[:0]) # revealed: tuple[()]
reveal_type(t[:2]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[:10]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[:]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
reveal_type(t[::-1]) # revealed: tuple[Literal[b"b"], None, Literal["a"], Literal[1]]
reveal_type(t[::2]) # revealed: tuple[Literal[1], None]
reveal_type(t[-2:-5:-1]) # revealed: tuple[None, Literal["a"], Literal[1]]
reveal_type(t[::-2]) # revealed: tuple[Literal[b"b"], Literal["a"]]
reveal_type(t[-1::-3]) # revealed: tuple[Literal[b"b"], Literal[1]]
reveal_type(t[None:2:None]) # revealed: tuple[Literal[1], Literal["a"]]
reveal_type(t[1:None:1]) # revealed: tuple[Literal["a"], None, Literal[b"b"]]
reveal_type(t[None:None:None]) # revealed: tuple[Literal[1], Literal["a"], None, Literal[b"b"]]
start = 1
stop = None
step = 2
reveal_type(t[start:stop:step]) # revealed: tuple[Literal["a"], Literal[b"b"]]
reveal_type(t[False:True]) # revealed: tuple[Literal[1]]
reveal_type(t[True:3]) # revealed: tuple[Literal["a"], None]
t[0:4:0] # error: [zero-stepsize-in-slice]
t[:4:0] # error: [zero-stepsize-in-slice]
t[0::0] # error: [zero-stepsize-in-slice]
t[::0] # error: [zero-stepsize-in-slice]
tuple_slice = t[m:n]
# TODO: Support overloads... Should be `tuple[Literal[1, 'a', b"b"] | None, ...]`
reveal_type(tuple_slice) # revealed: @Todo(return type)

View File

@@ -180,11 +180,3 @@ a = 4 / 0 # error: [division-by-zero]
# error: [unknown-rule] "Unknown rule `is-equal-14`"
a = 10 + 4 # knot: ignore[is-equal-14]
```
## Code with `lint:` prefix
```py
# error:[unknown-rule] "Unknown rule `lint:division-by-zero`. Did you mean `division-by-zero`?"
# error: [division-by-zero]
a = 10 / 0 # knot: ignore[lint:division-by-zero]
```

View File

@@ -13,7 +13,7 @@ typeshed:
```py
import sys
reveal_type(sys.platform) # revealed: LiteralString
reveal_type(sys.platform) # revealed: str
```
## Explicit selection of `all` platforms
@@ -26,7 +26,7 @@ python-platform = "all"
```py
import sys
reveal_type(sys.platform) # revealed: LiteralString
reveal_type(sys.platform) # revealed: str
```
## Explicit selection of a specific platform
@@ -66,6 +66,6 @@ It is [recommended](https://docs.python.org/3/library/sys.html#sys.platform) to
```py
import sys
reveal_type(sys.platform.startswith("freebsd")) # revealed: @Todo(Attribute access on `LiteralString` types)
reveal_type(sys.platform.startswith("linux")) # revealed: @Todo(Attribute access on `LiteralString` types)
reveal_type(sys.platform.startswith("freebsd")) # revealed: @Todo(instance attributes)
reveal_type(sys.platform.startswith("linux")) # revealed: @Todo(instance attributes)
```

View File

@@ -117,9 +117,9 @@ properties on instance types:
```py path=b.py
import sys
reveal_type(sys.version_info.micro) # revealed: @Todo(@property)
reveal_type(sys.version_info.releaselevel) # revealed: @Todo(@property)
reveal_type(sys.version_info.serial) # revealed: @Todo(@property)
reveal_type(sys.version_info.micro) # revealed: @Todo(instance attributes)
reveal_type(sys.version_info.releaselevel) # revealed: @Todo(instance attributes)
reveal_type(sys.version_info.serial) # revealed: @Todo(instance attributes)
```
## Accessing fields by index/slice

View File

@@ -94,36 +94,6 @@ reveal_type(C.__mro__)
u: Unknown[str]
```
### `AlwaysTruthy` and `AlwaysFalsy`
`AlwaysTruthy` and `AlwaysFalsy` represent the sets of all possible objects whose truthiness is
always truthy or falsy, respectively.
They do not accept any type arguments.
```py
from typing_extensions import Literal
from knot_extensions import AlwaysFalsy, AlwaysTruthy, is_subtype_of, static_assert
static_assert(is_subtype_of(Literal[True], AlwaysTruthy))
static_assert(is_subtype_of(Literal[False], AlwaysFalsy))
static_assert(not is_subtype_of(int, AlwaysFalsy))
static_assert(not is_subtype_of(str, AlwaysFalsy))
def _(t: AlwaysTruthy, f: AlwaysFalsy):
reveal_type(t) # revealed: AlwaysTruthy
reveal_type(f) # revealed: AlwaysFalsy
def f(
a: AlwaysTruthy[int], # error: [invalid-type-form]
b: AlwaysFalsy[str], # error: [invalid-type-form]
):
reveal_type(a) # revealed: Unknown
reveal_type(b) # revealed: Unknown
```
## Static assertions
### Basics

View File

@@ -33,7 +33,7 @@ in strict mode.
```py
def f(x: type):
reveal_type(x) # revealed: type
reveal_type(x.__repr__) # revealed: @Todo(bound method)
reveal_type(x.__repr__) # revealed: @Todo(instance attributes)
class A: ...
@@ -48,7 +48,7 @@ x: type = A() # error: [invalid-assignment]
```py
def f(x: type[object]):
reveal_type(x) # revealed: type
reveal_type(x.__repr__) # revealed: @Todo(bound method)
reveal_type(x.__repr__) # revealed: @Todo(instance attributes)
class A: ...

View File

@@ -263,12 +263,15 @@ static_assert(not is_assignable_to(int, Not[int]))
static_assert(not is_assignable_to(int, Not[Literal[1]]))
static_assert(not is_assignable_to(Intersection[Any, Parent], Unrelated))
static_assert(is_assignable_to(Intersection[Unrelated, Any], Intersection[Unrelated, Any]))
static_assert(is_assignable_to(Intersection[Unrelated, Any], Intersection[Unrelated, Not[Any]]))
# TODO: The following assertions should not fail (see https://github.com/astral-sh/ruff/issues/14899)
# error: [static-assert-error]
static_assert(is_assignable_to(Intersection[Any, int], int))
# error: [static-assert-error]
static_assert(is_assignable_to(Intersection[Unrelated, Any], Intersection[Unrelated, Any]))
# error: [static-assert-error]
static_assert(is_assignable_to(Intersection[Unrelated, Any], Intersection[Unrelated, Not[Any]]))
# error: [static-assert-error]
static_assert(is_assignable_to(Intersection[Unrelated, Any], Not[tuple[Unrelated, Any]]))
```
@@ -346,24 +349,4 @@ static_assert(is_assignable_to(Never, type[str]))
static_assert(is_assignable_to(Never, type[Any]))
```
### `bool` is assignable to unions that include `bool`
Since we decompose `bool` to `Literal[True, False]` in unions, it would be surprisingly easy to get
this wrong if we forgot to normalize `bool` to `Literal[True, False]` when it appeared on the
left-hand side in `Type::is_assignable_to()`.
```py
from knot_extensions import is_assignable_to, static_assert
static_assert(is_assignable_to(bool, str | bool))
```
### `bool` is assignable to `AlwaysTruthy | AlwaysFalsy`
```py
from knot_extensions import static_assert, is_assignable_to, AlwaysTruthy, AlwaysFalsy
static_assert(is_assignable_to(bool, AlwaysTruthy | AlwaysFalsy))
```
[typing documentation]: https://typing.readthedocs.io/en/latest/spec/concepts.html#the-assignable-to-or-consistent-subtyping-relation

View File

@@ -1,310 +0,0 @@
# Disjointness relation
Two types `S` and `T` are disjoint if their intersection `S & T` is empty (equivalent to `Never`).
This means that it is known that no possible runtime object inhabits both types simultaneously.
## Basic builtin types
```py
from typing_extensions import Literal, LiteralString, Any
from knot_extensions import Intersection, Not, TypeOf, is_disjoint_from, static_assert
static_assert(is_disjoint_from(bool, str))
static_assert(not is_disjoint_from(bool, bool))
static_assert(not is_disjoint_from(bool, int))
static_assert(not is_disjoint_from(bool, object))
static_assert(not is_disjoint_from(Any, bool))
static_assert(not is_disjoint_from(Any, Any))
static_assert(not is_disjoint_from(LiteralString, LiteralString))
static_assert(not is_disjoint_from(str, LiteralString))
static_assert(not is_disjoint_from(str, type))
static_assert(not is_disjoint_from(str, type[Any]))
```
## Class hierarchies
```py
from knot_extensions import is_disjoint_from, static_assert, Intersection, is_subtype_of
from typing import final
class A: ...
class B1(A): ...
class B2(A): ...
# B1 and B2 are subclasses of A, so they are not disjoint from A:
static_assert(not is_disjoint_from(A, B1))
static_assert(not is_disjoint_from(A, B2))
# The two subclasses B1 and B2 are also not disjoint ...
static_assert(not is_disjoint_from(B1, B2))
# ... because they could share a common subclass ...
class C(B1, B2): ...
# ... which lies in their intersection:
static_assert(is_subtype_of(C, Intersection[B1, B2]))
# However, if a class is marked final, it can not be subclassed ...
@final
class FinalSubclass(A): ...
static_assert(not is_disjoint_from(FinalSubclass, A))
# ... which makes it disjoint from B1, B2:
static_assert(is_disjoint_from(B1, FinalSubclass))
static_assert(is_disjoint_from(B2, FinalSubclass))
```
## Tuple types
```py
from typing_extensions import Literal
from knot_extensions import TypeOf, is_disjoint_from, static_assert
static_assert(is_disjoint_from(tuple[()], TypeOf[object]))
static_assert(is_disjoint_from(tuple[()], TypeOf[Literal]))
static_assert(is_disjoint_from(tuple[None], None))
static_assert(is_disjoint_from(tuple[None], Literal[b"a"]))
static_assert(is_disjoint_from(tuple[None], Literal["a"]))
static_assert(is_disjoint_from(tuple[None], Literal[1]))
static_assert(is_disjoint_from(tuple[None], Literal[True]))
static_assert(is_disjoint_from(tuple[Literal[1]], tuple[Literal[2]]))
static_assert(is_disjoint_from(tuple[Literal[1], Literal[2]], tuple[Literal[1]]))
static_assert(is_disjoint_from(tuple[Literal[1], Literal[2]], tuple[Literal[1], Literal[3]]))
static_assert(not is_disjoint_from(tuple[Literal[1], Literal[2]], tuple[Literal[1], int]))
```
## Unions
```py
from typing_extensions import Literal
from knot_extensions import Intersection, is_disjoint_from, static_assert
static_assert(is_disjoint_from(Literal[1, 2], Literal[3]))
static_assert(is_disjoint_from(Literal[1, 2], Literal[3, 4]))
static_assert(not is_disjoint_from(Literal[1, 2], Literal[2]))
static_assert(not is_disjoint_from(Literal[1, 2], Literal[2, 3]))
```
## Intersections
```py
from typing_extensions import Literal, final
from knot_extensions import Intersection, is_disjoint_from, static_assert
@final
class P: ...
@final
class Q: ...
@final
class R: ...
# For three pairwise disjoint classes ...
static_assert(is_disjoint_from(P, Q))
static_assert(is_disjoint_from(P, R))
static_assert(is_disjoint_from(Q, R))
# ... their intersections are also disjoint:
static_assert(is_disjoint_from(Intersection[P, Q], R))
static_assert(is_disjoint_from(Intersection[P, R], Q))
static_assert(is_disjoint_from(Intersection[Q, R], P))
# On the other hand, for non-disjoint classes ...
class X: ...
class Y: ...
class Z: ...
static_assert(not is_disjoint_from(X, Y))
static_assert(not is_disjoint_from(X, Z))
static_assert(not is_disjoint_from(Y, Z))
# ... their intersections are also not disjoint:
static_assert(not is_disjoint_from(Intersection[X, Y], Z))
static_assert(not is_disjoint_from(Intersection[X, Z], Y))
static_assert(not is_disjoint_from(Intersection[Y, Z], X))
```
## Special types
### `Never`
`Never` is disjoint from every type, including itself.
```py
from typing_extensions import Never
from knot_extensions import is_disjoint_from, static_assert
static_assert(is_disjoint_from(Never, Never))
static_assert(is_disjoint_from(Never, None))
static_assert(is_disjoint_from(Never, int))
static_assert(is_disjoint_from(Never, object))
```
### `None`
```py
from typing_extensions import Literal
from knot_extensions import is_disjoint_from, static_assert
static_assert(is_disjoint_from(None, Literal[True]))
static_assert(is_disjoint_from(None, Literal[1]))
static_assert(is_disjoint_from(None, Literal["test"]))
static_assert(is_disjoint_from(None, Literal[b"test"]))
static_assert(is_disjoint_from(None, LiteralString))
static_assert(is_disjoint_from(None, int))
static_assert(is_disjoint_from(None, type[object]))
static_assert(not is_disjoint_from(None, None))
static_assert(not is_disjoint_from(None, int | None))
static_assert(not is_disjoint_from(None, object))
```
### Literals
```py
from typing_extensions import Literal, LiteralString
from knot_extensions import TypeOf, is_disjoint_from, static_assert
static_assert(is_disjoint_from(Literal[True], Literal[False]))
static_assert(is_disjoint_from(Literal[True], Literal[1]))
static_assert(is_disjoint_from(Literal[False], Literal[0]))
static_assert(is_disjoint_from(Literal[1], Literal[2]))
static_assert(is_disjoint_from(Literal["a"], Literal["b"]))
static_assert(is_disjoint_from(Literal[b"a"], LiteralString))
static_assert(is_disjoint_from(Literal[b"a"], Literal[b"b"]))
static_assert(is_disjoint_from(Literal[b"a"], Literal["a"]))
static_assert(is_disjoint_from(type[object], TypeOf[Literal]))
static_assert(is_disjoint_from(type[str], LiteralString))
static_assert(not is_disjoint_from(Literal[True], Literal[True]))
static_assert(not is_disjoint_from(Literal[False], Literal[False]))
static_assert(not is_disjoint_from(Literal[True], bool))
static_assert(not is_disjoint_from(Literal[True], int))
static_assert(not is_disjoint_from(Literal[1], Literal[1]))
static_assert(not is_disjoint_from(Literal["a"], Literal["a"]))
static_assert(not is_disjoint_from(Literal["a"], LiteralString))
static_assert(not is_disjoint_from(Literal["a"], str))
```
### Class, module and function literals
```py
from types import ModuleType, FunctionType
from knot_extensions import TypeOf, is_disjoint_from, static_assert
class A: ...
class B: ...
type LiteralA = TypeOf[A]
type LiteralB = TypeOf[B]
# Class literals for different classes are always disjoint.
# They are singleton types that only contain the class object itself.
static_assert(is_disjoint_from(LiteralA, LiteralB))
# The class A is a subclass of A, so A is not disjoint from type[A]:
static_assert(not is_disjoint_from(LiteralA, type[A]))
# The class A is disjoint from type[B] because it's not a subclass of B:
static_assert(is_disjoint_from(LiteralA, type[B]))
# However, type[A] is not disjoint from type[B], as there could be
# classes that inherit from both A and B:
static_assert(not is_disjoint_from(type[A], type[B]))
import random
import math
static_assert(is_disjoint_from(TypeOf[random], TypeOf[math]))
static_assert(not is_disjoint_from(TypeOf[random], ModuleType))
static_assert(not is_disjoint_from(TypeOf[random], object))
def f(): ...
def g(): ...
static_assert(is_disjoint_from(TypeOf[f], TypeOf[g]))
static_assert(not is_disjoint_from(TypeOf[f], FunctionType))
static_assert(not is_disjoint_from(TypeOf[f], object))
```
### `AlwaysTruthy` and `AlwaysFalsy`
```py
from knot_extensions import AlwaysFalsy, AlwaysTruthy, is_disjoint_from, static_assert
static_assert(is_disjoint_from(None, AlwaysTruthy))
static_assert(not is_disjoint_from(None, AlwaysFalsy))
static_assert(is_disjoint_from(AlwaysFalsy, AlwaysTruthy))
static_assert(not is_disjoint_from(str, AlwaysFalsy))
static_assert(not is_disjoint_from(str, AlwaysTruthy))
static_assert(is_disjoint_from(Literal[1, 2], AlwaysFalsy))
static_assert(not is_disjoint_from(Literal[0, 1], AlwaysTruthy))
```
### Instance types versus `type[T]` types
An instance type is disjoint from a `type[T]` type if the instance type is `@final` and the class of
the instance type is not a subclass of `T`'s metaclass.
```py
from typing import final
from knot_extensions import is_disjoint_from, static_assert
@final
class Foo: ...
static_assert(is_disjoint_from(Foo, type[int]))
static_assert(is_disjoint_from(type[object], Foo))
static_assert(is_disjoint_from(type[dict], Foo))
# Instance types can be disjoint from `type[]` types
# even if the instance type is a subtype of `type`
@final
class Meta1(type): ...
class UsesMeta1(metaclass=Meta1): ...
static_assert(not is_disjoint_from(Meta1, type[UsesMeta1]))
class Meta2(type): ...
class UsesMeta2(metaclass=Meta2): ...
static_assert(not is_disjoint_from(Meta2, type[UsesMeta2]))
static_assert(is_disjoint_from(Meta1, type[UsesMeta2]))
```
### `type[T]` versus `type[S]`
By the same token, `type[T]` is disjoint from `type[S]` if the metaclass of `T` is disjoint from the
metaclass of `S`.
```py
from typing import final
from knot_extensions import static_assert, is_disjoint_from
@final
class Meta1(type): ...
class Meta2(type): ...
class UsesMeta1(metaclass=Meta1): ...
class UsesMeta2(metaclass=Meta2): ...
static_assert(is_disjoint_from(type[UsesMeta1], type[UsesMeta2]))
```

View File

@@ -1,165 +0,0 @@
# Equivalence relation
`is_equivalent_to` implements [the equivalence relation] for fully static types.
Two types `A` and `B` are equivalent iff `A` is a subtype of `B` and `B` is a subtype of `A`.
## Basic
```py
from typing import Any
from typing_extensions import Literal
from knot_extensions import Unknown, is_equivalent_to, static_assert
static_assert(is_equivalent_to(Literal[1, 2], Literal[1, 2]))
static_assert(is_equivalent_to(type[object], type))
static_assert(not is_equivalent_to(Any, Any))
static_assert(not is_equivalent_to(Unknown, Unknown))
static_assert(not is_equivalent_to(Any, None))
static_assert(not is_equivalent_to(Literal[1, 2], Literal[1, 0]))
static_assert(not is_equivalent_to(Literal[1, 2], Literal[1, 2, 3]))
```
## Equivalence is commutative
```py
from typing_extensions import Literal
from knot_extensions import is_equivalent_to, static_assert
static_assert(is_equivalent_to(type, type[object]))
static_assert(not is_equivalent_to(Literal[1, 0], Literal[1, 2]))
static_assert(not is_equivalent_to(Literal[1, 2, 3], Literal[1, 2]))
```
## Differently ordered intersections and unions are equivalent
```py
from knot_extensions import is_equivalent_to, static_assert, Intersection, Not
class P: ...
class Q: ...
class R: ...
class S: ...
static_assert(is_equivalent_to(P | Q | R, P | R | Q)) # 1
static_assert(is_equivalent_to(P | Q | R, Q | P | R)) # 2
static_assert(is_equivalent_to(P | Q | R, Q | R | P)) # 3
static_assert(is_equivalent_to(P | Q | R, R | P | Q)) # 4
static_assert(is_equivalent_to(P | Q | R, R | Q | P)) # 5
static_assert(is_equivalent_to(P | R | Q, Q | P | R)) # 6
static_assert(is_equivalent_to(P | R | Q, Q | R | P)) # 7
static_assert(is_equivalent_to(P | R | Q, R | P | Q)) # 8
static_assert(is_equivalent_to(P | R | Q, R | Q | P)) # 9
static_assert(is_equivalent_to(Q | P | R, Q | R | P)) # 10
static_assert(is_equivalent_to(Q | P | R, R | P | Q)) # 11
static_assert(is_equivalent_to(Q | P | R, R | Q | P)) # 12
static_assert(is_equivalent_to(Q | R | P, R | P | Q)) # 13
static_assert(is_equivalent_to(Q | R | P, R | Q | P)) # 14
static_assert(is_equivalent_to(R | P | Q, R | Q | P)) # 15
static_assert(is_equivalent_to(str | None, None | str))
static_assert(is_equivalent_to(Intersection[P, Q], Intersection[Q, P]))
static_assert(is_equivalent_to(Intersection[Q, Not[P]], Intersection[Not[P], Q]))
static_assert(is_equivalent_to(Intersection[Q, R, Not[P]], Intersection[Not[P], R, Q]))
static_assert(is_equivalent_to(Intersection[Q | R, Not[P | S]], Intersection[Not[S | P], R | Q]))
```
## Tuples containing equivalent but differently ordered unions/intersections are equivalent
```py
from knot_extensions import is_equivalent_to, TypeOf, static_assert, Intersection, Not
from typing import Literal
class P: ...
class Q: ...
class R: ...
class S: ...
static_assert(is_equivalent_to(tuple[P | Q], tuple[Q | P]))
static_assert(is_equivalent_to(tuple[P | None], tuple[None | P]))
static_assert(
is_equivalent_to(tuple[Intersection[P, Q] | Intersection[R, Not[S]]], tuple[Intersection[Not[S], R] | Intersection[Q, P]])
)
```
## Unions containing tuples containing tuples containing unions (etc.)
```py
from knot_extensions import is_equivalent_to, static_assert, Intersection
class P: ...
class Q: ...
static_assert(
is_equivalent_to(
tuple[tuple[tuple[P | Q]]] | P,
tuple[tuple[tuple[Q | P]]] | P,
)
)
static_assert(
is_equivalent_to(
tuple[tuple[tuple[tuple[tuple[Intersection[P, Q]]]]]],
tuple[tuple[tuple[tuple[tuple[Intersection[Q, P]]]]]],
)
)
```
## Intersections containing tuples containing unions
```py
from knot_extensions import is_equivalent_to, static_assert, Intersection
class P: ...
class Q: ...
class R: ...
static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
```
## Unions containing tuples containing `bool`
```py
from knot_extensions import is_equivalent_to, static_assert
from typing_extensions import Literal
class P: ...
static_assert(is_equivalent_to(tuple[Literal[True, False]] | P, tuple[bool] | P))
static_assert(is_equivalent_to(P | tuple[bool], P | tuple[Literal[True, False]]))
```
## Unions and intersections involving `AlwaysTruthy`, `bool` and `AlwaysFalsy`
```py
from knot_extensions import AlwaysTruthy, AlwaysFalsy, static_assert, is_equivalent_to, Not
from typing_extensions import Literal
static_assert(is_equivalent_to(AlwaysTruthy | bool, Literal[False] | AlwaysTruthy))
static_assert(is_equivalent_to(AlwaysFalsy | bool, Literal[True] | AlwaysFalsy))
static_assert(is_equivalent_to(Not[AlwaysTruthy] | bool, Not[AlwaysTruthy] | Literal[True]))
static_assert(is_equivalent_to(Not[AlwaysFalsy] | bool, Literal[False] | Not[AlwaysFalsy]))
```
## Unions and intersections involving `AlwaysTruthy`, `LiteralString` and `AlwaysFalsy`
```py
from knot_extensions import AlwaysTruthy, AlwaysFalsy, static_assert, is_equivalent_to, Not, Intersection
from typing_extensions import Literal, LiteralString
# TODO: these should all pass!
# error: [static-assert-error]
static_assert(is_equivalent_to(AlwaysTruthy | LiteralString, Literal[""] | AlwaysTruthy))
# error: [static-assert-error]
static_assert(is_equivalent_to(AlwaysFalsy | LiteralString, Intersection[LiteralString, Not[Literal[""]]] | AlwaysFalsy))
# error: [static-assert-error]
static_assert(is_equivalent_to(Not[AlwaysFalsy] | LiteralString, Literal[""] | Not[AlwaysFalsy]))
# error: [static-assert-error]
static_assert(
is_equivalent_to(Not[AlwaysTruthy] | LiteralString, Not[AlwaysTruthy] | Intersection[LiteralString, Not[Literal[""]]])
)
```
[the equivalence relation]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-equivalent

View File

@@ -1,54 +0,0 @@
# Fully-static types
A type is fully static iff it does not contain any gradual forms.
## Fully-static
```py
from typing_extensions import Literal, LiteralString, Never
from knot_extensions import Intersection, Not, TypeOf, is_fully_static, static_assert
static_assert(is_fully_static(Never))
static_assert(is_fully_static(None))
static_assert(is_fully_static(Literal[1]))
static_assert(is_fully_static(Literal[True]))
static_assert(is_fully_static(Literal["abc"]))
static_assert(is_fully_static(Literal[b"abc"]))
static_assert(is_fully_static(LiteralString))
static_assert(is_fully_static(str))
static_assert(is_fully_static(object))
static_assert(is_fully_static(type))
static_assert(is_fully_static(TypeOf[str]))
static_assert(is_fully_static(TypeOf[Literal]))
static_assert(is_fully_static(str | None))
static_assert(is_fully_static(Intersection[str, Not[LiteralString]]))
static_assert(is_fully_static(tuple[()]))
static_assert(is_fully_static(tuple[int, object]))
static_assert(is_fully_static(type[str]))
static_assert(is_fully_static(type[object]))
```
## Non-fully-static
```py
from typing_extensions import Any, Literal, LiteralString
from knot_extensions import Intersection, Not, TypeOf, Unknown, is_fully_static, static_assert
static_assert(not is_fully_static(Any))
static_assert(not is_fully_static(Unknown))
static_assert(not is_fully_static(Any | str))
static_assert(not is_fully_static(str | Unknown))
static_assert(not is_fully_static(Intersection[Any, Not[LiteralString]]))
static_assert(not is_fully_static(tuple[Any, ...]))
static_assert(not is_fully_static(tuple[int, Any]))
static_assert(not is_fully_static(type[Any]))
```

View File

@@ -1,64 +0,0 @@
# Gradual equivalence relation
Two gradual types `A` and `B` are equivalent if all [materializations] of `A` are also
materializations of `B`, and all materializations of `B` are also materializations of `A`.
## Basic
```py
from typing import Any
from typing_extensions import Literal, LiteralString, Never
from knot_extensions import AlwaysFalsy, AlwaysTruthy, TypeOf, Unknown, is_gradual_equivalent_to, static_assert
static_assert(is_gradual_equivalent_to(Any, Any))
static_assert(is_gradual_equivalent_to(Unknown, Unknown))
static_assert(is_gradual_equivalent_to(Any, Unknown))
static_assert(is_gradual_equivalent_to(Never, Never))
static_assert(is_gradual_equivalent_to(AlwaysTruthy, AlwaysTruthy))
static_assert(is_gradual_equivalent_to(AlwaysFalsy, AlwaysFalsy))
static_assert(is_gradual_equivalent_to(LiteralString, LiteralString))
static_assert(is_gradual_equivalent_to(Literal[True], Literal[True]))
static_assert(is_gradual_equivalent_to(Literal[False], Literal[False]))
static_assert(is_gradual_equivalent_to(TypeOf[0:1:2], TypeOf[0:1:2]))
static_assert(is_gradual_equivalent_to(TypeOf[str], TypeOf[str]))
static_assert(is_gradual_equivalent_to(type, type[object]))
static_assert(not is_gradual_equivalent_to(type, type[Any]))
static_assert(not is_gradual_equivalent_to(type[object], type[Any]))
```
## Unions and intersections
```py
from typing import Any
from knot_extensions import Intersection, Not, Unknown, is_gradual_equivalent_to, static_assert
static_assert(is_gradual_equivalent_to(str | int, str | int))
static_assert(is_gradual_equivalent_to(str | int | Any, str | int | Unknown))
static_assert(is_gradual_equivalent_to(str | int, int | str))
static_assert(
is_gradual_equivalent_to(Intersection[str, int, Not[bytes], Not[None]], Intersection[int, str, Not[None], Not[bytes]])
)
# TODO: `~type[Any]` shoudld be gradually equivalent to `~type[Unknown]`
# error: [static-assert-error]
static_assert(is_gradual_equivalent_to(Intersection[str | int, Not[type[Any]]], Intersection[int | str, Not[type[Unknown]]]))
static_assert(not is_gradual_equivalent_to(str | int, int | str | bytes))
static_assert(not is_gradual_equivalent_to(str | int | bytes, int | str | dict))
```
## Tuples
```py
from knot_extensions import Unknown, is_gradual_equivalent_to, static_assert
static_assert(is_gradual_equivalent_to(tuple[str, Any], tuple[str, Unknown]))
static_assert(not is_gradual_equivalent_to(tuple[str, int], tuple[str, int, bytes]))
static_assert(not is_gradual_equivalent_to(tuple[str, int], tuple[int, str]))
```
[materializations]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-materialize

View File

@@ -1,25 +0,0 @@
## Single-valued types
A type is single-valued iff it is not empty and all inhabitants of it compare equal.
```py
from typing_extensions import Any, Literal, LiteralString, Never
from knot_extensions import is_single_valued, static_assert
static_assert(is_single_valued(None))
static_assert(is_single_valued(Literal[True]))
static_assert(is_single_valued(Literal[1]))
static_assert(is_single_valued(Literal["abc"]))
static_assert(is_single_valued(Literal[b"abc"]))
static_assert(is_single_valued(tuple[()]))
static_assert(is_single_valued(tuple[Literal[True], Literal[1]]))
static_assert(not is_single_valued(str))
static_assert(not is_single_valued(Never))
static_assert(not is_single_valued(Any))
static_assert(not is_single_valued(Literal[1, 2]))
static_assert(not is_single_valued(tuple[None, int]))
```

View File

@@ -1,56 +0,0 @@
# Singleton types
A type is a singleton type iff it has exactly one inhabitant.
## Basic
```py
from typing_extensions import Literal, Never
from knot_extensions import is_singleton, static_assert
static_assert(is_singleton(None))
static_assert(is_singleton(Literal[True]))
static_assert(is_singleton(Literal[False]))
static_assert(is_singleton(type[bool]))
static_assert(not is_singleton(Never))
static_assert(not is_singleton(str))
static_assert(not is_singleton(Literal[345]))
static_assert(not is_singleton(Literal[1, 2]))
static_assert(not is_singleton(tuple[()]))
static_assert(not is_singleton(tuple[None]))
static_assert(not is_singleton(tuple[None, Literal[True]]))
```
## `NoDefault`
### 3.12
```toml
[environment]
python-version = "3.12"
```
```py
from typing_extensions import _NoDefaultType
from knot_extensions import is_singleton, static_assert
static_assert(is_singleton(_NoDefaultType))
```
### 3.13
```toml
[environment]
python-version = "3.13"
```
```py
from typing import _NoDefaultType
from knot_extensions import is_singleton, static_assert
static_assert(is_singleton(_NoDefaultType))
```

View File

@@ -1,479 +0,0 @@
# Subtype relation
The `is_subtype_of(S, T)` relation below checks if type `S` is a subtype of type `T`.
A fully static type `S` is a subtype of another fully static type `T` iff the set of values
represented by `S` is a subset of the set of values represented by `T`.
See the [typing documentation] for more information.
## Basic builtin types
- `bool` is a subtype of `int`. This is modeled after Python's runtime behavior, where `int` is a
supertype of `bool` (present in `bool`s bases and MRO).
- `int` is not a subtype of `float`/`complex`, even though `float`/`complex` can be used in place of
`int` in some contexts (see [special case for float and complex]).
```py
from knot_extensions import is_subtype_of, static_assert
static_assert(is_subtype_of(bool, bool))
static_assert(is_subtype_of(bool, int))
static_assert(is_subtype_of(bool, object))
static_assert(is_subtype_of(int, int))
static_assert(is_subtype_of(int, object))
static_assert(is_subtype_of(object, object))
static_assert(not is_subtype_of(int, bool))
static_assert(not is_subtype_of(int, str))
static_assert(not is_subtype_of(object, int))
static_assert(not is_subtype_of(int, float))
static_assert(not is_subtype_of(int, complex))
static_assert(is_subtype_of(TypeError, Exception))
static_assert(is_subtype_of(FloatingPointError, Exception))
```
## Class hierarchies
```py
from knot_extensions import is_subtype_of, static_assert
from typing_extensions import Never
class A: ...
class B1(A): ...
class B2(A): ...
class C(B1, B2): ...
static_assert(is_subtype_of(B1, A))
static_assert(not is_subtype_of(A, B1))
static_assert(is_subtype_of(B2, A))
static_assert(not is_subtype_of(A, B2))
static_assert(not is_subtype_of(B1, B2))
static_assert(not is_subtype_of(B2, B1))
static_assert(is_subtype_of(C, B1))
static_assert(is_subtype_of(C, B2))
static_assert(not is_subtype_of(B1, C))
static_assert(not is_subtype_of(B2, C))
static_assert(is_subtype_of(C, A))
static_assert(not is_subtype_of(A, C))
static_assert(is_subtype_of(Never, A))
static_assert(is_subtype_of(Never, B1))
static_assert(is_subtype_of(Never, B2))
static_assert(is_subtype_of(Never, C))
static_assert(is_subtype_of(A, object))
static_assert(is_subtype_of(B1, object))
static_assert(is_subtype_of(B2, object))
static_assert(is_subtype_of(C, object))
```
## Literal types
```py
from typing_extensions import Literal, LiteralString
from knot_extensions import is_subtype_of, static_assert
# Boolean literals
static_assert(is_subtype_of(Literal[True], bool))
static_assert(is_subtype_of(Literal[True], int))
static_assert(is_subtype_of(Literal[True], object))
# Integer literals
static_assert(is_subtype_of(Literal[1], int))
static_assert(is_subtype_of(Literal[1], object))
static_assert(not is_subtype_of(Literal[1], bool))
# See the note above (or link below) concerning int and float/complex
static_assert(not is_subtype_of(Literal[1], float))
# String literals
static_assert(is_subtype_of(Literal["foo"], LiteralString))
static_assert(is_subtype_of(Literal["foo"], str))
static_assert(is_subtype_of(Literal["foo"], object))
static_assert(is_subtype_of(LiteralString, str))
static_assert(is_subtype_of(LiteralString, object))
# Bytes literals
static_assert(is_subtype_of(Literal[b"foo"], bytes))
static_assert(is_subtype_of(Literal[b"foo"], object))
```
## Tuple types
```py
from knot_extensions import is_subtype_of, static_assert
class A1: ...
class B1(A1): ...
class A2: ...
class B2(A2): ...
class Unrelated: ...
static_assert(is_subtype_of(B1, A1))
static_assert(is_subtype_of(B2, A2))
# Zero-element tuples
static_assert(is_subtype_of(tuple[()], tuple[()]))
static_assert(not is_subtype_of(tuple[()], tuple[Unrelated]))
# One-element tuples
static_assert(is_subtype_of(tuple[B1], tuple[A1]))
static_assert(not is_subtype_of(tuple[B1], tuple[Unrelated]))
static_assert(not is_subtype_of(tuple[B1], tuple[()]))
static_assert(not is_subtype_of(tuple[B1], tuple[A1, Unrelated]))
# Two-element tuples
static_assert(is_subtype_of(tuple[B1, B2], tuple[A1, A2]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[Unrelated, A2]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[A1, Unrelated]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[Unrelated, Unrelated]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[()]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[A1]))
static_assert(not is_subtype_of(tuple[B1, B2], tuple[A1, A2, Unrelated]))
static_assert(is_subtype_of(tuple[int], tuple))
```
## Union types
```py
from knot_extensions import is_subtype_of, static_assert
class A: ...
class B1(A): ...
class B2(A): ...
class Unrelated1: ...
class Unrelated2: ...
static_assert(is_subtype_of(B1, A))
static_assert(is_subtype_of(B2, A))
# Union on the right hand side
static_assert(is_subtype_of(B1, A | Unrelated1))
static_assert(is_subtype_of(B1, Unrelated1 | A))
static_assert(not is_subtype_of(B1, Unrelated1 | Unrelated2))
# Union on the left hand side
static_assert(is_subtype_of(B1 | B2, A))
static_assert(is_subtype_of(B1 | B2 | A, object))
static_assert(not is_subtype_of(B1 | Unrelated1, A))
static_assert(not is_subtype_of(Unrelated1 | B1, A))
# Union on both sides
static_assert(is_subtype_of(B1 | bool, A | int))
static_assert(is_subtype_of(B1 | bool, int | A))
static_assert(not is_subtype_of(B1 | bool, Unrelated1 | int))
static_assert(not is_subtype_of(B1 | bool, int | Unrelated1))
# Example: Unions of literals
static_assert(is_subtype_of(Literal[1, 2, 3], int))
static_assert(not is_subtype_of(Literal[1, "two", 3], int))
```
## Intersection types
```py
from typing_extensions import Literal, LiteralString
from knot_extensions import Intersection, Not, is_subtype_of, static_assert
class A: ...
class B1(A): ...
class B2(A): ...
class C(B1, B2): ...
class Unrelated: ...
static_assert(is_subtype_of(B1, A))
static_assert(is_subtype_of(B2, A))
static_assert(is_subtype_of(C, A))
static_assert(is_subtype_of(C, B1))
static_assert(is_subtype_of(C, B2))
# For complements, the subtyping relation is reversed:
static_assert(is_subtype_of(Not[A], Not[B1]))
static_assert(is_subtype_of(Not[A], Not[B2]))
static_assert(is_subtype_of(Not[A], Not[C]))
static_assert(is_subtype_of(Not[B1], Not[C]))
static_assert(is_subtype_of(Not[B2], Not[C]))
# The intersection of two types is a subtype of both:
static_assert(is_subtype_of(Intersection[B1, B2], B1))
static_assert(is_subtype_of(Intersection[B1, B2], B2))
# … and of their common supertype:
static_assert(is_subtype_of(Intersection[B1, B2], A))
# A common subtype of two types is a subtype of their intersection:
static_assert(is_subtype_of(C, Intersection[B1, B2]))
# … but not the other way around:
static_assert(not is_subtype_of(Intersection[B1, B2], C))
# "Removing" B1 from A leaves a subtype of A.
static_assert(is_subtype_of(Intersection[A, Not[B1]], A))
static_assert(is_subtype_of(Intersection[A, Not[B1]], Not[B1]))
# B1 and B2 are not disjoint, so this is not true:
static_assert(not is_subtype_of(B2, Intersection[A, Not[B1]]))
# … but for two disjoint subtypes, it is:
static_assert(is_subtype_of(Literal[2], Intersection[int, Not[Literal[1]]]))
# A and Unrelated are not related, so this is not true:
static_assert(not is_subtype_of(Intersection[A, Not[B1]], Not[Unrelated]))
# … but for a disjoint type like `None`, it is:
static_assert(is_subtype_of(Intersection[A, Not[B1]], Not[None]))
# Complements of types are still subtypes of `object`:
static_assert(is_subtype_of(Not[A], object))
# More examples:
static_assert(is_subtype_of(type[str], Not[None]))
static_assert(is_subtype_of(Not[LiteralString], object))
static_assert(not is_subtype_of(Intersection[int, Not[Literal[2]]], Intersection[int, Not[Literal[3]]]))
static_assert(not is_subtype_of(Not[Literal[2]], Not[Literal[3]]))
static_assert(not is_subtype_of(Not[Literal[2]], Not[int]))
static_assert(not is_subtype_of(int, Not[Literal[3]]))
static_assert(not is_subtype_of(Literal[1], Intersection[int, Not[Literal[1]]]))
```
## Special types
### `Never`
`Never` is a subtype of all types.
```py
from typing_extensions import Literal, Never
from knot_extensions import AlwaysTruthy, AlwaysFalsy, is_subtype_of, static_assert
static_assert(is_subtype_of(Never, Never))
static_assert(is_subtype_of(Never, Literal[True]))
static_assert(is_subtype_of(Never, bool))
static_assert(is_subtype_of(Never, int))
static_assert(is_subtype_of(Never, object))
static_assert(is_subtype_of(Never, AlwaysTruthy))
static_assert(is_subtype_of(Never, AlwaysFalsy))
```
### `AlwaysTruthy` and `AlwaysFalsy`
```py
from knot_extensions import AlwaysTruthy, AlwaysFalsy, is_subtype_of, static_assert
static_assert(is_subtype_of(Literal[1], AlwaysTruthy))
static_assert(is_subtype_of(Literal[0], AlwaysFalsy))
static_assert(is_subtype_of(AlwaysTruthy, object))
static_assert(is_subtype_of(AlwaysFalsy, object))
static_assert(not is_subtype_of(Literal[1], AlwaysFalsy))
static_assert(not is_subtype_of(Literal[0], AlwaysTruthy))
static_assert(not is_subtype_of(str, AlwaysTruthy))
static_assert(not is_subtype_of(str, AlwaysFalsy))
```
### Module literals
```py
from types import ModuleType
from knot_extensions import TypeOf, is_subtype_of, static_assert
from typing_extensions import assert_type
import typing
assert_type(typing, TypeOf[typing])
static_assert(is_subtype_of(TypeOf[typing], ModuleType))
```
### Slice literals
```py
from knot_extensions import TypeOf, is_subtype_of, static_assert
static_assert(is_subtype_of(TypeOf[1:2:3], slice))
```
### Special forms
```py
from typing import _SpecialForm
from knot_extensions import TypeOf, is_subtype_of, static_assert
static_assert(is_subtype_of(TypeOf[Literal], _SpecialForm))
static_assert(is_subtype_of(TypeOf[Literal], object))
static_assert(not is_subtype_of(_SpecialForm, TypeOf[Literal]))
```
## Class literal types and `type[…]`
### Basic
```py
from typing import _SpecialForm
from typing_extensions import Literal, assert_type
from knot_extensions import TypeOf, is_subtype_of, static_assert
class Meta(type): ...
class HasCustomMetaclass(metaclass=Meta): ...
type LiteralBool = TypeOf[bool]
type LiteralInt = TypeOf[int]
type LiteralStr = TypeOf[str]
type LiteralObject = TypeOf[object]
assert_type(bool, LiteralBool)
assert_type(int, LiteralInt)
assert_type(str, LiteralStr)
assert_type(object, LiteralObject)
# bool
static_assert(is_subtype_of(LiteralBool, LiteralBool))
static_assert(is_subtype_of(LiteralBool, type[bool]))
static_assert(is_subtype_of(LiteralBool, type[int]))
static_assert(is_subtype_of(LiteralBool, type[object]))
static_assert(is_subtype_of(LiteralBool, type))
static_assert(is_subtype_of(LiteralBool, object))
static_assert(not is_subtype_of(LiteralBool, LiteralInt))
static_assert(not is_subtype_of(LiteralBool, LiteralObject))
static_assert(not is_subtype_of(LiteralBool, bool))
static_assert(not is_subtype_of(type, type[bool]))
# int
static_assert(is_subtype_of(LiteralInt, LiteralInt))
static_assert(is_subtype_of(LiteralInt, type[int]))
static_assert(is_subtype_of(LiteralInt, type[object]))
static_assert(is_subtype_of(LiteralInt, type))
static_assert(is_subtype_of(LiteralInt, object))
static_assert(not is_subtype_of(LiteralInt, LiteralObject))
static_assert(not is_subtype_of(LiteralInt, int))
static_assert(not is_subtype_of(type, type[int]))
# LiteralString
static_assert(is_subtype_of(LiteralStr, type[str]))
static_assert(is_subtype_of(LiteralStr, type))
static_assert(is_subtype_of(LiteralStr, type[object]))
static_assert(not is_subtype_of(type[str], LiteralStr))
# custom meta classes
type LiteralHasCustomMetaclass = TypeOf[HasCustomMetaclass]
static_assert(is_subtype_of(LiteralHasCustomMetaclass, Meta))
static_assert(is_subtype_of(Meta, type[object]))
static_assert(is_subtype_of(Meta, type))
static_assert(not is_subtype_of(Meta, type[type]))
```
### Unions of class literals
```py
from typing_extensions import assert_type
from knot_extensions import TypeOf, is_subtype_of, static_assert
class Base: ...
class Derived(Base): ...
class Unrelated: ...
type LiteralBase = TypeOf[Base]
type LiteralDerived = TypeOf[Derived]
type LiteralUnrelated = TypeOf[Unrelated]
assert_type(Base, LiteralBase)
assert_type(Derived, LiteralDerived)
assert_type(Unrelated, LiteralUnrelated)
static_assert(is_subtype_of(LiteralBase, type))
static_assert(is_subtype_of(LiteralBase, object))
static_assert(is_subtype_of(LiteralBase, type[Base]))
static_assert(is_subtype_of(LiteralDerived, type[Base]))
static_assert(is_subtype_of(LiteralDerived, type[Derived]))
static_assert(not is_subtype_of(LiteralBase, type[Derived]))
static_assert(is_subtype_of(type[Derived], type[Base]))
static_assert(is_subtype_of(LiteralBase | LiteralUnrelated, type))
static_assert(is_subtype_of(LiteralBase | LiteralUnrelated, object))
```
## Non-fully-static types
`Any`, `Unknown`, `Todo` and derivatives thereof do not participate in subtyping.
```py
from knot_extensions import Unknown, is_subtype_of, static_assert, Intersection
from typing_extensions import Any
static_assert(not is_subtype_of(Any, Any))
static_assert(not is_subtype_of(Any, int))
static_assert(not is_subtype_of(int, Any))
static_assert(not is_subtype_of(Any, object))
static_assert(not is_subtype_of(object, Any))
static_assert(not is_subtype_of(int, Any | int))
static_assert(not is_subtype_of(Intersection[Any, int], int))
static_assert(not is_subtype_of(tuple[int, int], tuple[int, Any]))
# The same for `Unknown`:
static_assert(not is_subtype_of(Unknown, Unknown))
static_assert(not is_subtype_of(Unknown, int))
static_assert(not is_subtype_of(int, Unknown))
static_assert(not is_subtype_of(Unknown, object))
static_assert(not is_subtype_of(object, Unknown))
static_assert(not is_subtype_of(int, Unknown | int))
static_assert(not is_subtype_of(Intersection[Unknown, int], int))
static_assert(not is_subtype_of(tuple[int, int], tuple[int, Unknown]))
```
## `bool` is a subtype of `AlwaysTruthy | AlwaysFalsy`
`bool` is equivalent to `Literal[True] | Literal[False]`. `Literal[True]` is a subtype of
`AlwaysTruthy` and `Literal[False]` is a subtype of `AlwaysFalsy`; it therefore stands to reason
that `bool` is a subtype of `AlwaysTruthy | AlwaysFalsy`.
```py
from knot_extensions import AlwaysTruthy, AlwaysFalsy, is_subtype_of, static_assert, Not, is_disjoint_from
from typing_extensions import Literal
static_assert(is_subtype_of(bool, AlwaysTruthy | AlwaysFalsy))
# the inverse also applies -- TODO: this should pass!
# See the TODO comments in the `Type::Intersection` branch of `Type::is_disjoint_from()`.
static_assert(is_disjoint_from(bool, Not[AlwaysTruthy | AlwaysFalsy])) # error: [static-assert-error]
# `Type::is_subtype_of` delegates many questions of `bool` subtyping to `int`,
# but set-theoretic types like intersections and unions are still handled differently to `int`
static_assert(is_subtype_of(Literal[True], Not[Literal[2]]))
static_assert(is_subtype_of(bool, Not[Literal[2]]))
static_assert(is_subtype_of(Literal[True], bool | None))
static_assert(is_subtype_of(bool, bool | None))
static_assert(not is_subtype_of(int, Not[Literal[2]]))
```
[special case for float and complex]: https://typing.readthedocs.io/en/latest/spec/special-types.html#special-cases-for-float-and-complex
[typing documentation]: https://typing.readthedocs.io/en/latest/spec/concepts.html#subtype-supertype-and-type-equivalence

View File

@@ -1,27 +0,0 @@
# `__str__` and `__repr__`
```py
from typing_extensions import Literal, LiteralString
def _(
a: Literal[1],
b: Literal[True],
c: Literal[False],
d: Literal["ab'cd"],
e: LiteralString,
f: int,
):
reveal_type(str(a)) # revealed: Literal["1"]
reveal_type(str(b)) # revealed: Literal["True"]
reveal_type(str(c)) # revealed: Literal["False"]
reveal_type(str(d)) # revealed: Literal["ab'cd"]
reveal_type(str(e)) # revealed: LiteralString
reveal_type(str(f)) # revealed: str
reveal_type(repr(a)) # revealed: Literal["1"]
reveal_type(repr(b)) # revealed: Literal["True"]
reveal_type(repr(c)) # revealed: Literal["False"]
reveal_type(repr(d)) # revealed: Literal["'ab\\'cd'"]
reveal_type(repr(e)) # revealed: LiteralString
reveal_type(repr(f)) # revealed: str
```

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