Compare commits
25 Commits
0.10.0
...
david/make
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac74a120b | ||
|
|
1ab975b142 | ||
|
|
92416e1f85 | ||
|
|
5ecea4e81f | ||
|
|
b3a0353bf2 | ||
|
|
9953dede9e | ||
|
|
fed67170ec | ||
|
|
cc5270ae9c | ||
|
|
c0fc2796a2 | ||
|
|
c5224316c0 | ||
|
|
67087c0417 | ||
|
|
72fe5525ab | ||
|
|
ff290172d7 | ||
|
|
7673b7265d | ||
|
|
caca1874ae | ||
|
|
08f4c60660 | ||
|
|
e86c21e90a | ||
|
|
c84f1e0c72 | ||
|
|
d6ae12c05f | ||
|
|
0743c21811 | ||
|
|
c322baaaef | ||
|
|
ce3dcb066c | ||
|
|
f406835639 | ||
|
|
30383d4855 | ||
|
|
c7d97c3cd5 |
31
.github/ISSUE_TEMPLATE/1_bug_report.yaml
vendored
31
.github/ISSUE_TEMPLATE/1_bug_report.yaml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report an error or unexpected behavior
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to report an issue! We're glad to have you involved with Ruff.
|
||||
|
||||
**Before reporting, please make sure to search through [existing issues](https://github.com/astral-sh/ruff/issues?q=is:issue+is:open+label:bug) (including [closed](https://github.com/astral-sh/ruff/issues?q=is:issue%20state:closed%20label:bug)).**
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Summary
|
||||
description: |
|
||||
A clear and concise description of the bug, including a minimal reproducible example.
|
||||
|
||||
Be sure to include the command you invoked (e.g., `ruff check /path/to/file.py --fix`), ideally including the `--isolated` flag and
|
||||
the current Ruff settings (e.g., relevant sections from your `pyproject.toml`).
|
||||
|
||||
If possible, try to include the [playground](https://play.ruff.rs) link that reproduces this issue.
|
||||
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of ruff are you using? (see `ruff version`)
|
||||
placeholder: e.g., ruff 0.9.3 (90589372d 2025-01-23)
|
||||
validations:
|
||||
required: false
|
||||
10
.github/ISSUE_TEMPLATE/2_rule_request.yaml
vendored
10
.github/ISSUE_TEMPLATE/2_rule_request.yaml
vendored
@@ -1,10 +0,0 @@
|
||||
name: Rule request
|
||||
description: Anything related to lint rules (proposing new rules, changes to existing rules, auto-fixes, etc.)
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Summary
|
||||
description: |
|
||||
A clear and concise description of the relevant request. If applicable, please describe the current behavior as well.
|
||||
validations:
|
||||
required: true
|
||||
18
.github/ISSUE_TEMPLATE/3_question.yaml
vendored
18
.github/ISSUE_TEMPLATE/3_question.yaml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Question
|
||||
description: Ask a question about Ruff
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Question
|
||||
description: Describe your question in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of ruff are you using? (see `ruff version`)
|
||||
placeholder: e.g., ruff 0.9.3 (90589372d 2025-01-23)
|
||||
validations:
|
||||
required: false
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,2 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://docs.astral.sh/ruff
|
||||
about: Please consult the documentation before creating an issue.
|
||||
- name: Community
|
||||
url: https://discord.com/invite/astral-sh
|
||||
about: Join our Discord community to ask questions and collaborate.
|
||||
# This file cannot use the extension `.yaml`.
|
||||
blank_issues_enabled: false
|
||||
|
||||
22
.github/ISSUE_TEMPLATE/issue.yaml
vendored
Normal file
22
.github/ISSUE_TEMPLATE/issue.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: New issue
|
||||
description: A generic issue
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to report an issue! We're glad to have you involved with Ruff.
|
||||
|
||||
If you're filing a bug report, please consider including the following information:
|
||||
|
||||
* List of keywords you searched for before creating this issue. Write them down here so that others can find this issue more easily and help provide feedback.
|
||||
e.g. "RUF001", "unused variable", "Jupyter notebook"
|
||||
* A minimal code snippet that reproduces the bug.
|
||||
* The command you invoked (e.g., `ruff /path/to/file.py --fix`), ideally including the `--isolated` flag.
|
||||
* The current Ruff settings (any relevant sections from your `pyproject.toml`).
|
||||
* The current Ruff version (`ruff --version`).
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A description of the issue
|
||||
15
.github/renovate.json5
vendored
15
.github/renovate.json5
vendored
@@ -58,6 +58,12 @@
|
||||
description: "Disable PRs updating GitHub runners (e.g. 'runs-on: macos-14')",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
// TODO: Remove this once the codebase is upgrade to v4 (https://github.com/astral-sh/ruff/pull/16069)
|
||||
matchPackageNames: ["tailwindcss"],
|
||||
matchManagers: ["npm"],
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
// Disable updates of `zip-rs`; intentionally pinned for now due to ownership change
|
||||
// See: https://github.com/astral-sh/uv/issues/3642
|
||||
@@ -95,7 +101,14 @@
|
||||
matchManagers: ["cargo"],
|
||||
matchPackageNames: ["strum"],
|
||||
description: "Weekly update of strum dependencies",
|
||||
}
|
||||
},
|
||||
{
|
||||
groupName: "ESLint",
|
||||
matchManagers: ["npm"],
|
||||
matchPackageNames: ["eslint"],
|
||||
allowedVersions: "<9",
|
||||
description: "Constraint ESLint to version 8 until TypeScript-eslint supports ESLint 9", // https://github.com/typescript-eslint/typescript-eslint/issues/8211
|
||||
},
|
||||
],
|
||||
vulnerabilityAlerts: {
|
||||
commitMessageSuffix: "",
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -163,7 +163,7 @@ jobs:
|
||||
# Mapping of base image followed by a comma followed by one or more base tags (comma separated)
|
||||
# Note, org.opencontainers.image.version label will use the first base tag (use the most specific tag first)
|
||||
image-mapping:
|
||||
- alpine:3.21,alpine3.21,alpine
|
||||
- alpine:3.20,alpine3.20,alpine
|
||||
- debian:bookworm-slim,bookworm-slim,debian-slim
|
||||
- buildpack-deps:bookworm,bookworm,debian
|
||||
steps:
|
||||
|
||||
1
.github/workflows/daily_property_tests.yaml
vendored
1
.github/workflows/daily_property_tests.yaml
vendored
@@ -47,7 +47,6 @@ jobs:
|
||||
run: |
|
||||
export QUICKCHECK_TESTS=100000
|
||||
for _ in {1..5}; do
|
||||
cargo test --locked --release --package red_knot_python_semantic -- --ignored list::property_tests
|
||||
cargo test --locked --release --package red_knot_python_semantic -- --ignored types::property_tests::stable
|
||||
done
|
||||
|
||||
|
||||
93
.github/workflows/mypy_primer.yaml
vendored
93
.github/workflows/mypy_primer.yaml
vendored
@@ -1,93 +0,0 @@
|
||||
name: Run mypy_primer
|
||||
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/red_knot*/**"
|
||||
- "crates/ruff_db"
|
||||
- "crates/ruff_python_ast"
|
||||
- "crates/ruff_python_parser"
|
||||
- ".github/workflows/mypy_primer.yaml"
|
||||
- ".github/workflows/mypy_primer_comment.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_NET_RETRY: 10
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTUP_MAX_RETRIES: 10
|
||||
|
||||
jobs:
|
||||
mypy_primer:
|
||||
name: Run mypy_primer
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: ruff
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "ruff"
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Install mypy_primer
|
||||
run: |
|
||||
uv tool install "git+https://github.com/astral-sh/mypy_primer.git@add-red-knot-support"
|
||||
|
||||
- name: Run mypy_primer
|
||||
shell: bash
|
||||
run: |
|
||||
cd ruff
|
||||
|
||||
echo "new commit"
|
||||
git rev-list --format=%s --max-count=1 "$GITHUB_SHA"
|
||||
|
||||
MERGE_BASE="$(git merge-base "$GITHUB_SHA" "origin/$GITHUB_BASE_REF")"
|
||||
git checkout -b base_commit "$MERGE_BASE"
|
||||
echo "base commit"
|
||||
git rev-list --format=%s --max-count=1 base_commit
|
||||
|
||||
cd ..
|
||||
|
||||
# Allow the exit code to be 0 or 1, only fail for actual mypy_primer crashes/bugs
|
||||
uvx mypy_primer \
|
||||
--repo ruff \
|
||||
--type-checker knot \
|
||||
--old base_commit \
|
||||
--new "$GITHUB_SHA" \
|
||||
--project-selector '/(mypy_primer|black|pyp|git-revise|zipp|arrow)$' \
|
||||
--output concise \
|
||||
--debug > mypy_primer.diff || [ $? -eq 1 ]
|
||||
|
||||
# Output diff with ANSI color codes
|
||||
cat mypy_primer.diff
|
||||
|
||||
# Remove ANSI color codes before uploading
|
||||
sed -ie 's/\x1b\[[0-9;]*m//g' mypy_primer.diff
|
||||
|
||||
echo ${{ github.event.number }} > pr-number
|
||||
|
||||
- name: Upload diff
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mypy_primer_diff
|
||||
path: mypy_primer.diff
|
||||
|
||||
- name: Upload pr-number
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-number
|
||||
path: pr-number
|
||||
97
.github/workflows/mypy_primer_comment.yaml
vendored
97
.github/workflows/mypy_primer_comment.yaml
vendored
@@ -1,97 +0,0 @@
|
||||
name: PR comment (mypy_primer)
|
||||
|
||||
on: # zizmor: ignore[dangerous-triggers]
|
||||
workflow_run:
|
||||
workflows: [Run mypy_primer]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
workflow_run_id:
|
||||
description: The mypy_primer workflow that triggers the workflow run
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: dawidd6/action-download-artifact@v8
|
||||
name: Download PR number
|
||||
with:
|
||||
name: pr-number
|
||||
run_id: ${{ github.event.workflow_run.id || github.event.inputs.workflow_run_id }}
|
||||
if_no_artifact_found: ignore
|
||||
allow_forks: true
|
||||
|
||||
- name: Parse pull request number
|
||||
id: pr-number
|
||||
run: |
|
||||
if [[ -f pr-number ]]
|
||||
then
|
||||
echo "pr-number=$(<pr-number)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: dawidd6/action-download-artifact@v8
|
||||
name: "Download mypy_primer results"
|
||||
id: download-mypy_primer_diff
|
||||
if: steps.pr-number.outputs.pr-number
|
||||
with:
|
||||
name: mypy_primer_diff
|
||||
workflow: mypy_primer.yaml
|
||||
pr: ${{ steps.pr-number.outputs.pr-number }}
|
||||
path: pr/mypy_primer_diff
|
||||
workflow_conclusion: completed
|
||||
if_no_artifact_found: ignore
|
||||
allow_forks: true
|
||||
|
||||
- name: Generate comment content
|
||||
id: generate-comment
|
||||
if: steps.download-mypy_primer_diff.outputs.found_artifact == 'true'
|
||||
run: |
|
||||
# Guard against malicious mypy_primer results that symlink to a secret
|
||||
# file on this runner
|
||||
if [[ -L pr/mypy_primer_diff/mypy_primer.diff ]]
|
||||
then
|
||||
echo "Error: mypy_primer.diff cannot be a symlink"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Note this identifier is used to find the comment to update on
|
||||
# subsequent runs
|
||||
echo '<!-- generated-comment mypy_primer -->' >> comment.txt
|
||||
|
||||
echo '## `mypy_primer` results' >> comment.txt
|
||||
if [ -s "pr/mypy_primer_diff/mypy_primer.diff" ]; then
|
||||
echo '<details>' >> comment.txt
|
||||
echo '<summary>Changes were detected when running on open source projects</summary>' >> comment.txt
|
||||
echo '' >> comment.txt
|
||||
echo '```diff' >> comment.txt
|
||||
cat pr/mypy_primer_diff/mypy_primer.diff >> comment.txt
|
||||
echo '```' >> comment.txt
|
||||
echo '</details>' >> comment.txt
|
||||
else
|
||||
echo 'No ecosystem changes detected ✅' >> comment.txt
|
||||
fi
|
||||
|
||||
echo 'comment<<EOF' >> "$GITHUB_OUTPUT"
|
||||
cat comment.txt >> "$GITHUB_OUTPUT"
|
||||
echo 'EOF' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find existing comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
if: steps.generate-comment.outcome == 'success'
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ steps.pr-number.outputs.pr-number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- generated-comment mypy_primer -->"
|
||||
|
||||
- name: Create or update comment
|
||||
if: steps.find-comment.outcome == 'success'
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ steps.pr-number.outputs.pr-number }}
|
||||
body-path: comment.txt
|
||||
edit-mode: replace
|
||||
@@ -60,7 +60,7 @@ repos:
|
||||
- black==25.1.0
|
||||
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: v1.30.0
|
||||
rev: v1.29.7
|
||||
hooks:
|
||||
- id: typos
|
||||
|
||||
@@ -74,7 +74,7 @@ repos:
|
||||
pass_filenames: false # This makes it a lot faster
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
rev: v0.9.6
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
- id: ruff
|
||||
@@ -84,7 +84,7 @@ repos:
|
||||
|
||||
# Prettier
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.5.2
|
||||
rev: v3.5.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
types: [yaml]
|
||||
@@ -92,12 +92,12 @@ repos:
|
||||
# zizmor detects security vulnerabilities in GitHub Actions workflows.
|
||||
# Additional configuration for the tool is found in `.github/zizmor.yml`
|
||||
- repo: https://github.com/woodruffw/zizmor-pre-commit
|
||||
rev: v1.4.1
|
||||
rev: v1.3.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||
rev: 0.31.2
|
||||
rev: 0.31.1
|
||||
hooks:
|
||||
- id: check-github-workflows
|
||||
|
||||
|
||||
@@ -1,46 +1,5 @@
|
||||
# Breaking Changes
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- **Changes to how the Python version is inferred when a `target-version` is not specified** ([#16319](https://github.com/astral-sh/ruff/pull/16319))
|
||||
|
||||
In previous versions of Ruff, you could specify your Python version with:
|
||||
|
||||
- The `target-version` option in a `ruff.toml` file or the `[tool.ruff]` section of a pyproject.toml file.
|
||||
- The `project.requires-python` field in a `pyproject.toml` file with a `[tool.ruff]` section.
|
||||
|
||||
These options worked well in most cases, and are still recommended for fine control of the Python version. However, because of the way Ruff discovers config files, `pyproject.toml` files without a `[tool.ruff]` section would be ignored, including the `requires-python` setting. Ruff would then use the default Python version (3.9 as of this writing) instead, which is surprising when you've attempted to request another version.
|
||||
|
||||
In v0.10, config discovery has been updated to address this issue:
|
||||
|
||||
- If Ruff finds a `ruff.toml` file without a `target-version`, it will check
|
||||
for a `pyproject.toml` file in the same directory and respect its
|
||||
`requires-python` version, even if it does not contain a `[tool.ruff]`
|
||||
section.
|
||||
- If Ruff finds a user-level configuration, the `requires-python` field of the closest `pyproject.toml` in a parent directory will take precedence.
|
||||
- If there is no config file (`ruff.toml`or `pyproject.toml` with a
|
||||
`[tool.ruff]` section) in the directory of the file being checked, Ruff will
|
||||
search for the closest `pyproject.toml` in the parent directories and use its
|
||||
`requires-python` setting.
|
||||
|
||||
- **Updated `TYPE_CHECKING` behavior** ([#16669](https://github.com/astral-sh/ruff/pull/16669))
|
||||
|
||||
Previously, Ruff only recognized typechecking blocks that tested the `typing.TYPE_CHECKING` symbol. Now, Ruff recognizes any local variable named `TYPE_CHECKING`. This release also removes support for the legacy `if 0:` and `if False:` typechecking checks. Use a local `TYPE_CHECKING` variable instead.
|
||||
|
||||
- **More robust noqa parsing** ([#16483](https://github.com/astral-sh/ruff/pull/16483))
|
||||
|
||||
The syntax for both file-level and in-line suppression comments has been unified and made more robust to certain errors. In most cases, this will result in more suppression comments being read by Ruff, but there are a few instances where previously read comments will now log an error to the user instead. Please refer to the documentation on [_Error suppression_](https://docs.astral.sh/ruff/linter/#error-suppression) for the full specification.
|
||||
|
||||
- **Avoid unnecessary parentheses around with statements with a single context manager and a trailing comment** ([#14005](https://github.com/astral-sh/ruff/pull/14005))
|
||||
|
||||
This change fixes a bug in the formatter where it introduced unnecessary parentheses around with statements with a single context manager and a trailing comment. This change may result in a change in formatting for some users.
|
||||
|
||||
- **Bump alpine default tag to 3.21 for derived Docker images** ([#16456](https://github.com/astral-sh/ruff/pull/16456))
|
||||
|
||||
Alpine 3.21 was released in Dec 2024 and is used in the official Alpine-based Python images. Now the ruff:alpine image will use 3.21 instead of 3.20 and ruff:alpine3.20 will no longer be updated.
|
||||
|
||||
- **\[`unsafe-markup-use`\]: `RUF035` has been recoded to `S704`** ([#15957](https://github.com/astral-sh/ruff/pull/15957))
|
||||
|
||||
## 0.9.0
|
||||
|
||||
Ruff now formats your code according to the 2025 style guide. As a result, your code might now get formatted differently. See the [changelog](./CHANGELOG.md#090) for a detailed list of changes.
|
||||
|
||||
219
CHANGELOG.md
219
CHANGELOG.md
@@ -1,200 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.0
|
||||
|
||||
Check out the [blog post](https://astral.sh/blog/ruff-v0.10.0) for a migration guide and overview of the changes!
|
||||
|
||||
### Breaking changes
|
||||
|
||||
See also, the "Remapped rules" section which may result in disabled rules.
|
||||
|
||||
- **Changes to how the Python version is inferred when a `target-version` is not specified** ([#16319](https://github.com/astral-sh/ruff/pull/16319))
|
||||
|
||||
In previous versions of Ruff, you could specify your Python version with:
|
||||
|
||||
- The `target-version` option in a `ruff.toml` file or the `[tool.ruff]` section of a pyproject.toml file.
|
||||
- The `project.requires-python` field in a `pyproject.toml` file with a `[tool.ruff]` section.
|
||||
|
||||
These options worked well in most cases, and are still recommended for fine control of the Python version. However, because of the way Ruff discovers config files, `pyproject.toml` files without a `[tool.ruff]` section would be ignored, including the `requires-python` setting. Ruff would then use the default Python version (3.9 as of this writing) instead, which is surprising when you've attempted to request another version.
|
||||
|
||||
In v0.10, config discovery has been updated to address this issue:
|
||||
|
||||
- If Ruff finds a `ruff.toml` file without a `target-version`, it will check
|
||||
for a `pyproject.toml` file in the same directory and respect its
|
||||
`requires-python` version, even if it does not contain a `[tool.ruff]`
|
||||
section.
|
||||
- If Ruff finds a user-level configuration, the `requires-python` field of the closest `pyproject.toml` in a parent directory will take precedence.
|
||||
- If there is no config file (`ruff.toml`or `pyproject.toml` with a
|
||||
`[tool.ruff]` section) in the directory of the file being checked, Ruff will
|
||||
search for the closest `pyproject.toml` in the parent directories and use its
|
||||
`requires-python` setting.
|
||||
|
||||
- **Updated `TYPE_CHECKING` behavior** ([#16669](https://github.com/astral-sh/ruff/pull/16669))
|
||||
|
||||
Previously, Ruff only recognized typechecking blocks that tested the `typing.TYPE_CHECKING` symbol. Now, Ruff recognizes any local variable named `TYPE_CHECKING`. This release also removes support for the legacy `if 0:` and `if False:` typechecking checks. Use a local `TYPE_CHECKING` variable instead.
|
||||
|
||||
- **More robust noqa parsing** ([#16483](https://github.com/astral-sh/ruff/pull/16483))
|
||||
|
||||
The syntax for both file-level and in-line suppression comments has been unified and made more robust to certain errors. In most cases, this will result in more suppression comments being read by Ruff, but there are a few instances where previously read comments will now log an error to the user instead. Please refer to the documentation on [_Error suppression_](https://docs.astral.sh/ruff/linter/#error-suppression) for the full specification.
|
||||
|
||||
- **Avoid unnecessary parentheses around with statements with a single context manager and a trailing comment** ([#14005](https://github.com/astral-sh/ruff/pull/14005))
|
||||
|
||||
This change fixes a bug in the formatter where it introduced unnecessary parentheses around with statements with a single context manager and a trailing comment. This change may result in a change in formatting for some users.
|
||||
|
||||
- **Bump alpine default tag to 3.21 for derived Docker images** ([#16456](https://github.com/astral-sh/ruff/pull/16456))
|
||||
|
||||
Alpine 3.21 was released in Dec 2024 and is used in the official Alpine-based Python images. Now the ruff:alpine image will use 3.21 instead of 3.20 and ruff:alpine3.20 will no longer be updated.
|
||||
|
||||
### Deprecated Rules
|
||||
|
||||
The following rules have been deprecated:
|
||||
|
||||
- [`non-pep604-isinstance`](https://docs.astral.sh/ruff/rules/non-pep604-isinstance/) (`UP038`)
|
||||
- [`suspicious-xmle-tree-usage`](https://docs.astral.sh/ruff/rules/suspicious-xmle-tree-usage/) (`S320`)
|
||||
|
||||
### Remapped rules
|
||||
|
||||
The following rules have been remapped to new rule codes:
|
||||
|
||||
- \[`unsafe-markup-use`\]: `RUF035` to `S704`
|
||||
|
||||
### Stabilization
|
||||
|
||||
The following rules have been stabilized and are no longer in preview:
|
||||
|
||||
- [`batched-without-explicit-strict`](https://docs.astral.sh/ruff/rules/batched-without-explicit-strict) (`B911`)
|
||||
- [`unnecessary-dict-comprehension-for-iterable`](https://docs.astral.sh/ruff/rules/unnecessary-dict-comprehension-for-iterable) (`C420`)
|
||||
- [`datetime-min-max`](https://docs.astral.sh/ruff/rules/datetime-min-max) (`DTZ901`)
|
||||
- [`fast-api-unused-path-parameter`](https://docs.astral.sh/ruff/rules/fast-api-unused-path-parameter) (`FAST003`)
|
||||
- [`root-logger-call`](https://docs.astral.sh/ruff/rules/root-logger-call) (`LOG015`)
|
||||
- [`len-test`](https://docs.astral.sh/ruff/rules/len-test) (`PLC1802`)
|
||||
- [`shallow-copy-environ`](https://docs.astral.sh/ruff/rules/shallow-copy-environ) (`PLW1507`)
|
||||
- [`os-listdir`](https://docs.astral.sh/ruff/rules/os-listdir) (`PTH208`)
|
||||
- [`invalid-pathlib-with-suffix`](https://docs.astral.sh/ruff/rules/invalid-pathlib-with-suffix) (`PTH210`)
|
||||
- [`invalid-assert-message-literal-argument`](https://docs.astral.sh/ruff/rules/invalid-assert-message-literal-argument) (`RUF040`)
|
||||
- [`unnecessary-nested-literal`](https://docs.astral.sh/ruff/rules/unnecessary-nested-literal) (`RUF041`)
|
||||
- [`unnecessary-cast-to-int`](https://docs.astral.sh/ruff/rules/unnecessary-cast-to-int) (`RUF046`)
|
||||
- [`map-int-version-parsing`](https://docs.astral.sh/ruff/rules/map-int-version-parsing) (`RUF048`)
|
||||
- [`if-key-in-dict-del`](https://docs.astral.sh/ruff/rules/if-key-in-dict-del) (`RUF051`)
|
||||
- [`unsafe-markup-use`](https://docs.astral.sh/ruff/rules/unsafe-markup-use) (`S704`). This rule has also been renamed from `RUF035`.
|
||||
- [`split-static-string`](https://docs.astral.sh/ruff/rules/split-static-string) (`SIM905`)
|
||||
- [`runtime-cast-value`](https://docs.astral.sh/ruff/rules/runtime-cast-value) (`TC006`)
|
||||
- [`unquoted-type-alias`](https://docs.astral.sh/ruff/rules/unquoted-type-alias) (`TC007`)
|
||||
- [`non-pep646-unpack`](https://docs.astral.sh/ruff/rules/non-pep646-unpack) (`UP044`)
|
||||
|
||||
The following behaviors have been stabilized:
|
||||
|
||||
- [`bad-staticmethod-argument`](https://docs.astral.sh/ruff/rules/bad-staticmethod-argument/) (`PLW0211`) [`invalid-first-argument-name-for-class-method`](https://docs.astral.sh/ruff/rules/invalid-first-argument-name-for-class-method/) (`N804`): `__new__` methods are now no longer flagged by `invalid-first-argument-name-for-class-method` (`N804`) but instead by `bad-staticmethod-argument` (`PLW0211`)
|
||||
- [`bad-str-strip-call`](https://docs.astral.sh/ruff/rules/bad-str-strip-call/) (`PLE1310`): The rule now applies to objects which are known to have type `str` or `bytes`.
|
||||
- [`blanket-noqa`](https://docs.astral.sh/ruff/rules/blanket-noqa/) (`PGH004`): Also detect blanked file-level noqa comments (and not just line level comments).
|
||||
- [`custom-type-var-for-self`](https://docs.astral.sh/ruff/rules/custom-type-var-for-self/) (`PYI019`): More accurate detection of custom `TypeVars` replaceable by `Self`. The range of the diagnostic is now the full function header rather than just the return annotation.
|
||||
- [`invalid-argument-name`](https://docs.astral.sh/ruff/rules/invalid-argument-name/) (`N803`): Ignore argument names of functions decorated with `typing.override`
|
||||
- [`invalid-envvar-default`](https://docs.astral.sh/ruff/rules/invalid-envvar-default/) (`PLW1508`): Detect default value arguments to `os.environ.get` with invalid type.
|
||||
- [`pytest-raises-with-multiple-statements`](https://docs.astral.sh/ruff/rules/pytest-raises-with-multiple-statements/) (`PT012`) [`pytest-warns-with-multiple-statements`](https://docs.astral.sh/ruff/rules/pytest-warns-with-multiple-statements/) (`PT031`): Allow `for` statements with an empty body in `pytest.raises` and `pytest.warns` `with` statements.
|
||||
- [`redundant-open-modes`](https://docs.astral.sh/ruff/rules/redundant-open-modes/) (`UP015`): The diagnostic range is now the range of the redundant mode argument where it previously was the range of the entire open call. You may have to replace your `noqa` comments when suppressing `UP015`.
|
||||
- [`stdlib-module-shadowing`](https://docs.astral.sh/ruff/rules/stdlib-module-shadowing/) (`A005`): Changes the default value of `lint.flake8-builtins.strict-checking` from `true` to `false`.
|
||||
- [`type-none-comparison`](https://docs.astral.sh/ruff/rules/type-none-comparison/) (`FURB169`): Now also recognizes `type(expr) is type(None)` comparisons where `expr` isn't a name expression.
|
||||
|
||||
The following fixes or improvements to fixes have been stabilized:
|
||||
|
||||
- [`repeated-equality-comparison`](https://docs.astral.sh/ruff/rules/repeated-equality-comparison/) (`PLR1714`) ([#16685](https://github.com/astral-sh/ruff/pull/16685))
|
||||
- [`needless-bool`](https://docs.astral.sh/ruff/rules/needless-bool/) (`SIM103`) ([#16684](https://github.com/astral-sh/ruff/pull/16684))
|
||||
- [`unused-private-type-var`](https://docs.astral.sh/ruff/rules/unused-private-type-var/) (`PYI018`) ([#16682](https://github.com/astral-sh/ruff/pull/16682))
|
||||
|
||||
### Server
|
||||
|
||||
- Remove logging output for `ruff.printDebugInformation` ([#16617](https://github.com/astral-sh/ruff/pull/16617))
|
||||
|
||||
### Configuration
|
||||
|
||||
- \[`flake8-builtins`\] Deprecate the `builtins-` prefixed options in favor of the unprefixed options (e.g. `builtins-allowed-modules` is now deprecated in favor of `allowed-modules`) ([#16092](https://github.com/astral-sh/ruff/pull/16092))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [flake8-bandit] Fix mixed-case hash algorithm names (S324) ([#16552](https://github.com/astral-sh/ruff/pull/16552))
|
||||
|
||||
### CLI
|
||||
|
||||
- [ruff] Fix `last_tag`/`commits_since_last_tag` for `version` command ([#16686](https://github.com/astral-sh/ruff/pull/16686))
|
||||
|
||||
## 0.9.10
|
||||
|
||||
### Preview features
|
||||
|
||||
- \[`ruff`\] Add new rule `RUF059`: Unused unpacked assignment ([#16449](https://github.com/astral-sh/ruff/pull/16449))
|
||||
- \[`syntax-errors`\] Detect assignment expressions before Python 3.8 ([#16383](https://github.com/astral-sh/ruff/pull/16383))
|
||||
- \[`syntax-errors`\] Named expressions in decorators before Python 3.9 ([#16386](https://github.com/astral-sh/ruff/pull/16386))
|
||||
- \[`syntax-errors`\] Parenthesized keyword argument names after Python 3.8 ([#16482](https://github.com/astral-sh/ruff/pull/16482))
|
||||
- \[`syntax-errors`\] Positional-only parameters before Python 3.8 ([#16481](https://github.com/astral-sh/ruff/pull/16481))
|
||||
- \[`syntax-errors`\] Tuple unpacking in `return` and `yield` before Python 3.8 ([#16485](https://github.com/astral-sh/ruff/pull/16485))
|
||||
- \[`syntax-errors`\] Type parameter defaults before Python 3.13 ([#16447](https://github.com/astral-sh/ruff/pull/16447))
|
||||
- \[`syntax-errors`\] Type parameter lists before Python 3.12 ([#16479](https://github.com/astral-sh/ruff/pull/16479))
|
||||
- \[`syntax-errors`\] `except*` before Python 3.11 ([#16446](https://github.com/astral-sh/ruff/pull/16446))
|
||||
- \[`syntax-errors`\] `type` statements before Python 3.12 ([#16478](https://github.com/astral-sh/ruff/pull/16478))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Escape template filenames in glob patterns in configuration ([#16407](https://github.com/astral-sh/ruff/pull/16407))
|
||||
- \[`flake8-simplify`\] Exempt unittest context methods for `SIM115` rule ([#16439](https://github.com/astral-sh/ruff/pull/16439))
|
||||
- Formatter: Fix syntax error location in notebooks ([#16499](https://github.com/astral-sh/ruff/pull/16499))
|
||||
- \[`pyupgrade`\] Do not offer fix when at least one target is `global`/`nonlocal` (`UP028`) ([#16451](https://github.com/astral-sh/ruff/pull/16451))
|
||||
- \[`flake8-builtins`\] Ignore variables matching module attribute names (`A001`) ([#16454](https://github.com/astral-sh/ruff/pull/16454))
|
||||
- \[`pylint`\] Convert `code` keyword argument to a positional argument in fix for (`PLR1722`) ([#16424](https://github.com/astral-sh/ruff/pull/16424))
|
||||
|
||||
### CLI
|
||||
|
||||
- Move rule code from `description` to `check_name` in GitLab output serializer ([#16437](https://github.com/astral-sh/ruff/pull/16437))
|
||||
|
||||
### Documentation
|
||||
|
||||
- \[`pydocstyle`\] Clarify that `D417` only checks docstrings with an arguments section ([#16494](https://github.com/astral-sh/ruff/pull/16494))
|
||||
|
||||
## 0.9.9
|
||||
|
||||
### Preview features
|
||||
|
||||
- Fix caching of unsupported-syntax errors ([#16425](https://github.com/astral-sh/ruff/pull/16425))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Only show unsupported-syntax errors in editors when preview mode is enabled ([#16429](https://github.com/astral-sh/ruff/pull/16429))
|
||||
|
||||
## 0.9.8
|
||||
|
||||
### Preview features
|
||||
|
||||
- Start detecting version-related syntax errors in the parser ([#16090](https://github.com/astral-sh/ruff/pull/16090))
|
||||
|
||||
### Rule changes
|
||||
|
||||
- \[`pylint`\] Mark fix unsafe (`PLW1507`) ([#16343](https://github.com/astral-sh/ruff/pull/16343))
|
||||
- \[`pylint`\] Catch `case np.nan`/`case math.nan` in `match` statements (`PLW0177`) ([#16378](https://github.com/astral-sh/ruff/pull/16378))
|
||||
- \[`ruff`\] Add more Pydantic models variants to the list of default copy semantics (`RUF012`) ([#16291](https://github.com/astral-sh/ruff/pull/16291))
|
||||
|
||||
### Server
|
||||
|
||||
- Avoid indexing the project if `configurationPreference` is `editorOnly` ([#16381](https://github.com/astral-sh/ruff/pull/16381))
|
||||
- Avoid unnecessary info at non-trace server log level ([#16389](https://github.com/astral-sh/ruff/pull/16389))
|
||||
- Expand `ruff.configuration` to allow inline config ([#16296](https://github.com/astral-sh/ruff/pull/16296))
|
||||
- Notify users for invalid client settings ([#16361](https://github.com/astral-sh/ruff/pull/16361))
|
||||
|
||||
### Configuration
|
||||
|
||||
- Add `per-file-target-version` option ([#16257](https://github.com/astral-sh/ruff/pull/16257))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- \[`refurb`\] Do not consider docstring(s) (`FURB156`) ([#16391](https://github.com/astral-sh/ruff/pull/16391))
|
||||
- \[`flake8-self`\] Ignore attribute accesses on instance-like variables (`SLF001`) ([#16149](https://github.com/astral-sh/ruff/pull/16149))
|
||||
- \[`pylint`\] Fix false positives, add missing methods, and support positional-only parameters (`PLE0302`) ([#16263](https://github.com/astral-sh/ruff/pull/16263))
|
||||
- \[`flake8-pyi`\] Mark `PYI030` fix unsafe when comments are deleted ([#16322](https://github.com/astral-sh/ruff/pull/16322))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix example for `S611` ([#16316](https://github.com/astral-sh/ruff/pull/16316))
|
||||
- Normalize inconsistent markdown headings in docstrings ([#16364](https://github.com/astral-sh/ruff/pull/16364))
|
||||
- Document MSRV policy ([#16384](https://github.com/astral-sh/ruff/pull/16384))
|
||||
|
||||
## 0.9.7
|
||||
|
||||
### Preview features
|
||||
@@ -208,7 +13,16 @@ The following fixes or improvements to fixes have been stabilized:
|
||||
|
||||
### Rule changes
|
||||
|
||||
- \[`flake8-comprehensions`\]: Handle trailing comma in `C403` fix ([#16110](https://github.com/astral-sh/ruff/pull/16110))
|
||||
- \[`flake8-debugger`\] Also flag `sys.breakpointhook` and `sys.__breakpointhook__` (`T100`) ([#16191](https://github.com/astral-sh/ruff/pull/16191))
|
||||
- \[`pydocstyle`\] Handle arguments with the same names as sections (`D417`) ([#16011](https://github.com/astral-sh/ruff/pull/16011))
|
||||
- \[`pylint`\] Correct ordering of arguments in fix for `if-stmt-min-max` (`PLR1730`) ([#16080](https://github.com/astral-sh/ruff/pull/16080))
|
||||
- \[`pylint`\] Do not offer fix for raw strings (`PLE251`) ([#16132](https://github.com/astral-sh/ruff/pull/16132))
|
||||
- \[`pyupgrade`\] Do not upgrade functional `TypedDicts` with private field names to the class-based syntax (`UP013`) ([#16219](https://github.com/astral-sh/ruff/pull/16219))
|
||||
- \[`pyupgrade`\] Handle micro version numbers correctly (`UP036`) ([#16091](https://github.com/astral-sh/ruff/pull/16091))
|
||||
- \[`pyupgrade`\] Unwrap unary expressions correctly (`UP018`) ([#15919](https://github.com/astral-sh/ruff/pull/15919))
|
||||
- \[`ruff`\] Skip `RUF001` diagnostics when visiting string type definitions ([#16122](https://github.com/astral-sh/ruff/pull/16122))
|
||||
- \[`flake8-pyi`\] Avoid flagging `custom-typevar-for-self` on metaclass methods (`PYI019`) ([#16141](https://github.com/astral-sh/ruff/pull/16141))
|
||||
- \[`pycodestyle`\] Exempt `site.addsitedir(...)` calls (`E402`) ([#16251](https://github.com/astral-sh/ruff/pull/16251))
|
||||
|
||||
### Formatter
|
||||
@@ -229,16 +43,7 @@ The following fixes or improvements to fixes have been stabilized:
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- \[`flake8-comprehensions`\] Handle trailing comma in `C403` fix ([#16110](https://github.com/astral-sh/ruff/pull/16110))
|
||||
- \[`flake8-pyi`\] Avoid flagging `custom-typevar-for-self` on metaclass methods (`PYI019`) ([#16141](https://github.com/astral-sh/ruff/pull/16141))
|
||||
- \[`pydocstyle`\] Handle arguments with the same names as sections (`D417`) ([#16011](https://github.com/astral-sh/ruff/pull/16011))
|
||||
- \[`pylint`\] Correct ordering of arguments in fix for `if-stmt-min-max` (`PLR1730`) ([#16080](https://github.com/astral-sh/ruff/pull/16080))
|
||||
- \[`pylint`\] Do not offer fix for raw strings (`PLE251`) ([#16132](https://github.com/astral-sh/ruff/pull/16132))
|
||||
- \[`pyupgrade`\] Do not upgrade functional `TypedDicts` with private field names to the class-based syntax (`UP013`) ([#16219](https://github.com/astral-sh/ruff/pull/16219))
|
||||
- \[`pyupgrade`\] Handle micro version numbers correctly (`UP036`) ([#16091](https://github.com/astral-sh/ruff/pull/16091))
|
||||
- \[`pyupgrade`\] Unwrap unary expressions correctly (`UP018`) ([#15919](https://github.com/astral-sh/ruff/pull/15919))
|
||||
- \[`refurb`\] Correctly handle lengths of literal strings in `slice-to-remove-prefix-or-suffix` (`FURB188`) ([#16237](https://github.com/astral-sh/ruff/pull/16237))
|
||||
- \[`ruff`\] Skip `RUF001` diagnostics when visiting string type definitions ([#16122](https://github.com/astral-sh/ruff/pull/16122))
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -1448,7 +1253,7 @@ The following fixes have been stabilized:
|
||||
|
||||
## 0.5.6
|
||||
|
||||
Ruff 0.5.6 automatically enables linting and formatting of notebooks in _preview mode_.
|
||||
Ruff 0.5.6 automatically enables linting and formatting of notebooks in *preview mode*.
|
||||
You can opt-out of this behavior by adding `*.ipynb` to the `extend-exclude` setting.
|
||||
|
||||
```toml
|
||||
@@ -2201,7 +2006,7 @@ To setup `ruff server` with your editor, refer to the [README.md](https://github
|
||||
|
||||
### Server
|
||||
|
||||
_This section is devoted to updates for our new language server, written in Rust._
|
||||
*This section is devoted to updates for our new language server, written in Rust.*
|
||||
|
||||
- Enable ruff-specific source actions ([#10916](https://github.com/astral-sh/ruff/pull/10916))
|
||||
- Refreshes diagnostics for open files when file configuration is changed ([#10988](https://github.com/astral-sh/ruff/pull/10988))
|
||||
@@ -3608,7 +3413,7 @@ Read Ruff's new [versioning policy](https://docs.astral.sh/ruff/versioning/).
|
||||
- \[`refurb`\] Add `single-item-membership-test` (`FURB171`) ([#7815](https://github.com/astral-sh/ruff/pull/7815))
|
||||
- \[`pylint`\] Add `and-or-ternary` (`R1706`) ([#7811](https://github.com/astral-sh/ruff/pull/7811))
|
||||
|
||||
_New rules are added in [preview](https://docs.astral.sh/ruff/preview/)._
|
||||
*New rules are added in [preview](https://docs.astral.sh/ruff/preview/).*
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
418
Cargo.lock
generated
418
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.83"
|
||||
rust-version = "1.80"
|
||||
homepage = "https://docs.astral.sh/ruff"
|
||||
documentation = "https://docs.astral.sh/ruff"
|
||||
repository = "https://github.com/astral-sh/ruff"
|
||||
@@ -123,7 +123,7 @@ rayon = { version = "1.10.0" }
|
||||
regex = { version = "1.10.2" }
|
||||
rustc-hash = { version = "2.0.0" }
|
||||
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
|
||||
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "095d8b2b8115c3cf8bf31914dd9ea74648bb7cf9" }
|
||||
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "351d9cf0037be949d17800d0c7b4838e533c2ed6" }
|
||||
schemars = { version = "0.8.16" }
|
||||
seahash = { version = "4.1.0" }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
|
||||
@@ -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.10.0/install.sh | sh
|
||||
powershell -c "irm https://astral.sh/ruff/0.10.0/install.ps1 | iex"
|
||||
curl -LsSf https://astral.sh/ruff/0.9.7/install.sh | sh
|
||||
powershell -c "irm https://astral.sh/ruff/0.9.7/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.10.0
|
||||
rev: v0.9.7
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -23,10 +23,6 @@ extend-ignore-re = [
|
||||
# Line ignore with trailing "spellchecker:disable-line"
|
||||
"(?Rm)^.*#\\s*spellchecker:disable-line$",
|
||||
"LICENSEs",
|
||||
# Various third party dependencies uses `typ` as struct field names (e.g., lsp_types::LogMessageParams)
|
||||
"typ",
|
||||
# TODO: Remove this once the `TYP` redirects are removed from `rule_redirects.rs`
|
||||
"TYP",
|
||||
]
|
||||
|
||||
[default.extend-identifiers]
|
||||
|
||||
@@ -19,7 +19,6 @@ ruff_db = { workspace = true, features = ["os", "cache"] }
|
||||
ruff_python_ast = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
argfile = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true, features = ["wrap_help"] }
|
||||
colored = { workspace = true }
|
||||
@@ -32,7 +31,6 @@ tracing = { workspace = true, features = ["release_max_level_debug"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
|
||||
tracing-flame = { workspace = true }
|
||||
tracing-tree = { workspace = true }
|
||||
wild = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
ruff_db = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Running `mypy_primer`
|
||||
|
||||
## Basics
|
||||
|
||||
For now, we use our own [fork of mypy primer]. It can be run using `uvx --from "…" mypy_primer`. For example, to see the help message, run:
|
||||
|
||||
```sh
|
||||
uvx --from "git+https://github.com/astral-sh/mypy_primer.git@add-red-knot-support" mypy_primer -h
|
||||
```
|
||||
|
||||
Alternatively, you can install the forked version of `mypy_primer` using:
|
||||
|
||||
```sh
|
||||
uv tool install "git+https://github.com/astral-sh/mypy_primer.git@add-red-knot-support"
|
||||
```
|
||||
|
||||
and then run it using `uvx mypy_primer` or just `mypy_primer`, if your `PATH` is set up accordingly (see: [Tool executables]).
|
||||
|
||||
## Showing the diagnostics diff between two Git revisions
|
||||
|
||||
To show the diagnostics diff between two Git revisions (e.g. your feature branch and `main`), run:
|
||||
|
||||
```sh
|
||||
mypy_primer \
|
||||
--type-checker knot \
|
||||
--old origin/main \
|
||||
--new my/feature \
|
||||
--debug \
|
||||
--output concise \
|
||||
--project-selector '/black$'
|
||||
```
|
||||
|
||||
This will show the diagnostics diff for the `black` project between the `main` branch and your `my/feature` branch. To run the
|
||||
diff for all projects, you currently need to copy the project-selector regex from the CI pipeline in `.github/workflows/mypy_primer.yaml`.
|
||||
|
||||
You can also take a look at the [full list of ecosystem projects]. Note that some of them might still need a `knot_paths` configuration
|
||||
option to work correctly.
|
||||
|
||||
## Avoiding recompilation
|
||||
|
||||
If you want to run `mypy_primer` repeatedly, e.g. for different projects, but for the same combination of `--old` and `--new`, you
|
||||
can use set the `MYPY_PRIMER_NO_REBUILD` environment variable to avoid recompilation of Red Knot:
|
||||
|
||||
```sh
|
||||
MYPY_PRIMER_NO_REBUILD=1 mypy_primer …
|
||||
```
|
||||
|
||||
## Running from a local copy of the repository
|
||||
|
||||
If you are working on a local branch, you can use `mypy_primer`'s `--repo` option to specify the path to your local copy of the `ruff` repository.
|
||||
This allows `mypy_primer` to check out local branches:
|
||||
|
||||
```sh
|
||||
mypy_primer --repo /path/to/ruff --old origin/main --new my/local-branch …
|
||||
```
|
||||
|
||||
Note that you might need to clean up `/tmp/mypy_primer` in order for this to work correctly.
|
||||
|
||||
[fork of mypy primer]: https://github.com/astral-sh/mypy_primer/tree/add-red-knot-support
|
||||
[full list of ecosystem projects]: https://github.com/astral-sh/mypy_primer/blob/add-red-knot-support/mypy_primer/projects.py
|
||||
[tool executables]: https://docs.astral.sh/uv/concepts/tools/#tool-executables
|
||||
@@ -32,13 +32,6 @@ pub(crate) enum Command {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub(crate) struct CheckCommand {
|
||||
/// List of files or directories to check.
|
||||
#[clap(
|
||||
help = "List of files or directories to check [default: the project root]",
|
||||
value_name = "PATH"
|
||||
)]
|
||||
pub paths: Vec<SystemPathBuf>,
|
||||
|
||||
/// 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,
|
||||
@@ -48,14 +41,12 @@ pub(crate) struct CheckCommand {
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
pub(crate) project: Option<SystemPathBuf>,
|
||||
|
||||
/// Path to the Python installation from which Red Knot resolves type information and third-party dependencies.
|
||||
/// Path to the virtual environment the project uses.
|
||||
///
|
||||
/// Red Knot will search in the path's `site-packages` directories for type information and
|
||||
/// third-party imports.
|
||||
///
|
||||
/// This option is commonly used to specify the path to a virtual environment.
|
||||
/// 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) python: Option<SystemPathBuf>,
|
||||
pub(crate) venv_path: Option<SystemPathBuf>,
|
||||
|
||||
/// Custom directory to use for stdlib typeshed stubs.
|
||||
#[arg(long, value_name = "PATH", alias = "custom-typeshed-dir")]
|
||||
@@ -83,7 +74,7 @@ pub(crate) struct CheckCommand {
|
||||
#[arg(long)]
|
||||
pub(crate) exit_zero: bool,
|
||||
|
||||
/// Watch files for changes and recheck files related to the changed files.
|
||||
/// Run in watch mode by re-running whenever files change.
|
||||
#[arg(long, short = 'W')]
|
||||
pub(crate) watch: bool,
|
||||
}
|
||||
@@ -106,7 +97,7 @@ impl CheckCommand {
|
||||
python_version: self
|
||||
.python_version
|
||||
.map(|version| RangedValue::cli(version.into())),
|
||||
python: self.python.map(RelativePathBuf::cli),
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::io::{self, stdout, BufWriter, Write};
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::process::{ExitCode, Termination};
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -15,8 +15,8 @@ use red_knot_project::watch::ProjectWatcher;
|
||||
use red_knot_project::{watch, Db};
|
||||
use red_knot_project::{ProjectDatabase, ProjectMetadata};
|
||||
use red_knot_server::run_server;
|
||||
use ruff_db::diagnostic::{DisplayDiagnosticConfig, OldDiagnosticTrait, Severity};
|
||||
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
|
||||
use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity};
|
||||
use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf};
|
||||
use salsa::plumbing::ZalsaDatabase;
|
||||
|
||||
mod args;
|
||||
@@ -39,15 +39,6 @@ pub fn main() -> ExitStatus {
|
||||
// the configuration it is help to chain errors ("resolving configuration failed" ->
|
||||
// "failed to read file: subdir/pyproject.toml")
|
||||
for cause in error.chain() {
|
||||
// Exit "gracefully" on broken pipe errors.
|
||||
//
|
||||
// See: https://github.com/BurntSushi/ripgrep/blob/bf63fe8f258afc09bae6caa48f0ae35eaf115005/crates/core/main.rs#L47C1-L61C14
|
||||
if let Some(ioerr) = cause.downcast_ref::<io::Error>() {
|
||||
if ioerr.kind() == io::ErrorKind::BrokenPipe {
|
||||
return ExitStatus::Success;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stderr, " {} {cause}", "Cause:".bold()).ok();
|
||||
}
|
||||
|
||||
@@ -56,10 +47,7 @@ pub fn main() -> ExitStatus {
|
||||
}
|
||||
|
||||
fn run() -> anyhow::Result<ExitStatus> {
|
||||
let args = wild::args_os();
|
||||
let args = argfile::expand_args_from(args, argfile::parse_fromfile, argfile::PREFIX)
|
||||
.context("Failed to read CLI arguments from file")?;
|
||||
let args = Args::parse_from(args);
|
||||
let args = Args::parse_from(std::env::args());
|
||||
|
||||
match args.command {
|
||||
Command::Server => run_server().map(|()| ExitStatus::Success),
|
||||
@@ -81,7 +69,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
|
||||
let _guard = setup_tracing(verbosity)?;
|
||||
|
||||
// The base path to which all CLI arguments are relative to.
|
||||
let cwd = {
|
||||
let cli_base_path = {
|
||||
let cwd = std::env::current_dir().context("Failed to get the current working directory")?;
|
||||
SystemPathBuf::from_path_buf(cwd)
|
||||
.map_err(|path| {
|
||||
@@ -92,42 +80,30 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
|
||||
})?
|
||||
};
|
||||
|
||||
let project_path = args
|
||||
let cwd = args
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| {
|
||||
if project.as_std_path().is_dir() {
|
||||
Ok(SystemPath::absolute(project, &cwd))
|
||||
.map(|cwd| {
|
||||
if cwd.as_std_path().is_dir() {
|
||||
Ok(SystemPath::absolute(cwd, &cli_base_path))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Provided project path `{project}` is not a directory"
|
||||
))
|
||||
Err(anyhow!("Provided project path `{cwd}` is not a directory"))
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| cwd.clone());
|
||||
|
||||
let check_paths: Vec<_> = args
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| SystemPath::absolute(path, &cwd))
|
||||
.collect();
|
||||
.unwrap_or_else(|| cli_base_path.clone());
|
||||
|
||||
let system = OsSystem::new(cwd);
|
||||
let watch = args.watch;
|
||||
let exit_zero = args.exit_zero;
|
||||
|
||||
let cli_options = args.into_options();
|
||||
let mut project_metadata = ProjectMetadata::discover(&project_path, &system)?;
|
||||
let mut project_metadata = ProjectMetadata::discover(system.current_directory(), &system)?;
|
||||
project_metadata.apply_cli_options(cli_options.clone());
|
||||
project_metadata.apply_configuration_files(&system)?;
|
||||
|
||||
let mut db = ProjectDatabase::new(project_metadata, system)?;
|
||||
|
||||
if !check_paths.is_empty() {
|
||||
db.project().set_included_paths(&mut db, check_paths);
|
||||
}
|
||||
|
||||
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options);
|
||||
|
||||
// Listen to Ctrl+C and abort the watch mode.
|
||||
@@ -143,7 +119,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
|
||||
let exit_status = if watch {
|
||||
main_loop.watch(&mut db)?
|
||||
} else {
|
||||
main_loop.run(&mut db)?
|
||||
main_loop.run(&mut db)
|
||||
};
|
||||
|
||||
tracing::trace!("Counts for entire CLI run:\n{}", countme::get_all());
|
||||
@@ -203,7 +179,7 @@ impl MainLoop {
|
||||
)
|
||||
}
|
||||
|
||||
fn watch(mut self, db: &mut ProjectDatabase) -> Result<ExitStatus> {
|
||||
fn watch(mut self, db: &mut ProjectDatabase) -> anyhow::Result<ExitStatus> {
|
||||
tracing::debug!("Starting watch mode");
|
||||
let sender = self.sender.clone();
|
||||
let watcher = watch::directory_watcher(move |event| {
|
||||
@@ -212,12 +188,12 @@ impl MainLoop {
|
||||
|
||||
self.watcher = Some(ProjectWatcher::new(watcher, db));
|
||||
|
||||
self.run(db)?;
|
||||
self.run(db);
|
||||
|
||||
Ok(ExitStatus::Success)
|
||||
}
|
||||
|
||||
fn run(mut self, db: &mut ProjectDatabase) -> Result<ExitStatus> {
|
||||
fn run(mut self, db: &mut ProjectDatabase) -> ExitStatus {
|
||||
self.sender.send(MainLoopMessage::CheckWorkspace).unwrap();
|
||||
|
||||
let result = self.main_loop(db);
|
||||
@@ -227,7 +203,7 @@ impl MainLoop {
|
||||
result
|
||||
}
|
||||
|
||||
fn main_loop(&mut self, db: &mut ProjectDatabase) -> Result<ExitStatus> {
|
||||
fn main_loop(&mut self, db: &mut ProjectDatabase) -> ExitStatus {
|
||||
// Schedule the first check.
|
||||
tracing::debug!("Starting main loop");
|
||||
|
||||
@@ -265,43 +241,14 @@ impl MainLoop {
|
||||
Severity::Error
|
||||
};
|
||||
|
||||
let failed = result
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity() >= min_error_severity);
|
||||
|
||||
if check_revision == revision {
|
||||
if db.project().files(db).is_empty() {
|
||||
tracing::warn!("No python files found under the given path(s)");
|
||||
}
|
||||
|
||||
let mut stdout = stdout().lock();
|
||||
|
||||
if result.is_empty() {
|
||||
writeln!(stdout, "All checks passed!")?;
|
||||
|
||||
if self.watcher.is_none() {
|
||||
return Ok(ExitStatus::Success);
|
||||
}
|
||||
} else {
|
||||
let mut failed = false;
|
||||
let diagnostics_count = result.len();
|
||||
|
||||
for diagnostic in result {
|
||||
writeln!(stdout, "{}", diagnostic.display(db, &display_config))?;
|
||||
|
||||
failed |= diagnostic.severity() >= min_error_severity;
|
||||
}
|
||||
|
||||
writeln!(
|
||||
stdout,
|
||||
"Found {} diagnostic{}",
|
||||
diagnostics_count,
|
||||
if diagnostics_count > 1 { "s" } else { "" }
|
||||
)?;
|
||||
|
||||
if self.watcher.is_none() {
|
||||
return Ok(if failed {
|
||||
ExitStatus::Failure
|
||||
} else {
|
||||
ExitStatus::Success
|
||||
});
|
||||
}
|
||||
#[allow(clippy::print_stdout)]
|
||||
for diagnostic in result {
|
||||
println!("{}", diagnostic.display(db, &display_config));
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
@@ -309,6 +256,14 @@ impl MainLoop {
|
||||
);
|
||||
}
|
||||
|
||||
if self.watcher.is_none() {
|
||||
return if failed {
|
||||
ExitStatus::Failure
|
||||
} else {
|
||||
ExitStatus::Success
|
||||
};
|
||||
}
|
||||
|
||||
tracing::trace!("Counts after last check:\n{}", countme::get_all());
|
||||
}
|
||||
|
||||
@@ -326,14 +281,14 @@ impl MainLoop {
|
||||
// TODO: Don't use Salsa internal APIs
|
||||
// [Zulip-Thread](https://salsa.zulipchat.com/#narrow/stream/333573-salsa-3.2E0/topic/Expose.20an.20API.20to.20cancel.20other.20queries)
|
||||
let _ = db.zalsa_mut();
|
||||
return Ok(ExitStatus::Success);
|
||||
return ExitStatus::Success;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Waiting for next main loop message.");
|
||||
}
|
||||
|
||||
Ok(ExitStatus::Success)
|
||||
ExitStatus::Success
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,8 +308,7 @@ impl MainLoopCancellationToken {
|
||||
enum MainLoopMessage {
|
||||
CheckWorkspace,
|
||||
CheckCompleted {
|
||||
/// The diagnostics that were found during the check.
|
||||
result: Vec<Box<dyn OldDiagnosticTrait>>,
|
||||
result: Vec<Box<dyn Diagnostic>>,
|
||||
revision: u64,
|
||||
},
|
||||
ApplyChanges(Vec<watch::ChangeEvent>),
|
||||
|
||||
@@ -28,7 +28,7 @@ fn config_override() -> anyhow::Result<()> {
|
||||
),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -40,18 +40,16 @@ fn config_override() -> anyhow::Result<()> {
|
||||
| ^^^^^^^^^^^^ Type `<module 'sys'>` has no attribute `last_exc`
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
All checks passed!
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
@@ -86,7 +84,7 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
|
||||
"libs/utils.py",
|
||||
r#"
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
a + b
|
||||
"#,
|
||||
),
|
||||
(
|
||||
@@ -100,7 +98,7 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
|
||||
])?;
|
||||
|
||||
// Make sure that the CLI fails when the `libs` directory is not in the search path.
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r"
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -113,18 +111,16 @@ fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
|
||||
4 | stat = add(10, 15)
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")).arg("--extra-search-path").arg("../libs"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
All checks passed!
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
@@ -158,7 +154,7 @@ fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Re
|
||||
"libs/utils.py",
|
||||
r#"
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
a + b
|
||||
"#,
|
||||
),
|
||||
(
|
||||
@@ -172,12 +168,11 @@ fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Re
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
All checks passed!
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
Ok(())
|
||||
@@ -200,7 +195,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
|
||||
|
||||
// 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"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -222,10 +217,9 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
case.write_file(
|
||||
"pyproject.toml",
|
||||
@@ -236,7 +230,7 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -249,10 +243,9 @@ fn configuration_rule_severity() -> anyhow::Result<()> {
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -276,7 +269,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
|
||||
|
||||
// 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"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -309,10 +302,9 @@ fn cli_rule_severity() -> anyhow::Result<()> {
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
Found 3 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case
|
||||
@@ -323,7 +315,7 @@ fn cli_rule_severity() -> anyhow::Result<()> {
|
||||
.arg("division-by-zero")
|
||||
.arg("--warn")
|
||||
.arg("unresolved-import"),
|
||||
@r"
|
||||
@r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -347,10 +339,9 @@ fn cli_rule_severity() -> anyhow::Result<()> {
|
||||
6 | for a in range(0, y):
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
"###
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -374,7 +365,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
||||
|
||||
// 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"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -396,10 +387,9 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case
|
||||
@@ -411,7 +401,7 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
||||
// Override the error severity with warning
|
||||
.arg("--ignore")
|
||||
.arg("possibly-unresolved-reference"),
|
||||
@r"
|
||||
@r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -424,10 +414,9 @@ fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
||||
4 | for a in range(0, y):
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
"###
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -447,7 +436,7 @@ fn configuration_unknown_rules() -> anyhow::Result<()> {
|
||||
("test.py", "print(10)"),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r#"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -459,10 +448,9 @@ fn configuration_unknown_rules() -> anyhow::Result<()> {
|
||||
| --------------- Unknown lint rule `division-by-zer`
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
"#);
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -472,16 +460,15 @@ fn configuration_unknown_rules() -> anyhow::Result<()> {
|
||||
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"
|
||||
assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
warning: unknown-rule: Unknown lint rule `division-by-zer`
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -490,7 +477,7 @@ fn cli_unknown_rules() -> anyhow::Result<()> {
|
||||
fn exit_code_only_warnings() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -501,10 +488,9 @@ fn exit_code_only_warnings() -> anyhow::Result<()> {
|
||||
| - Name `x` used when not defined
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -519,7 +505,7 @@ fn exit_code_only_info() -> anyhow::Result<()> {
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -531,10 +517,9 @@ fn exit_code_only_info() -> anyhow::Result<()> {
|
||||
| -------------- info: Revealed type is `Literal[1]`
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -549,7 +534,7 @@ fn exit_code_only_info_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -561,10 +546,9 @@ fn exit_code_only_info_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
| -------------- info: Revealed type is `Literal[1]`
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -573,7 +557,7 @@ fn exit_code_only_info_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -584,10 +568,9 @@ fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
|
||||
| - Name `x` used when not defined
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -605,7 +588,7 @@ fn exit_code_no_errors_but_error_on_warning_is_enabled_in_configuration() -> any
|
||||
),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -616,10 +599,9 @@ fn exit_code_no_errors_but_error_on_warning_is_enabled_in_configuration() -> any
|
||||
| - Name `x` used when not defined
|
||||
|
|
||||
|
||||
Found 1 diagnostic
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -634,7 +616,7 @@ fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command(), @r"
|
||||
assert_cmd_snapshot!(case.command(), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -654,10 +636,9 @@ fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -672,7 +653,7 @@ fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::
|
||||
"###,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
||||
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -692,10 +673,9 @@ fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -710,7 +690,7 @@ fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
|
||||
"#,
|
||||
)?;
|
||||
|
||||
assert_cmd_snapshot!(case.command().arg("--exit-zero"), @r"
|
||||
assert_cmd_snapshot!(case.command().arg("--exit-zero"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -730,10 +710,9 @@ fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
|
||||
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
");
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -770,7 +749,7 @@ fn user_configuration() -> anyhow::Result<()> {
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()),
|
||||
@r"
|
||||
@r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
@@ -792,10 +771,9 @@ fn user_configuration() -> anyhow::Result<()> {
|
||||
| - Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
"###
|
||||
);
|
||||
|
||||
// The user-level configuration promotes `possibly-unresolved-reference` to an error.
|
||||
@@ -812,7 +790,7 @@ fn user_configuration() -> anyhow::Result<()> {
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()),
|
||||
@r"
|
||||
@r###"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
@@ -834,134 +812,9 @@ fn user_configuration() -> anyhow::Result<()> {
|
||||
| ^ Name `x` used when possibly not defined
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_specific_paths() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([
|
||||
(
|
||||
"project/main.py",
|
||||
r#"
|
||||
y = 4 / 0 # error: division-by-zero
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"project/tests/test_main.py",
|
||||
r#"
|
||||
import does_not_exist # error: unresolved-import
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"project/other.py",
|
||||
r#"
|
||||
from main2 import z # error: unresolved-import
|
||||
|
||||
print(z)
|
||||
"#,
|
||||
),
|
||||
])?;
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case.command(),
|
||||
@r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/project/tests/test_main.py:2:8
|
||||
|
|
||||
2 | import does_not_exist # error: unresolved-import
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
||||
|
|
||||
|
||||
error: lint:division-by-zero
|
||||
--> <temp_dir>/project/main.py:2:5
|
||||
|
|
||||
2 | y = 4 / 0 # error: division-by-zero
|
||||
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
||||
|
|
||||
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/project/other.py:2:6
|
||||
|
|
||||
2 | from main2 import z # error: unresolved-import
|
||||
| ^^^^^ Cannot resolve import `main2`
|
||||
3 |
|
||||
4 | print(z)
|
||||
|
|
||||
|
||||
Found 3 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
|
||||
// Now check only the `tests` and `other.py` files.
|
||||
// We should no longer see any diagnostics related to `main.py`.
|
||||
assert_cmd_snapshot!(
|
||||
case.command().arg("project/tests").arg("project/other.py"),
|
||||
@r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/project/tests/test_main.py:2:8
|
||||
|
|
||||
2 | import does_not_exist # error: unresolved-import
|
||||
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
||||
|
|
||||
|
||||
error: lint:unresolved-import
|
||||
--> <temp_dir>/project/other.py:2:6
|
||||
|
|
||||
2 | from main2 import z # error: unresolved-import
|
||||
| ^^^^^ Cannot resolve import `main2`
|
||||
3 |
|
||||
4 | print(z)
|
||||
|
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_non_existing_path() -> anyhow::Result<()> {
|
||||
let case = TestCase::with_files([])?;
|
||||
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.add_filter(
|
||||
®ex::escape("The system cannot find the path specified. (os error 3)"),
|
||||
"No such file or directory (os error 2)",
|
||||
);
|
||||
let _s = settings.bind_to_scope();
|
||||
|
||||
assert_cmd_snapshot!(
|
||||
case.command().arg("project/main.py").arg("project/tests"),
|
||||
@r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
error: io: `<temp_dir>/project/main.py`: No such file or directory (os error 2)
|
||||
|
||||
error: io: `<temp_dir>/project/tests`: No such file or directory (os error 2)
|
||||
|
||||
Found 2 diagnostics
|
||||
|
||||
----- stderr -----
|
||||
WARN No python files found under the given path(s)
|
||||
"
|
||||
"###
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#![allow(clippy::disallowed_names)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -194,29 +193,11 @@ impl TestCase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_indexed_project_files(&self, expected: impl IntoIterator<Item = File>) {
|
||||
let mut expected: HashSet<_> = expected.into_iter().collect();
|
||||
|
||||
let actual = self.db().project().files(self.db());
|
||||
for file in &actual {
|
||||
assert!(
|
||||
expected.remove(&file),
|
||||
"Indexed project files contains '{}' which was not expected.",
|
||||
file.path(self.db())
|
||||
);
|
||||
}
|
||||
|
||||
if !expected.is_empty() {
|
||||
let paths: Vec<_> = expected
|
||||
.iter()
|
||||
.map(|file| file.path(self.db()).as_str())
|
||||
.collect();
|
||||
panic!(
|
||||
"Indexed project files are missing the following files: {:?}",
|
||||
paths.join(", ")
|
||||
);
|
||||
}
|
||||
fn collect_project_files(&self) -> Vec<File> {
|
||||
let files = self.db().project().files(self.db());
|
||||
let mut collected: Vec<_> = files.into_iter().collect();
|
||||
collected.sort_unstable_by_key(|file| file.path(self.db()).as_system_path().unwrap());
|
||||
collected
|
||||
}
|
||||
|
||||
fn system_file(&self, path: impl AsRef<SystemPath>) -> Result<File, FileError> {
|
||||
@@ -241,15 +222,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
trait Setup {
|
||||
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()>;
|
||||
trait SetupFiles {
|
||||
fn setup(self, context: &SetupContext) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
struct SetupContext<'a> {
|
||||
system: &'a OsSystem,
|
||||
root_path: &'a SystemPath,
|
||||
options: Option<Options>,
|
||||
included_paths: Option<Vec<SystemPathBuf>>,
|
||||
}
|
||||
|
||||
impl<'a> SetupContext<'a> {
|
||||
@@ -272,77 +251,55 @@ impl<'a> SetupContext<'a> {
|
||||
fn join_root_path(&self, relative: impl AsRef<SystemPath>) -> SystemPathBuf {
|
||||
self.root_path().join(relative)
|
||||
}
|
||||
|
||||
fn write_project_file(
|
||||
&self,
|
||||
relative_path: impl AsRef<SystemPath>,
|
||||
content: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let relative_path = relative_path.as_ref();
|
||||
let absolute_path = self.join_project_path(relative_path);
|
||||
Self::write_file_impl(absolute_path, content)
|
||||
}
|
||||
|
||||
fn write_file(
|
||||
&self,
|
||||
relative_path: impl AsRef<SystemPath>,
|
||||
content: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let relative_path = relative_path.as_ref();
|
||||
let absolute_path = self.join_root_path(relative_path);
|
||||
Self::write_file_impl(absolute_path, content)
|
||||
}
|
||||
|
||||
fn write_file_impl(path: impl AsRef<SystemPath>, content: &str) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create parent directory for file `{path}`"))?;
|
||||
}
|
||||
|
||||
let mut file = std::fs::File::create(path.as_std_path())
|
||||
.with_context(|| format!("Failed to open file `{path}`"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.with_context(|| format!("Failed to write to file `{path}`"))?;
|
||||
file.sync_data()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_options(&mut self, options: Options) {
|
||||
self.options = Some(options);
|
||||
}
|
||||
|
||||
fn set_included_paths(&mut self, paths: Vec<SystemPathBuf>) {
|
||||
self.included_paths = Some(paths);
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize, P> Setup for [(P, &'static str); N]
|
||||
impl<const N: usize, P> SetupFiles for [(P, &'static str); N]
|
||||
where
|
||||
P: AsRef<SystemPath>,
|
||||
{
|
||||
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()> {
|
||||
fn setup(self, context: &SetupContext) -> anyhow::Result<()> {
|
||||
for (relative_path, content) in self {
|
||||
context.write_project_file(relative_path, content)?;
|
||||
let relative_path = relative_path.as_ref();
|
||||
let absolute_path = context.join_project_path(relative_path);
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create parent directory for file `{relative_path}`")
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut file = std::fs::File::create(absolute_path.as_std_path())
|
||||
.with_context(|| format!("Failed to open file `{relative_path}`"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.with_context(|| format!("Failed to write to file `{relative_path}`"))?;
|
||||
file.sync_data()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> Setup for F
|
||||
impl<F> SetupFiles for F
|
||||
where
|
||||
F: FnOnce(&mut SetupContext) -> anyhow::Result<()>,
|
||||
F: FnOnce(&SetupContext) -> anyhow::Result<()>,
|
||||
{
|
||||
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()> {
|
||||
fn setup(self, context: &SetupContext) -> anyhow::Result<()> {
|
||||
self(context)
|
||||
}
|
||||
}
|
||||
|
||||
fn setup<F>(setup_files: F) -> anyhow::Result<TestCase>
|
||||
where
|
||||
F: Setup,
|
||||
F: SetupFiles,
|
||||
{
|
||||
setup_with_options(setup_files, |_context| None)
|
||||
}
|
||||
|
||||
fn setup_with_options<F>(
|
||||
setup_files: F,
|
||||
create_options: impl FnOnce(&SetupContext) -> Option<Options>,
|
||||
) -> anyhow::Result<TestCase>
|
||||
where
|
||||
F: SetupFiles,
|
||||
{
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
|
||||
@@ -368,18 +325,16 @@ where
|
||||
.with_context(|| format!("Failed to create project directory `{project_path}`"))?;
|
||||
|
||||
let system = OsSystem::new(&project_path);
|
||||
let mut setup_context = SetupContext {
|
||||
let setup_context = SetupContext {
|
||||
system: &system,
|
||||
root_path: &root_path,
|
||||
options: None,
|
||||
included_paths: None,
|
||||
};
|
||||
|
||||
setup_files
|
||||
.setup(&mut setup_context)
|
||||
.setup(&setup_context)
|
||||
.context("Failed to setup test files")?;
|
||||
|
||||
if let Some(options) = setup_context.options {
|
||||
if let Some(options) = create_options(&setup_context) {
|
||||
std::fs::write(
|
||||
project_path.join("pyproject.toml").as_std_path(),
|
||||
toml::to_string(&PyProject {
|
||||
@@ -393,8 +348,6 @@ where
|
||||
.context("Failed to write configuration")?;
|
||||
}
|
||||
|
||||
let included_paths = setup_context.included_paths;
|
||||
|
||||
let mut project = ProjectMetadata::discover(&project_path, &system)?;
|
||||
project.apply_configuration_files(&system)?;
|
||||
|
||||
@@ -410,11 +363,7 @@ where
|
||||
.with_context(|| format!("Failed to create search path `{path}`"))?;
|
||||
}
|
||||
|
||||
let mut db = ProjectDatabase::new(project, system)?;
|
||||
|
||||
if let Some(included_paths) = included_paths {
|
||||
db.project().set_included_paths(&mut db, included_paths);
|
||||
}
|
||||
let db = ProjectDatabase::new(project, system)?;
|
||||
|
||||
let (sender, receiver) = crossbeam::channel::unbounded();
|
||||
let watcher = directory_watcher(move |events| sender.send(events).unwrap())
|
||||
@@ -476,7 +425,7 @@ fn new_file() -> anyhow::Result<()> {
|
||||
let foo_path = case.project_path("foo.py");
|
||||
|
||||
assert_eq!(case.system_file(&foo_path), Err(FileError::NotFound));
|
||||
case.assert_indexed_project_files([bar_file]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar_file]);
|
||||
|
||||
std::fs::write(foo_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
@@ -486,7 +435,7 @@ fn new_file() -> anyhow::Result<()> {
|
||||
|
||||
let foo = case.system_file(&foo_path).expect("foo.py to exist.");
|
||||
|
||||
case.assert_indexed_project_files([bar_file, foo]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar_file, foo]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -499,7 +448,7 @@ fn new_ignored_file() -> anyhow::Result<()> {
|
||||
let foo_path = case.project_path("foo.py");
|
||||
|
||||
assert_eq!(case.system_file(&foo_path), Err(FileError::NotFound));
|
||||
case.assert_indexed_project_files([bar_file]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar_file]);
|
||||
|
||||
std::fs::write(foo_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
@@ -508,132 +457,7 @@ fn new_ignored_file() -> anyhow::Result<()> {
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert!(case.system_file(&foo_path).is_ok());
|
||||
case.assert_indexed_project_files([bar_file]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_non_project_file() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_project_file("bar.py", "")?;
|
||||
context.set_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
context.join_root_path("site_packages"),
|
||||
)]),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let bar_path = case.project_path("bar.py");
|
||||
let bar_file = case.system_file(&bar_path).unwrap();
|
||||
|
||||
case.assert_indexed_project_files([bar_file]);
|
||||
|
||||
// Add a file to site packages
|
||||
let black_path = case.root_path().join("site_packages/black.py");
|
||||
std::fs::write(black_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
let changes = case.stop_watch(event_for_file("black.py"));
|
||||
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert!(case.system_file(&black_path).is_ok());
|
||||
|
||||
// The file should not have been added to the project files
|
||||
case.assert_indexed_project_files([bar_file]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_with_explicit_included_paths() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_project_file("src/main.py", "")?;
|
||||
context.write_project_file("src/sub/__init__.py", "")?;
|
||||
context.write_project_file("src/test.py", "")?;
|
||||
context.set_included_paths(vec![
|
||||
context.join_project_path("src/main.py"),
|
||||
context.join_project_path("src/sub"),
|
||||
]);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let main_path = case.project_path("src/main.py");
|
||||
let main_file = case.system_file(&main_path).unwrap();
|
||||
|
||||
let sub_init_path = case.project_path("src/sub/__init__.py");
|
||||
let sub_init = case.system_file(&sub_init_path).unwrap();
|
||||
|
||||
case.assert_indexed_project_files([main_file, sub_init]);
|
||||
|
||||
// Write a new file to `sub` which is an included path
|
||||
let sub_a_path = case.project_path("src/sub/a.py");
|
||||
std::fs::write(sub_a_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
// and write a second file in the root directory -- this should not be included
|
||||
let test2_path = case.project_path("src/test2.py");
|
||||
std::fs::write(test2_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
let changes = case.stop_watch(event_for_file("test2.py"));
|
||||
|
||||
case.apply_changes(changes);
|
||||
|
||||
let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist");
|
||||
|
||||
case.assert_indexed_project_files([main_file, sub_init, sub_a_file]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_in_included_out_of_project_directory() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_project_file("src/main.py", "")?;
|
||||
context.write_project_file("script.py", "")?;
|
||||
context.write_file("outside_project/a.py", "")?;
|
||||
|
||||
context.set_included_paths(vec![
|
||||
context.join_root_path("outside_project"),
|
||||
context.join_project_path("src"),
|
||||
]);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let main_path = case.project_path("src/main.py");
|
||||
let main_file = case.system_file(&main_path).unwrap();
|
||||
|
||||
let outside_a_path = case.root_path().join("outside_project/a.py");
|
||||
let outside_a = case.system_file(&outside_a_path).unwrap();
|
||||
|
||||
case.assert_indexed_project_files([outside_a, main_file]);
|
||||
|
||||
// Write a new file to `src` which should be watched
|
||||
let src_a = case.project_path("src/a.py");
|
||||
std::fs::write(src_a.as_std_path(), "print('Hello')")?;
|
||||
|
||||
// and write a second file to `outside_project` which should be watched too
|
||||
let outside_b_path = case.root_path().join("outside_project/b.py");
|
||||
std::fs::write(outside_b_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
// and a third file in the project's root that should not be included
|
||||
let script2_path = case.project_path("script2.py");
|
||||
std::fs::write(script2_path.as_std_path(), "print('Hello')")?;
|
||||
|
||||
let changes = case.stop_watch(event_for_file("script2.py"));
|
||||
|
||||
case.apply_changes(changes);
|
||||
|
||||
let src_a_file = case.system_file(&src_a).unwrap();
|
||||
let outside_b_file = case.system_file(&outside_b_path).unwrap();
|
||||
|
||||
// The file should not have been added to the project files
|
||||
case.assert_indexed_project_files([main_file, outside_a, outside_b_file, src_a_file]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar_file]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -646,7 +470,7 @@ fn changed_file() -> anyhow::Result<()> {
|
||||
|
||||
let foo = case.system_file(&foo_path)?;
|
||||
assert_eq!(source_text(case.db(), foo).as_str(), foo_source);
|
||||
case.assert_indexed_project_files([foo]);
|
||||
assert_eq!(&case.collect_project_files(), &[foo]);
|
||||
|
||||
update_file(&foo_path, "print('Version 2')")?;
|
||||
|
||||
@@ -657,7 +481,7 @@ fn changed_file() -> anyhow::Result<()> {
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
|
||||
case.assert_indexed_project_files([foo]);
|
||||
assert_eq!(&case.collect_project_files(), &[foo]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -671,7 +495,7 @@ fn deleted_file() -> anyhow::Result<()> {
|
||||
let foo = case.system_file(&foo_path)?;
|
||||
|
||||
assert!(foo.exists(case.db()));
|
||||
case.assert_indexed_project_files([foo]);
|
||||
assert_eq!(&case.collect_project_files(), &[foo]);
|
||||
|
||||
std::fs::remove_file(foo_path.as_std_path())?;
|
||||
|
||||
@@ -680,7 +504,7 @@ fn deleted_file() -> anyhow::Result<()> {
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert!(!foo.exists(case.db()));
|
||||
case.assert_indexed_project_files([]);
|
||||
assert_eq!(&case.collect_project_files(), &[] as &[File]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -700,7 +524,7 @@ fn move_file_to_trash() -> anyhow::Result<()> {
|
||||
let foo = case.system_file(&foo_path)?;
|
||||
|
||||
assert!(foo.exists(case.db()));
|
||||
case.assert_indexed_project_files([foo]);
|
||||
assert_eq!(&case.collect_project_files(), &[foo]);
|
||||
|
||||
std::fs::rename(
|
||||
foo_path.as_std_path(),
|
||||
@@ -712,7 +536,7 @@ fn move_file_to_trash() -> anyhow::Result<()> {
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert!(!foo.exists(case.db()));
|
||||
case.assert_indexed_project_files([]);
|
||||
assert_eq!(&case.collect_project_files(), &[] as &[File]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -730,7 +554,7 @@ fn move_file_to_project() -> anyhow::Result<()> {
|
||||
let foo_in_project = case.project_path("foo.py");
|
||||
|
||||
assert!(case.system_file(&foo_path).is_ok());
|
||||
case.assert_indexed_project_files([bar]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar]);
|
||||
|
||||
std::fs::rename(foo_path.as_std_path(), foo_in_project.as_std_path())?;
|
||||
|
||||
@@ -741,7 +565,7 @@ fn move_file_to_project() -> anyhow::Result<()> {
|
||||
let foo_in_project = case.system_file(&foo_in_project)?;
|
||||
|
||||
assert!(foo_in_project.exists(case.db()));
|
||||
case.assert_indexed_project_files([bar, foo_in_project]);
|
||||
assert_eq!(&case.collect_project_files(), &[bar, foo_in_project]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -755,7 +579,7 @@ fn rename_file() -> anyhow::Result<()> {
|
||||
|
||||
let foo = case.system_file(&foo_path)?;
|
||||
|
||||
case.assert_indexed_project_files([foo]);
|
||||
assert_eq!(case.collect_project_files(), [foo]);
|
||||
|
||||
std::fs::rename(foo_path.as_std_path(), bar_path.as_std_path())?;
|
||||
|
||||
@@ -768,7 +592,7 @@ fn rename_file() -> anyhow::Result<()> {
|
||||
let bar = case.system_file(&bar_path)?;
|
||||
|
||||
assert!(bar.exists(case.db()));
|
||||
case.assert_indexed_project_files([bar]);
|
||||
assert_eq!(case.collect_project_files(), [bar]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -794,7 +618,7 @@ fn directory_moved_to_project() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
assert_eq!(sub_a_module, None);
|
||||
case.assert_indexed_project_files([bar]);
|
||||
assert_eq!(case.collect_project_files(), &[bar]);
|
||||
|
||||
let sub_new_path = case.project_path("sub");
|
||||
std::fs::rename(sub_original_path.as_std_path(), sub_new_path.as_std_path())
|
||||
@@ -818,7 +642,7 @@ fn directory_moved_to_project() -> anyhow::Result<()> {
|
||||
)
|
||||
.is_some());
|
||||
|
||||
case.assert_indexed_project_files([bar, init_file, a_file]);
|
||||
assert_eq!(case.collect_project_files(), &[bar, init_file, a_file]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -846,7 +670,7 @@ fn directory_moved_to_trash() -> anyhow::Result<()> {
|
||||
.system_file(sub_path.join("a.py"))
|
||||
.expect("a.py to exist");
|
||||
|
||||
case.assert_indexed_project_files([bar, init_file, a_file]);
|
||||
assert_eq!(case.collect_project_files(), &[bar, init_file, a_file]);
|
||||
|
||||
std::fs::create_dir(case.root_path().join(".trash").as_std_path())?;
|
||||
let trashed_sub = case.root_path().join(".trash/sub");
|
||||
@@ -867,7 +691,7 @@ fn directory_moved_to_trash() -> anyhow::Result<()> {
|
||||
assert!(!init_file.exists(case.db()));
|
||||
assert!(!a_file.exists(case.db()));
|
||||
|
||||
case.assert_indexed_project_files([bar]);
|
||||
assert_eq!(case.collect_project_files(), &[bar]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -901,7 +725,7 @@ fn directory_renamed() -> anyhow::Result<()> {
|
||||
.system_file(sub_path.join("a.py"))
|
||||
.expect("a.py to exist");
|
||||
|
||||
case.assert_indexed_project_files([bar, sub_init, sub_a]);
|
||||
assert_eq!(case.collect_project_files(), &[bar, sub_init, sub_a]);
|
||||
|
||||
let foo_baz = case.project_path("foo/baz");
|
||||
|
||||
@@ -943,7 +767,10 @@ fn directory_renamed() -> anyhow::Result<()> {
|
||||
assert!(foo_baz_init.exists(case.db()));
|
||||
assert!(foo_baz_a.exists(case.db()));
|
||||
|
||||
case.assert_indexed_project_files([bar, foo_baz_init, foo_baz_a]);
|
||||
assert_eq!(
|
||||
case.collect_project_files(),
|
||||
&[bar, foo_baz_init, foo_baz_a]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -972,7 +799,7 @@ fn directory_deleted() -> anyhow::Result<()> {
|
||||
let a_file = case
|
||||
.system_file(sub_path.join("a.py"))
|
||||
.expect("a.py to exist");
|
||||
case.assert_indexed_project_files([bar, init_file, a_file]);
|
||||
assert_eq!(case.collect_project_files(), &[bar, init_file, a_file]);
|
||||
|
||||
std::fs::remove_dir_all(sub_path.as_std_path())
|
||||
.with_context(|| "Failed to remove the sub directory")?;
|
||||
@@ -990,17 +817,15 @@ fn directory_deleted() -> anyhow::Result<()> {
|
||||
|
||||
assert!(!init_file.exists(case.db()));
|
||||
assert!(!a_file.exists(case.db()));
|
||||
case.assert_indexed_project_files([bar]);
|
||||
assert_eq!(case.collect_project_files(), &[bar]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_project_file("bar.py", "import sub.a")?;
|
||||
|
||||
context.set_options(Options {
|
||||
let mut case = setup_with_options([("bar.py", "import sub.a")], |context| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
context.join_root_path("site_packages"),
|
||||
@@ -1008,8 +833,7 @@ fn search_path() -> anyhow::Result<()> {
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
|
||||
let site_packages = case.root_path().join("site_packages");
|
||||
@@ -1026,7 +850,10 @@ fn search_path() -> anyhow::Result<()> {
|
||||
case.apply_changes(changes);
|
||||
|
||||
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some());
|
||||
case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]);
|
||||
assert_eq!(
|
||||
case.collect_project_files(),
|
||||
&[case.system_file(case.project_path("bar.py")).unwrap()]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1063,9 +890,8 @@ fn add_search_path() -> anyhow::Result<()> {
|
||||
|
||||
#[test]
|
||||
fn remove_search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_project_file("bar.py", "import sub.a")?;
|
||||
context.set_options(Options {
|
||||
let mut case = setup_with_options([("bar.py", "import sub.a")], |context| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
context.join_root_path("site_packages"),
|
||||
@@ -1073,9 +899,7 @@ fn remove_search_path() -> anyhow::Result<()> {
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
|
||||
// Remove site packages from the search path settings.
|
||||
@@ -1098,30 +922,30 @@ fn remove_search_path() -> anyhow::Result<()> {
|
||||
|
||||
#[test]
|
||||
fn change_python_version_and_platform() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup_with_options(
|
||||
// `sys.last_exc` is a Python 3.12 only feature
|
||||
// `os.getegid()` is Unix only
|
||||
context.write_project_file(
|
||||
[(
|
||||
"bar.py",
|
||||
r#"
|
||||
import sys
|
||||
import os
|
||||
print(sys.last_exc, os.getegid())
|
||||
"#,
|
||||
)?;
|
||||
context.set_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY311)),
|
||||
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
||||
"win32".to_string(),
|
||||
))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
)],
|
||||
|_context| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY311)),
|
||||
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
||||
"win32".to_string(),
|
||||
))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
let diagnostics = case.db.check().context("Failed to check project.")?;
|
||||
|
||||
@@ -1156,35 +980,38 @@ print(sys.last_exc, os.getegid())
|
||||
|
||||
#[test]
|
||||
fn changed_versions_file() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
std::fs::write(
|
||||
context.join_project_path("bar.py").as_std_path(),
|
||||
"import sub.a",
|
||||
)?;
|
||||
std::fs::create_dir_all(context.join_root_path("typeshed/stdlib").as_std_path())?;
|
||||
std::fs::write(
|
||||
context
|
||||
.join_root_path("typeshed/stdlib/VERSIONS")
|
||||
.as_std_path(),
|
||||
"",
|
||||
)?;
|
||||
std::fs::write(
|
||||
context
|
||||
.join_root_path("typeshed/stdlib/os.pyi")
|
||||
.as_std_path(),
|
||||
"# not important",
|
||||
)?;
|
||||
let mut case = setup_with_options(
|
||||
|context: &SetupContext| {
|
||||
std::fs::write(
|
||||
context.join_project_path("bar.py").as_std_path(),
|
||||
"import sub.a",
|
||||
)?;
|
||||
std::fs::create_dir_all(context.join_root_path("typeshed/stdlib").as_std_path())?;
|
||||
std::fs::write(
|
||||
context
|
||||
.join_root_path("typeshed/stdlib/VERSIONS")
|
||||
.as_std_path(),
|
||||
"",
|
||||
)?;
|
||||
std::fs::write(
|
||||
context
|
||||
.join_root_path("typeshed/stdlib/os.pyi")
|
||||
.as_std_path(),
|
||||
"# not important",
|
||||
)?;
|
||||
|
||||
context.set_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
typeshed: Some(RelativePathBuf::cli(context.join_root_path("typeshed"))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
},
|
||||
|context| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
typeshed: Some(RelativePathBuf::cli(context.join_root_path("typeshed"))),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
// Unset the custom typeshed directory.
|
||||
assert_eq!(
|
||||
@@ -1229,7 +1056,7 @@ fn changed_versions_file() -> anyhow::Result<()> {
|
||||
/// we're seeing is that Windows only emits a single event, similar to Linux.
|
||||
#[test]
|
||||
fn hard_links_in_project() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
let foo_path = context.join_project_path("foo.py");
|
||||
std::fs::write(foo_path.as_std_path(), "print('Version 1')")?;
|
||||
|
||||
@@ -1248,7 +1075,6 @@ fn hard_links_in_project() -> anyhow::Result<()> {
|
||||
|
||||
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 1')");
|
||||
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 1')");
|
||||
case.assert_indexed_project_files([bar, foo]);
|
||||
|
||||
// Write to the hard link target.
|
||||
update_file(foo_path, "print('Version 2')").context("Failed to update foo.py")?;
|
||||
@@ -1301,7 +1127,7 @@ fn hard_links_in_project() -> anyhow::Result<()> {
|
||||
ignore = "windows doesn't support observing changes to hard linked files."
|
||||
)]
|
||||
fn hard_links_to_target_outside_project() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
let foo_path = context.join_root_path("foo.py");
|
||||
std::fs::write(foo_path.as_std_path(), "print('Version 1')")?;
|
||||
|
||||
@@ -1409,7 +1235,7 @@ mod unix {
|
||||
ignore = "FSEvents doesn't emit change events for symlinked directories outside of the watched paths."
|
||||
)]
|
||||
fn symlink_target_outside_watched_paths() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
// Set up the symlink target.
|
||||
let link_target = context.join_root_path("bar");
|
||||
std::fs::create_dir_all(link_target.as_std_path())
|
||||
@@ -1490,7 +1316,7 @@ mod unix {
|
||||
/// ```
|
||||
#[test]
|
||||
fn symlink_inside_project() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
// Set up the symlink target.
|
||||
let link_target = context.join_project_path("patched/bar");
|
||||
std::fs::create_dir_all(link_target.as_std_path())
|
||||
@@ -1528,8 +1354,6 @@ mod unix {
|
||||
);
|
||||
assert_eq!(baz.file().path(case.db()).as_system_path(), Some(&*bar_baz));
|
||||
|
||||
case.assert_indexed_project_files([patched_bar_baz_file]);
|
||||
|
||||
// Write to the symlink target.
|
||||
update_file(&patched_bar_baz, "def baz(): print('Version 2')")
|
||||
.context("Failed to update bar/baz.py")?;
|
||||
@@ -1565,7 +1389,6 @@ mod unix {
|
||||
bar_baz_text = bar_baz_text.as_str()
|
||||
);
|
||||
|
||||
case.assert_indexed_project_files([patched_bar_baz_file]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1583,39 +1406,43 @@ mod unix {
|
||||
/// ```
|
||||
#[test]
|
||||
fn symlinked_module_search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
// Set up the symlink target.
|
||||
let site_packages = context.join_root_path("site-packages");
|
||||
let bar = site_packages.join("bar");
|
||||
std::fs::create_dir_all(bar.as_std_path()).context("Failed to create bar directory")?;
|
||||
let baz_original = bar.join("baz.py");
|
||||
std::fs::write(baz_original.as_std_path(), "def baz(): ...")
|
||||
.context("Failed to write baz.py")?;
|
||||
let mut case = setup_with_options(
|
||||
|context: &SetupContext| {
|
||||
// Set up the symlink target.
|
||||
let site_packages = context.join_root_path("site-packages");
|
||||
let bar = site_packages.join("bar");
|
||||
std::fs::create_dir_all(bar.as_std_path())
|
||||
.context("Failed to create bar directory")?;
|
||||
let baz_original = bar.join("baz.py");
|
||||
std::fs::write(baz_original.as_std_path(), "def baz(): ...")
|
||||
.context("Failed to write baz.py")?;
|
||||
|
||||
// Symlink the site packages in the venv to the global site packages
|
||||
let venv_site_packages =
|
||||
context.join_project_path(".venv/lib/python3.12/site-packages");
|
||||
std::fs::create_dir_all(venv_site_packages.parent().unwrap())
|
||||
.context("Failed to create .venv directory")?;
|
||||
std::os::unix::fs::symlink(
|
||||
site_packages.as_std_path(),
|
||||
venv_site_packages.as_std_path(),
|
||||
)
|
||||
.context("Failed to create symlink to site-packages")?;
|
||||
// Symlink the site packages in the venv to the global site packages
|
||||
let venv_site_packages =
|
||||
context.join_project_path(".venv/lib/python3.12/site-packages");
|
||||
std::fs::create_dir_all(venv_site_packages.parent().unwrap())
|
||||
.context("Failed to create .venv directory")?;
|
||||
std::os::unix::fs::symlink(
|
||||
site_packages.as_std_path(),
|
||||
venv_site_packages.as_std_path(),
|
||||
)
|
||||
.context("Failed to create symlink to site-packages")?;
|
||||
|
||||
context.set_options(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
".venv/lib/python3.12/site-packages",
|
||||
)]),
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
},
|
||||
|_context| {
|
||||
Some(Options {
|
||||
environment: Some(EnvironmentOptions {
|
||||
extra_paths: Some(vec![RelativePathBuf::cli(
|
||||
".venv/lib/python3.12/site-packages",
|
||||
)]),
|
||||
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
||||
..EnvironmentOptions::default()
|
||||
}),
|
||||
..Options::default()
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
let baz = resolve_module(
|
||||
case.db().upcast(),
|
||||
@@ -1642,8 +1469,6 @@ mod unix {
|
||||
Some(&*baz_original)
|
||||
);
|
||||
|
||||
case.assert_indexed_project_files([]);
|
||||
|
||||
// Write to the symlink target.
|
||||
update_file(&baz_original, "def baz(): print('Version 2')")
|
||||
.context("Failed to update bar/baz.py")?;
|
||||
@@ -1669,15 +1494,13 @@ mod unix {
|
||||
"def baz(): print('Version 2')"
|
||||
);
|
||||
|
||||
case.assert_indexed_project_files([]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_projects_delete_root() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
std::fs::write(
|
||||
context.join_project_path("pyproject.toml").as_std_path(),
|
||||
r#"
|
||||
@@ -1719,7 +1542,7 @@ fn nested_projects_delete_root() -> anyhow::Result<()> {
|
||||
fn changes_to_user_configuration() -> anyhow::Result<()> {
|
||||
let mut _config_dir_override: Option<UserConfigDirectoryOverrideGuard> = None;
|
||||
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let mut case = setup(|context: &SetupContext| {
|
||||
std::fs::write(
|
||||
context.join_project_path("pyproject.toml").as_std_path(),
|
||||
r#"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{collections::HashMap, hash::BuildHasher};
|
||||
|
||||
use red_knot_python_semantic::{PythonPath, PythonPlatform};
|
||||
use red_knot_python_semantic::{PythonPlatform, SitePackages};
|
||||
use ruff_db::system::SystemPathBuf;
|
||||
use ruff_python_ast::PythonVersion;
|
||||
|
||||
@@ -128,7 +128,7 @@ macro_rules! impl_noop_combine {
|
||||
|
||||
impl_noop_combine!(SystemPathBuf);
|
||||
impl_noop_combine!(PythonPlatform);
|
||||
impl_noop_combine!(PythonPath);
|
||||
impl_noop_combine!(SitePackages);
|
||||
impl_noop_combine!(PythonVersion);
|
||||
|
||||
// std types
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::DEFAULT_LINT_REGISTRY;
|
||||
use crate::{Project, ProjectMetadata};
|
||||
use red_knot_python_semantic::lint::{LintRegistry, RuleSelection};
|
||||
use red_knot_python_semantic::{Db as SemanticDb, Program};
|
||||
use ruff_db::diagnostic::OldDiagnosticTrait;
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
use ruff_db::files::{File, Files};
|
||||
use ruff_db::system::System;
|
||||
use ruff_db::vendored::VendoredFileSystem;
|
||||
@@ -55,11 +55,11 @@ impl ProjectDatabase {
|
||||
}
|
||||
|
||||
/// Checks all open files in the project and its dependencies.
|
||||
pub fn check(&self) -> Result<Vec<Box<dyn OldDiagnosticTrait>>, Cancelled> {
|
||||
pub fn check(&self) -> Result<Vec<Box<dyn Diagnostic>>, Cancelled> {
|
||||
self.with_db(|db| db.project().check(db))
|
||||
}
|
||||
|
||||
pub fn check_file(&self, file: File) -> Result<Vec<Box<dyn OldDiagnosticTrait>>, Cancelled> {
|
||||
pub fn check_file(&self, file: File) -> Result<Vec<Box<dyn Diagnostic>>, Cancelled> {
|
||||
let _span = tracing::debug_span!("check_file", file=%file.path(self)).entered();
|
||||
|
||||
self.with_db(|db| self.project().check_file(db, file))
|
||||
|
||||
@@ -2,20 +2,20 @@ use crate::db::{Db, ProjectDatabase};
|
||||
use crate::metadata::options::Options;
|
||||
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
|
||||
use crate::{Project, ProjectMetadata};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::walk::ProjectFilesWalker;
|
||||
use red_knot_python_semantic::Program;
|
||||
use ruff_db::files::{File, Files};
|
||||
use ruff_db::files::{system_path_to_file, File, Files};
|
||||
use ruff_db::system::walk_directory::WalkState;
|
||||
use ruff_db::system::SystemPath;
|
||||
use ruff_db::Db as _;
|
||||
use ruff_python_ast::PySourceType;
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
impl ProjectDatabase {
|
||||
#[tracing::instrument(level = "debug", skip(self, changes, cli_options))]
|
||||
pub fn apply_changes(&mut self, changes: Vec<ChangeEvent>, cli_options: Option<&Options>) {
|
||||
let mut project = self.project();
|
||||
let project_root = project.root(self).to_path_buf();
|
||||
let project_path = project.root(self).to_path_buf();
|
||||
let program = Program::get(self);
|
||||
let custom_stdlib_versions_path = program
|
||||
.custom_stdlib_search_path(self)
|
||||
@@ -30,7 +30,7 @@ impl ProjectDatabase {
|
||||
|
||||
// Deduplicate the `sync` calls. Many file watchers emit multiple events for the same path.
|
||||
let mut synced_files = FxHashSet::default();
|
||||
let mut sync_recursively = BTreeSet::default();
|
||||
let mut synced_recursively = FxHashSet::default();
|
||||
|
||||
let mut sync_path = |db: &mut ProjectDatabase, path: &SystemPath| {
|
||||
if synced_files.insert(path.to_path_buf()) {
|
||||
@@ -38,9 +38,13 @@ impl ProjectDatabase {
|
||||
}
|
||||
};
|
||||
|
||||
for change in changes {
|
||||
tracing::trace!("Handle change: {:?}", change);
|
||||
let mut sync_recursively = |db: &mut ProjectDatabase, path: &SystemPath| {
|
||||
if synced_recursively.insert(path.to_path_buf()) {
|
||||
Files::sync_recursively(db, path);
|
||||
}
|
||||
};
|
||||
|
||||
for change in changes {
|
||||
if let Some(path) = change.system_path() {
|
||||
if matches!(
|
||||
path.file_name(),
|
||||
@@ -66,27 +70,16 @@ impl ProjectDatabase {
|
||||
match kind {
|
||||
CreatedKind::File => sync_path(self, &path),
|
||||
CreatedKind::Directory | CreatedKind::Any => {
|
||||
sync_recursively.insert(path.clone());
|
||||
sync_recursively(self, &path);
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike other files, it's not only important to update the status of existing
|
||||
// and known `File`s (`sync_recursively`), it's also important to discover new files
|
||||
// that were added in the project's root (or any of the paths included for checking).
|
||||
//
|
||||
// This is important because `Project::check` iterates over all included files.
|
||||
// The code below walks the `added_paths` and adds all files that
|
||||
// should be included in the project. We can skip this check for
|
||||
// paths that aren't part of the project or shouldn't be included
|
||||
// when checking the project.
|
||||
if project.is_path_included(self, &path) {
|
||||
if self.system().is_file(&path) {
|
||||
// Add the parent directory because `walkdir` always visits explicitly passed files
|
||||
// even if they match an exclude filter.
|
||||
added_paths.insert(path.parent().unwrap().to_path_buf());
|
||||
} else {
|
||||
added_paths.insert(path);
|
||||
}
|
||||
if self.system().is_file(&path) {
|
||||
// Add the parent directory because `walkdir` always visits explicitly passed files
|
||||
// even if they match an exclude filter.
|
||||
added_paths.insert(path.parent().unwrap().to_path_buf());
|
||||
} else {
|
||||
added_paths.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +103,7 @@ impl ProjectDatabase {
|
||||
project.remove_file(self, file);
|
||||
}
|
||||
} else {
|
||||
sync_recursively.insert(path.clone());
|
||||
sync_recursively(self, &path);
|
||||
|
||||
if custom_stdlib_versions_path
|
||||
.as_ref()
|
||||
@@ -119,19 +112,11 @@ impl ProjectDatabase {
|
||||
custom_stdlib_change = true;
|
||||
}
|
||||
|
||||
if project.is_path_included(self, &path) || path == project_root {
|
||||
// TODO: Shouldn't it be enough to simply traverse the project files and remove all
|
||||
// that start with the given path?
|
||||
tracing::debug!(
|
||||
"Reload project because of a path that could have been a directory."
|
||||
);
|
||||
|
||||
// Perform a full-reload in case the deleted directory contained the pyproject.toml.
|
||||
// We may want to make this more clever in the future, to e.g. iterate over the
|
||||
// indexed files and remove the once that start with the same path, unless
|
||||
// the deleted path is the project configuration.
|
||||
project_changed = true;
|
||||
}
|
||||
// Perform a full-reload in case the deleted directory contained the pyproject.toml.
|
||||
// We may want to make this more clever in the future, to e.g. iterate over the
|
||||
// indexed files and remove the once that start with the same path, unless
|
||||
// the deleted path is the project configuration.
|
||||
project_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,29 +133,13 @@ impl ProjectDatabase {
|
||||
ChangeEvent::Rescan => {
|
||||
project_changed = true;
|
||||
Files::sync_all(self);
|
||||
sync_recursively.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sync_recursively = sync_recursively.into_iter();
|
||||
let mut last = None;
|
||||
|
||||
for path in sync_recursively {
|
||||
// Avoid re-syncing paths that are sub-paths of each other.
|
||||
if let Some(last) = &last {
|
||||
if path.starts_with(last) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Files::sync_recursively(self, &path);
|
||||
last = Some(path);
|
||||
}
|
||||
|
||||
if project_changed {
|
||||
match ProjectMetadata::discover(&project_root, self.system()) {
|
||||
match ProjectMetadata::discover(&project_path, self.system()) {
|
||||
Ok(mut metadata) => {
|
||||
if let Some(cli_options) = cli_options {
|
||||
metadata.apply_cli_options(cli_options.clone());
|
||||
@@ -217,24 +186,50 @@ impl ProjectDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
let diagnostics = if let Some(walker) = ProjectFilesWalker::incremental(self, added_paths) {
|
||||
// Use directory walking to discover newly added files.
|
||||
let (files, diagnostics) = walker.collect_vec(self);
|
||||
let mut added_paths = added_paths.into_iter();
|
||||
|
||||
for file in files {
|
||||
project.add_file(self, file);
|
||||
// Use directory walking to discover newly added files.
|
||||
if let Some(path) = added_paths.next() {
|
||||
let mut walker = self.system().walk_directory(&path);
|
||||
|
||||
for extra_path in added_paths {
|
||||
walker = walker.add(&extra_path);
|
||||
}
|
||||
|
||||
diagnostics
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let added_paths = std::sync::Mutex::new(Vec::default());
|
||||
|
||||
// Note: We simply replace all IO related diagnostics here. This isn't ideal, because
|
||||
// it removes IO errors that may still be relevant. However, tracking IO errors correctly
|
||||
// across revisions doesn't feel essential, considering that they're rare. However, we could
|
||||
// implement a `BTreeMap` or similar and only prune the diagnostics from paths that we've
|
||||
// re-scanned (or that were removed etc).
|
||||
project.replace_index_diagnostics(self, diagnostics);
|
||||
walker.run(|| {
|
||||
Box::new(|entry| {
|
||||
let Ok(entry) = entry else {
|
||||
return WalkState::Continue;
|
||||
};
|
||||
|
||||
if !entry.file_type().is_file() {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
|
||||
if entry
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(PySourceType::try_from_extension)
|
||||
.is_some()
|
||||
{
|
||||
let mut paths = added_paths.lock().unwrap();
|
||||
|
||||
paths.push(entry.into_path());
|
||||
}
|
||||
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
for path in added_paths.into_inner().unwrap() {
|
||||
let file = system_path_to_file(self, &path);
|
||||
|
||||
if let Ok(file) = file {
|
||||
project.add_file(self, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ use salsa::Setter;
|
||||
use ruff_db::files::File;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::{IOErrorDiagnostic, Project};
|
||||
use crate::Project;
|
||||
|
||||
/// Cheap cloneable hash set of files.
|
||||
type FileSet = Arc<FxHashSet<File>>;
|
||||
|
||||
/// The indexed files of a project.
|
||||
///
|
||||
@@ -32,9 +35,9 @@ impl IndexedFiles {
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed(inner: Arc<IndexedInner>) -> Self {
|
||||
fn indexed(files: FileSet) -> Self {
|
||||
Self {
|
||||
state: std::sync::Mutex::new(State::Indexed(inner)),
|
||||
state: std::sync::Mutex::new(State::Indexed(files)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +46,8 @@ impl IndexedFiles {
|
||||
|
||||
match &*state {
|
||||
State::Lazy => Index::Lazy(LazyFiles { files: state }),
|
||||
State::Indexed(inner) => Index::Indexed(Indexed {
|
||||
inner: Arc::clone(inner),
|
||||
State::Indexed(files) => Index::Indexed(Indexed {
|
||||
files: Arc::clone(files),
|
||||
_lifetime: PhantomData,
|
||||
}),
|
||||
}
|
||||
@@ -91,7 +94,7 @@ impl IndexedFiles {
|
||||
Some(IndexedMut {
|
||||
db: Some(db),
|
||||
project,
|
||||
indexed,
|
||||
files: indexed,
|
||||
did_change: false,
|
||||
})
|
||||
}
|
||||
@@ -109,7 +112,7 @@ enum State {
|
||||
Lazy,
|
||||
|
||||
/// The files are indexed. Stores the known files of a package.
|
||||
Indexed(Arc<IndexedInner>),
|
||||
Indexed(FileSet),
|
||||
}
|
||||
|
||||
pub(super) enum Index<'db> {
|
||||
@@ -126,48 +129,32 @@ pub(super) struct LazyFiles<'db> {
|
||||
|
||||
impl<'db> LazyFiles<'db> {
|
||||
/// Sets the indexed files of a package to `files`.
|
||||
pub(super) fn set(
|
||||
mut self,
|
||||
files: FxHashSet<File>,
|
||||
diagnostics: Vec<IOErrorDiagnostic>,
|
||||
) -> Indexed<'db> {
|
||||
pub(super) fn set(mut self, files: FxHashSet<File>) -> Indexed<'db> {
|
||||
let files = Indexed {
|
||||
inner: Arc::new(IndexedInner { files, diagnostics }),
|
||||
files: Arc::new(files),
|
||||
_lifetime: PhantomData,
|
||||
};
|
||||
*self.files = State::Indexed(Arc::clone(&files.inner));
|
||||
*self.files = State::Indexed(Arc::clone(&files.files));
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
/// The indexed files of the project.
|
||||
/// The indexed files of a package.
|
||||
///
|
||||
/// Note: This type is intentionally non-cloneable. Making it cloneable requires
|
||||
/// revisiting the locking behavior in [`IndexedFiles::indexed_mut`].
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Indexed<'db> {
|
||||
inner: Arc<IndexedInner>,
|
||||
files: FileSet,
|
||||
// Preserve the lifetime of `PackageFiles`.
|
||||
_lifetime: PhantomData<&'db ()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IndexedInner {
|
||||
files: FxHashSet<File>,
|
||||
diagnostics: Vec<IOErrorDiagnostic>,
|
||||
}
|
||||
|
||||
impl Indexed<'_> {
|
||||
pub(super) fn diagnostics(&self) -> &[IOErrorDiagnostic] {
|
||||
&self.inner.diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Indexed<'_> {
|
||||
type Target = FxHashSet<File>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner.files
|
||||
&self.files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +165,7 @@ impl<'a> IntoIterator for &'a Indexed<'_> {
|
||||
type IntoIter = IndexedIter<'a>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.inner.files.iter().copied()
|
||||
self.files.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,13 +176,13 @@ impl<'a> IntoIterator for &'a Indexed<'_> {
|
||||
pub(super) struct IndexedMut<'db> {
|
||||
db: Option<&'db mut dyn Db>,
|
||||
project: Project,
|
||||
indexed: Arc<IndexedInner>,
|
||||
files: FileSet,
|
||||
did_change: bool,
|
||||
}
|
||||
|
||||
impl IndexedMut<'_> {
|
||||
pub(super) fn insert(&mut self, file: File) -> bool {
|
||||
if self.inner_mut().files.insert(file) {
|
||||
if self.files_mut().insert(file) {
|
||||
self.did_change = true;
|
||||
true
|
||||
} else {
|
||||
@@ -204,7 +191,7 @@ impl IndexedMut<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, file: File) -> bool {
|
||||
if self.inner_mut().files.remove(&file) {
|
||||
if self.files_mut().remove(&file) {
|
||||
self.did_change = true;
|
||||
true
|
||||
} else {
|
||||
@@ -212,13 +199,8 @@ impl IndexedMut<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_diagnostics(&mut self, diagnostics: Vec<IOErrorDiagnostic>) {
|
||||
self.inner_mut().diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
fn inner_mut(&mut self) -> &mut IndexedInner {
|
||||
Arc::get_mut(&mut self.indexed)
|
||||
.expect("All references to `FilesSet` should have been dropped")
|
||||
fn files_mut(&mut self) -> &mut FxHashSet<File> {
|
||||
Arc::get_mut(&mut self.files).expect("All references to `FilesSet` to have been dropped")
|
||||
}
|
||||
|
||||
fn set_impl(&mut self) {
|
||||
@@ -226,16 +208,16 @@ impl IndexedMut<'_> {
|
||||
return;
|
||||
};
|
||||
|
||||
let indexed = Arc::clone(&self.indexed);
|
||||
let files = Arc::clone(&self.files);
|
||||
|
||||
if self.did_change {
|
||||
// If there are changes, set the new file_set to trigger a salsa revision change.
|
||||
self.project
|
||||
.set_file_set(db)
|
||||
.to(IndexedFiles::indexed(indexed));
|
||||
.to(IndexedFiles::indexed(files));
|
||||
} else {
|
||||
// The `indexed_mut` replaced the `state` with Lazy. Restore it back to the indexed state.
|
||||
*self.project.file_set(db).state.lock().unwrap() = State::Indexed(indexed);
|
||||
*self.project.file_set(db).state.lock().unwrap() = State::Indexed(files);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +237,7 @@ mod tests {
|
||||
use crate::files::Index;
|
||||
use crate::ProjectMetadata;
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf};
|
||||
use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
|
||||
use ruff_python_ast::name::Name;
|
||||
|
||||
#[test]
|
||||
@@ -270,7 +252,7 @@ mod tests {
|
||||
let file = system_path_to_file(&db, "test.py").unwrap();
|
||||
|
||||
let files = match project.file_set(&db).get() {
|
||||
Index::Lazy(lazy) => lazy.set(FxHashSet::from_iter([file]), Vec::new()),
|
||||
Index::Lazy(lazy) => lazy.set(FxHashSet::from_iter([file])),
|
||||
Index::Indexed(files) => files,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#![allow(clippy::ref_option)]
|
||||
|
||||
use crate::metadata::options::OptionDiagnostic;
|
||||
use crate::walk::{ProjectFilesFilter, ProjectFilesWalker};
|
||||
pub use db::{Db, ProjectDatabase};
|
||||
use files::{Index, Indexed, IndexedFiles};
|
||||
use metadata::settings::Settings;
|
||||
@@ -9,24 +8,24 @@ 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::{DiagnosticId, OldDiagnosticTrait, OldParseDiagnostic, Severity, Span};
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, ParseDiagnostic, Severity, Span};
|
||||
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::{SystemPath, SystemPathBuf};
|
||||
use rustc_hash::FxHashSet;
|
||||
use ruff_db::system::walk_directory::WalkState;
|
||||
use ruff_db::system::{FileType, SystemPath};
|
||||
use ruff_python_ast::PySourceType;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use salsa::Durability;
|
||||
use salsa::Setter;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod combine;
|
||||
|
||||
mod db;
|
||||
mod files;
|
||||
pub mod metadata;
|
||||
mod walk;
|
||||
pub mod watch;
|
||||
|
||||
pub static DEFAULT_LINT_REGISTRY: std::sync::LazyLock<LintRegistry> =
|
||||
@@ -72,30 +71,6 @@ pub struct Project {
|
||||
#[return_ref]
|
||||
pub settings: Settings,
|
||||
|
||||
/// The paths that should be included when checking this project.
|
||||
///
|
||||
/// The default (when this list is empty) is to include all files in the project root
|
||||
/// (that satisfy the configured include and exclude patterns).
|
||||
/// However, it's sometimes desired to only check a subset of the project, e.g. to see
|
||||
/// the diagnostics for a single file or a folder.
|
||||
///
|
||||
/// This list gets initialized by the paths passed to `knot check <paths>`
|
||||
///
|
||||
/// ## How is this different from `open_files`?
|
||||
///
|
||||
/// The `included_paths` is closely related to `open_files`. The only difference is that
|
||||
/// `open_files` is already a resolved set of files whereas `included_paths` is only a list of paths
|
||||
/// that are resolved to files by indexing them. The other difference is that
|
||||
/// new files added to any directory in `included_paths` will be indexed and added to the project
|
||||
/// whereas `open_files` needs to be updated manually (e.g. by the IDE).
|
||||
///
|
||||
/// In short, `open_files` is cheaper in contexts where the set of files is known, like
|
||||
/// in an IDE when the user only wants to check the open tabs. This could be modeled
|
||||
/// with `included_paths` too but it would require an explicit walk dir step that's simply unnecessary.
|
||||
#[default]
|
||||
#[return_ref]
|
||||
included_paths_list: Vec<SystemPathBuf>,
|
||||
|
||||
/// Diagnostics that were generated when resolving the project settings.
|
||||
#[return_ref]
|
||||
settings_diagnostics: Vec<OptionDiagnostic>,
|
||||
@@ -131,16 +106,6 @@ impl Project {
|
||||
self.settings(db).to_rules()
|
||||
}
|
||||
|
||||
/// Returns `true` if `path` is both part of the project and included (see `included_paths_list`).
|
||||
///
|
||||
/// Unlike [Self::files], this method does not respect `.gitignore` files. It only checks
|
||||
/// the project's include and exclude settings as well as the paths that were passed to `knot check <paths>`.
|
||||
/// This means, that this method is an over-approximation of `Self::files` and may return `true` for paths
|
||||
/// that won't be included when checking the project because they're ignored in a `.gitignore` file.
|
||||
pub fn is_path_included(self, db: &dyn Db, path: &SystemPath) -> bool {
|
||||
ProjectFilesFilter::from_project(db, self).is_included(path)
|
||||
}
|
||||
|
||||
pub fn reload(self, db: &mut dyn Db, metadata: ProjectMetadata) {
|
||||
tracing::debug!("Reloading project");
|
||||
assert_eq!(self.root(db), metadata.root());
|
||||
@@ -163,22 +128,15 @@ impl Project {
|
||||
}
|
||||
|
||||
/// Checks all open files in the project and its dependencies.
|
||||
pub(crate) fn check(self, db: &ProjectDatabase) -> Vec<Box<dyn OldDiagnosticTrait>> {
|
||||
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 OldDiagnosticTrait>> = Vec::new();
|
||||
let mut diagnostics: Vec<Box<dyn Diagnostic>> = Vec::new();
|
||||
diagnostics.extend(self.settings_diagnostics(db).iter().map(|diagnostic| {
|
||||
let diagnostic: Box<dyn OldDiagnosticTrait> = Box::new(diagnostic.clone());
|
||||
diagnostic
|
||||
}));
|
||||
|
||||
let files = ProjectFiles::new(db, self);
|
||||
|
||||
diagnostics.extend(files.diagnostics().iter().cloned().map(|diagnostic| {
|
||||
let diagnostic: Box<dyn OldDiagnosticTrait> = Box::new(diagnostic);
|
||||
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
|
||||
diagnostic
|
||||
}));
|
||||
|
||||
@@ -189,6 +147,7 @@ impl Project {
|
||||
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();
|
||||
@@ -207,12 +166,12 @@ impl Project {
|
||||
Arc::into_inner(result).unwrap().into_inner().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn check_file(self, db: &dyn Db, file: File) -> Vec<Box<dyn OldDiagnosticTrait>> {
|
||||
pub(crate) fn check_file(self, db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
|
||||
let mut file_diagnostics: Vec<_> = self
|
||||
.settings_diagnostics(db)
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
let diagnostic: Box<dyn OldDiagnosticTrait> = Box::new(diagnostic.clone());
|
||||
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
|
||||
diagnostic
|
||||
})
|
||||
.collect();
|
||||
@@ -248,30 +207,6 @@ impl Project {
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn set_included_paths(self, db: &mut dyn Db, paths: Vec<SystemPathBuf>) {
|
||||
tracing::debug!("Setting included paths: {paths}", paths = paths.len());
|
||||
|
||||
self.set_included_paths_list(db).to(paths);
|
||||
self.reload_files(db);
|
||||
}
|
||||
|
||||
/// Returns the paths that should be checked.
|
||||
///
|
||||
/// The default is to check the entire project in which case this method returns
|
||||
/// the project root. However, users can specify to only check specific sub-folders or
|
||||
/// even files of a project by using `knot check <paths>`. In that case, this method
|
||||
/// returns the provided absolute paths.
|
||||
///
|
||||
/// Note: The CLI doesn't prohibit users from specifying paths outside the project root.
|
||||
/// This can be useful to check arbitrary files, but it isn't something we recommend.
|
||||
/// We should try to support this use case but it's okay if there are some limitations around it.
|
||||
fn included_paths_or_root(self, db: &dyn Db) -> &[SystemPathBuf] {
|
||||
match &**self.included_paths_list(db) {
|
||||
[] => std::slice::from_ref(&self.metadata(db).root),
|
||||
paths => paths,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -354,17 +289,6 @@ impl Project {
|
||||
index.insert(file);
|
||||
}
|
||||
|
||||
/// Replaces the diagnostics from indexing the project files with `diagnostics`.
|
||||
///
|
||||
/// This is a no-op if the project files haven't been indexed yet.
|
||||
pub fn replace_index_diagnostics(self, db: &mut dyn Db, diagnostics: Vec<IOErrorDiagnostic>) {
|
||||
let Some(mut index) = IndexedFiles::indexed_mut(db, self) else {
|
||||
return;
|
||||
};
|
||||
|
||||
index.set_diagnostics(diagnostics);
|
||||
}
|
||||
|
||||
/// Returns the files belonging to this project.
|
||||
pub fn files(self, db: &dyn Db) -> Indexed<'_> {
|
||||
let files = self.file_set(db);
|
||||
@@ -372,14 +296,12 @@ impl Project {
|
||||
let indexed = match files.get() {
|
||||
Index::Lazy(vacant) => {
|
||||
let _entered =
|
||||
tracing::debug_span!("Project::index_files", project = %self.name(db))
|
||||
tracing::debug_span!("Project::index_files", package = %self.name(db))
|
||||
.entered();
|
||||
|
||||
let walker = ProjectFilesWalker::new(db);
|
||||
let (files, diagnostics) = walker.collect_set(db);
|
||||
|
||||
tracing::info!("Indexed {} file(s)", files.len());
|
||||
vacant.set(files, diagnostics)
|
||||
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,
|
||||
};
|
||||
@@ -397,29 +319,28 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_file_impl(db: &dyn Db, file: File) -> Vec<Box<dyn OldDiagnosticTrait>> {
|
||||
let mut diagnostics: Vec<Box<dyn OldDiagnosticTrait>> = Vec::new();
|
||||
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: Some(file),
|
||||
error: read_error.clone().into(),
|
||||
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 OldDiagnosticTrait> =
|
||||
Box::new(OldParseDiagnostic::new(file, error.clone()));
|
||||
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 OldDiagnosticTrait> = Box::new(diagnostic.clone());
|
||||
let boxed: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
|
||||
boxed
|
||||
}));
|
||||
|
||||
@@ -434,6 +355,53 @@ fn check_file_impl(db: &dyn Db, file: File) -> Vec<Box<dyn OldDiagnosticTrait>>
|
||||
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>),
|
||||
@@ -448,13 +416,6 @@ impl<'a> ProjectFiles<'a> {
|
||||
ProjectFiles::Indexed(project.files(db))
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> &[IOErrorDiagnostic] {
|
||||
match self {
|
||||
ProjectFiles::OpenFiles(_) => &[],
|
||||
ProjectFiles::Indexed(indexed) => indexed.diagnostics(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a ProjectFiles<'a> {
|
||||
@@ -487,13 +448,13 @@ impl Iterator for ProjectFilesIter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug)]
|
||||
pub struct IOErrorDiagnostic {
|
||||
file: Option<File>,
|
||||
error: IOErrorKind,
|
||||
file: File,
|
||||
error: SourceTextError,
|
||||
}
|
||||
|
||||
impl OldDiagnosticTrait for IOErrorDiagnostic {
|
||||
impl Diagnostic for IOErrorDiagnostic {
|
||||
fn id(&self) -> DiagnosticId {
|
||||
DiagnosticId::Io
|
||||
}
|
||||
@@ -503,7 +464,7 @@ impl OldDiagnosticTrait for IOErrorDiagnostic {
|
||||
}
|
||||
|
||||
fn span(&self) -> Option<Span> {
|
||||
self.file.map(Span::from)
|
||||
Some(Span::from(self.file))
|
||||
}
|
||||
|
||||
fn severity(&self) -> Severity {
|
||||
@@ -511,24 +472,15 @@ impl OldDiagnosticTrait for IOErrorDiagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Clone)]
|
||||
enum IOErrorKind {
|
||||
#[error(transparent)]
|
||||
Walk(#[from] walk::WalkError),
|
||||
|
||||
#[error(transparent)]
|
||||
SourceText(#[from] SourceTextError),
|
||||
}
|
||||
|
||||
#[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::OldDiagnosticTrait;
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use ruff_db::source::source_text;
|
||||
use ruff_db::system::{DbWithTestSystem, DbWithWritableSystem as _, SystemPath, SystemPathBuf};
|
||||
use ruff_db::system::{DbWithTestSystem, SystemPath, SystemPathBuf};
|
||||
use ruff_db::testing::assert_function_query_was_not_run;
|
||||
use ruff_python_ast::name::Name;
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ impl ProjectMetadata {
|
||||
// If the `options` don't specify a python version but the `project.requires-python` field is set,
|
||||
// use that as a lower bound instead.
|
||||
if let Some(project) = project {
|
||||
if options
|
||||
if !options
|
||||
.environment
|
||||
.as_ref()
|
||||
.is_none_or(|env| env.python_version.is_none())
|
||||
.is_some_and(|env| env.python_version.is_some())
|
||||
{
|
||||
if let Some(requires_python) = project.resolve_requires_python_lower_bound()? {
|
||||
let mut environment = options.environment.unwrap_or_default();
|
||||
@@ -321,7 +321,7 @@ mod tests {
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([(root.join("foo.py"), ""), (root.join("bar.py"), "")])
|
||||
.write_files([(root.join("foo.py"), ""), (root.join("bar.py"), "")])
|
||||
.context("Failed to write files")?;
|
||||
|
||||
let project =
|
||||
@@ -349,7 +349,7 @@ mod tests {
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -393,7 +393,7 @@ mod tests {
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -432,7 +432,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -482,7 +482,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -532,7 +532,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -572,7 +572,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -623,7 +623,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_files_all([
|
||||
.write_files([
|
||||
(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
@@ -673,7 +673,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -703,7 +703,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -735,7 +735,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -765,7 +765,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -795,7 +795,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -828,7 +828,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -861,7 +861,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -886,7 +886,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
@@ -911,7 +911,7 @@ expected `.`, `]`
|
||||
|
||||
system
|
||||
.memory_file_system()
|
||||
.write_file_all(
|
||||
.write_file(
|
||||
root.join("pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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, PythonPath, PythonPlatform, SearchPathSettings};
|
||||
use ruff_db::diagnostic::{DiagnosticId, OldDiagnosticTrait, Severity, Span};
|
||||
use red_knot_python_semantic::{ProgramSettings, PythonPlatform, SearchPathSettings, SitePackages};
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity, Span};
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use ruff_db::system::{System, SystemPath};
|
||||
use ruff_macros::Combine;
|
||||
@@ -90,7 +90,7 @@ impl Options {
|
||||
.map(|env| {
|
||||
(
|
||||
env.extra_paths.clone(),
|
||||
env.python.clone(),
|
||||
env.venv_path.clone(),
|
||||
env.typeshed.clone(),
|
||||
)
|
||||
})
|
||||
@@ -104,11 +104,11 @@ impl Options {
|
||||
.collect(),
|
||||
src_roots,
|
||||
custom_typeshed: typeshed.map(|path| path.absolute(project_root, system)),
|
||||
python_path: python
|
||||
.map(|python_path| {
|
||||
PythonPath::SysPrefix(python_path.absolute(project_root, system))
|
||||
site_packages: python
|
||||
.map(|venv_path| SitePackages::Derived {
|
||||
venv_path: venv_path.absolute(project_root, system),
|
||||
})
|
||||
.unwrap_or(PythonPath::KnownSitePackages(vec![])),
|
||||
.unwrap_or(SitePackages::Known(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,14 +236,10 @@ pub struct EnvironmentOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub typeshed: Option<RelativePathBuf>,
|
||||
|
||||
/// Path to the Python installation from which Red Knot resolves type information and third-party dependencies.
|
||||
///
|
||||
/// Red Knot will search in the path's `site-packages` directories for type information and
|
||||
/// third-party imports.
|
||||
///
|
||||
/// This option is commonly used to specify the path to a virtual environment.
|
||||
// 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 python: Option<RelativePathBuf>,
|
||||
pub venv_path: Option<RelativePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
|
||||
@@ -376,7 +372,7 @@ impl OptionDiagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
impl OldDiagnosticTrait for OptionDiagnostic {
|
||||
impl Diagnostic for OptionDiagnostic {
|
||||
fn id(&self) -> DiagnosticId {
|
||||
self.id
|
||||
}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
use crate::{Db, IOErrorDiagnostic, IOErrorKind, Project};
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::system::walk_directory::{ErrorKind, WalkDirectoryBuilder, WalkState};
|
||||
use ruff_db::system::{FileType, SystemPath, SystemPathBuf};
|
||||
use ruff_python_ast::PySourceType;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Filter that decides which files are included in the project.
|
||||
///
|
||||
/// In the future, this will hold a reference to the `include` and `exclude` pattern.
|
||||
///
|
||||
/// This struct mainly exists because `dyn Db` isn't `Send` or `Sync`, making it impossible
|
||||
/// to access fields from within the walker.
|
||||
#[derive(Default, Debug)]
|
||||
pub(crate) struct ProjectFilesFilter<'a> {
|
||||
/// The same as [`Project::included_paths_or_root`].
|
||||
included_paths: &'a [SystemPathBuf],
|
||||
|
||||
/// The filter skips checking if the path is in `included_paths` if set to `true`.
|
||||
///
|
||||
/// Skipping this check is useful when the walker only walks over `included_paths`.
|
||||
skip_included_paths: bool,
|
||||
}
|
||||
|
||||
impl<'a> ProjectFilesFilter<'a> {
|
||||
pub(crate) fn from_project(db: &'a dyn Db, project: Project) -> Self {
|
||||
Self {
|
||||
included_paths: project.included_paths_or_root(db),
|
||||
skip_included_paths: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if a file is part of the project and included in the paths to check.
|
||||
///
|
||||
/// A file is included in the checked files if it is a sub path of the project's root
|
||||
/// (when no CLI path arguments are specified) or if it is a sub path of any path provided on the CLI (`knot check <paths>`) AND:
|
||||
///
|
||||
/// * It matches a positive `include` pattern and isn't excluded by a later negative `include` pattern.
|
||||
/// * It doesn't match a positive `exclude` pattern or is re-included by a later negative `exclude` pattern.
|
||||
///
|
||||
/// ## Note
|
||||
///
|
||||
/// This method may return `true` for files that don't end up being included when walking the
|
||||
/// project tree because it doesn't consider `.gitignore` and other ignore files when deciding
|
||||
/// if a file's included.
|
||||
pub(crate) fn is_included(&self, path: &SystemPath) -> bool {
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum CheckPathMatch {
|
||||
/// The path is a partial match of the checked path (it's a sub path)
|
||||
Partial,
|
||||
|
||||
/// The path matches a check path exactly.
|
||||
Full,
|
||||
}
|
||||
|
||||
let m = if self.skip_included_paths {
|
||||
Some(CheckPathMatch::Partial)
|
||||
} else {
|
||||
self.included_paths
|
||||
.iter()
|
||||
.filter_map(|included_path| {
|
||||
if let Ok(relative_path) = path.strip_prefix(included_path) {
|
||||
// Exact matches are always included
|
||||
if relative_path.as_str().is_empty() {
|
||||
Some(CheckPathMatch::Full)
|
||||
} else {
|
||||
Some(CheckPathMatch::Partial)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.max()
|
||||
};
|
||||
|
||||
match m {
|
||||
None => false,
|
||||
Some(CheckPathMatch::Partial) => {
|
||||
// TODO: For partial matches, only include the file if it is included by the project's include/exclude settings.
|
||||
true
|
||||
}
|
||||
Some(CheckPathMatch::Full) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ProjectFilesWalker<'a> {
|
||||
walker: WalkDirectoryBuilder,
|
||||
|
||||
filter: ProjectFilesFilter<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ProjectFilesWalker<'a> {
|
||||
pub(crate) fn new(db: &'a dyn Db) -> Self {
|
||||
let project = db.project();
|
||||
|
||||
let mut filter = ProjectFilesFilter::from_project(db, project);
|
||||
// It's unnecessary to filter on included paths because it only iterates over those to start with.
|
||||
filter.skip_included_paths = true;
|
||||
|
||||
Self::from_paths(db, project.included_paths_or_root(db), filter)
|
||||
.expect("included_paths_or_root to never return an empty iterator")
|
||||
}
|
||||
|
||||
/// Creates a walker for indexing the project files incrementally.
|
||||
///
|
||||
/// The main difference to a full project walk is that `paths` may contain paths
|
||||
/// that aren't part of the included files.
|
||||
pub(crate) fn incremental<P>(db: &'a dyn Db, paths: impl IntoIterator<Item = P>) -> Option<Self>
|
||||
where
|
||||
P: AsRef<SystemPath>,
|
||||
{
|
||||
let project = db.project();
|
||||
|
||||
let filter = ProjectFilesFilter::from_project(db, project);
|
||||
|
||||
Self::from_paths(db, paths, filter)
|
||||
}
|
||||
|
||||
fn from_paths<P>(
|
||||
db: &'a dyn Db,
|
||||
paths: impl IntoIterator<Item = P>,
|
||||
filter: ProjectFilesFilter<'a>,
|
||||
) -> Option<Self>
|
||||
where
|
||||
P: AsRef<SystemPath>,
|
||||
{
|
||||
let mut paths = paths.into_iter();
|
||||
|
||||
let mut walker = db.system().walk_directory(paths.next()?.as_ref());
|
||||
|
||||
for path in paths {
|
||||
walker = walker.add(path);
|
||||
}
|
||||
|
||||
Some(Self { walker, filter })
|
||||
}
|
||||
|
||||
/// Walks the project paths and collects the paths of all files that
|
||||
/// are included in the project.
|
||||
pub(crate) fn walk_paths(self) -> (Vec<SystemPathBuf>, Vec<IOErrorDiagnostic>) {
|
||||
let paths = std::sync::Mutex::new(Vec::new());
|
||||
let diagnostics = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
self.walker.run(|| {
|
||||
Box::new(|entry| {
|
||||
match entry {
|
||||
Ok(entry) => {
|
||||
if !self.filter.is_included(entry.path()) {
|
||||
tracing::debug!("Ignoring not-included path: {}", entry.path());
|
||||
return WalkState::Skip;
|
||||
}
|
||||
|
||||
// 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) => match error.kind() {
|
||||
ErrorKind::Loop { .. } => {
|
||||
unreachable!("Loops shouldn't be possible without following symlinks.")
|
||||
}
|
||||
ErrorKind::Io { path, err } => {
|
||||
let mut diagnostics = diagnostics.lock().unwrap();
|
||||
let error = if let Some(path) = path {
|
||||
WalkError::IOPathError {
|
||||
path: path.clone(),
|
||||
error: err.to_string(),
|
||||
}
|
||||
} else {
|
||||
WalkError::IOError {
|
||||
error: err.to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
diagnostics.push(IOErrorDiagnostic {
|
||||
file: None,
|
||||
error: IOErrorKind::Walk(error),
|
||||
});
|
||||
}
|
||||
ErrorKind::NonUtf8Path { path } => {
|
||||
diagnostics.lock().unwrap().push(IOErrorDiagnostic {
|
||||
file: None,
|
||||
error: IOErrorKind::Walk(WalkError::NonUtf8Path {
|
||||
path: path.clone(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
(
|
||||
paths.into_inner().unwrap(),
|
||||
diagnostics.into_inner().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_vec(self, db: &dyn Db) -> (Vec<File>, Vec<IOErrorDiagnostic>) {
|
||||
let (paths, diagnostics) = self.walk_paths();
|
||||
|
||||
(
|
||||
paths
|
||||
.into_iter()
|
||||
.filter_map(move |path| {
|
||||
// If this returns `None`, then the file was deleted between the `walk_directory` call and now.
|
||||
// We can ignore this.
|
||||
system_path_to_file(db.upcast(), &path).ok()
|
||||
})
|
||||
.collect(),
|
||||
diagnostics,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_set(self, db: &dyn Db) -> (FxHashSet<File>, Vec<IOErrorDiagnostic>) {
|
||||
let (paths, diagnostics) = self.walk_paths();
|
||||
|
||||
let mut files = FxHashSet::with_capacity_and_hasher(paths.len(), FxBuildHasher);
|
||||
|
||||
for path in paths {
|
||||
if let Ok(file) = system_path_to_file(db.upcast(), &path) {
|
||||
files.insert(file);
|
||||
}
|
||||
}
|
||||
|
||||
(files, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub(crate) enum WalkError {
|
||||
#[error("`{path}`: {error}")]
|
||||
IOPathError { path: SystemPathBuf, error: String },
|
||||
|
||||
#[error("Failed to walk project directory: {error}")]
|
||||
IOError { error: String },
|
||||
|
||||
#[error("`{path}` is not a valid UTF-8 path")]
|
||||
NonUtf8Path { path: PathBuf },
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use tracing::info;
|
||||
use red_knot_python_semantic::system_module_search_paths;
|
||||
use ruff_cache::{CacheKey, CacheKeyHasher};
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ruff_db::Upcast;
|
||||
use ruff_db::{Db as _, Upcast};
|
||||
|
||||
use crate::db::{Db, ProjectDatabase};
|
||||
use crate::watch::Watcher;
|
||||
@@ -42,9 +42,9 @@ impl ProjectWatcher {
|
||||
|
||||
pub fn update(&mut self, db: &ProjectDatabase) {
|
||||
let search_paths: Vec<_> = system_module_search_paths(db.upcast()).collect();
|
||||
let project_path = db.project().root(db);
|
||||
let project_path = db.project().root(db).to_path_buf();
|
||||
|
||||
let new_cache_key = Self::compute_cache_key(project_path, &search_paths);
|
||||
let new_cache_key = Self::compute_cache_key(&project_path, &search_paths);
|
||||
|
||||
if self.cache_key == Some(new_cache_key) {
|
||||
return;
|
||||
@@ -68,47 +68,41 @@ impl ProjectWatcher {
|
||||
|
||||
self.has_errored_paths = false;
|
||||
|
||||
let project_path = db
|
||||
.system()
|
||||
.canonicalize_path(&project_path)
|
||||
.unwrap_or(project_path);
|
||||
|
||||
let config_paths = db
|
||||
.project()
|
||||
.metadata(db)
|
||||
.extra_configuration_paths()
|
||||
.iter()
|
||||
.map(SystemPathBuf::as_path);
|
||||
|
||||
// Watch both the project root and any paths provided by the user on the CLI (removing any redundant nested paths).
|
||||
// This is necessary to observe changes to files that are outside the project root.
|
||||
// We always need to watch the project root to observe changes to its configuration.
|
||||
let included_paths = ruff_db::system::deduplicate_nested_paths(
|
||||
std::iter::once(project_path).chain(
|
||||
db.project()
|
||||
.included_paths_list(db)
|
||||
.iter()
|
||||
.map(SystemPathBuf::as_path),
|
||||
),
|
||||
);
|
||||
.cloned();
|
||||
|
||||
// Find the non-overlapping module search paths and filter out paths that are already covered by the project.
|
||||
// Module search paths are already canonicalized.
|
||||
let unique_module_paths = ruff_db::system::deduplicate_nested_paths(
|
||||
search_paths
|
||||
.into_iter()
|
||||
.filter(|path| !path.starts_with(project_path)),
|
||||
);
|
||||
.filter(|path| !path.starts_with(&project_path)),
|
||||
)
|
||||
.map(SystemPath::to_path_buf);
|
||||
|
||||
// Now add the new paths, first starting with the project path and then
|
||||
// adding the library search paths, and finally the paths for configurations.
|
||||
for path in included_paths
|
||||
for path in std::iter::once(project_path)
|
||||
.chain(unique_module_paths)
|
||||
.chain(config_paths)
|
||||
{
|
||||
// Log a warning. It's not worth aborting if registering a single folder fails because
|
||||
// Ruff otherwise stills works as expected.
|
||||
if let Err(error) = self.watcher.watch(path) {
|
||||
if let Err(error) = self.watcher.watch(&path) {
|
||||
// TODO: Log a user-facing warning.
|
||||
tracing::warn!("Failed to setup watcher for path `{path}`: {error}. You have to restart Ruff after making changes to files under this path or you might see stale results.");
|
||||
self.has_errored_paths = true;
|
||||
} else {
|
||||
self.watched_paths.push(path.to_path_buf());
|
||||
self.watched_paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ fn run_corpus_tests(pattern: &str) -> anyhow::Result<()> {
|
||||
let code = std::fs::read_to_string(source)?;
|
||||
|
||||
let mut check_with_file_name = |path: &SystemPath| {
|
||||
memory_fs.write_file_all(path, &code).unwrap();
|
||||
memory_fs.write_file(path, &code).unwrap();
|
||||
File::sync_path(&mut db, path);
|
||||
|
||||
// this test is only asserting that we can pull every expression type without a panic
|
||||
@@ -216,17 +216,6 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
self.visit_body(&for_stmt.orelse);
|
||||
return;
|
||||
}
|
||||
Stmt::With(with_stmt) => {
|
||||
for item in &with_stmt.items {
|
||||
if let Some(target) = &item.optional_vars {
|
||||
self.visit_target(target);
|
||||
}
|
||||
self.visit_expr(&item.context_expr);
|
||||
}
|
||||
|
||||
self.visit_body(&with_stmt.body);
|
||||
return;
|
||||
}
|
||||
Stmt::AnnAssign(_)
|
||||
| Stmt::Return(_)
|
||||
| Stmt::Delete(_)
|
||||
@@ -234,6 +223,7 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
| Stmt::TypeAlias(_)
|
||||
| Stmt::While(_)
|
||||
| Stmt::If(_)
|
||||
| Stmt::With(_)
|
||||
| Stmt::Match(_)
|
||||
| Stmt::Raise(_)
|
||||
| Stmt::Try(_)
|
||||
@@ -279,4 +269,18 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
|
||||
|
||||
/// Whether or not the .py/.pyi version of this file is expected to fail
|
||||
#[rustfmt::skip]
|
||||
const KNOWN_FAILURES: &[(&str, bool, bool)] = &[];
|
||||
const KNOWN_FAILURES: &[(&str, bool, bool)] = &[
|
||||
// related to circular references in nested functions
|
||||
("crates/ruff_linter/resources/test/fixtures/flake8_return/RET503.py", false, true),
|
||||
// related to circular references in class definitions
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py", true, true),
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py", true, true),
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F811_19.py", true, false),
|
||||
("crates/ruff_linter/resources/test/fixtures/pyupgrade/UP039.py", true, false),
|
||||
// related to circular references in type aliases (salsa cycle panic):
|
||||
("crates/ruff_python_parser/resources/inline/err/type_alias_invalid_value_expr.py", true, true),
|
||||
("crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC008.py", true, true),
|
||||
// related to circular references in f-string annotations (invalid syntax)
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_15.py", true, true),
|
||||
("crates/ruff_linter/resources/test/fixtures/pyflakes/F821_14.py", false, true),
|
||||
];
|
||||
|
||||
@@ -42,8 +42,6 @@ smallvec = { workspace = true }
|
||||
static_assertions = { workspace = true }
|
||||
test-case = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
strum = { workspace = true}
|
||||
strum_macros = { workspace = true}
|
||||
|
||||
[dev-dependencies]
|
||||
ruff_db = { workspace = true, features = ["testing", "os"] }
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# Callable
|
||||
|
||||
References:
|
||||
|
||||
- <https://typing.readthedocs.io/en/latest/spec/callables.html#callable>
|
||||
|
||||
TODO: Use `collections.abc` as importing from `typing` is deprecated but this requires support for
|
||||
`*` imports. See: <https://docs.python.org/3/library/typing.html#deprecated-aliases>.
|
||||
|
||||
## Invalid forms
|
||||
|
||||
The `Callable` special form requires _exactly_ two arguments where the first argument is either a
|
||||
parameter type list, parameter specification, `typing.Concatenate`, or `...` and the second argument
|
||||
is the return type. Here, we explore various invalid forms.
|
||||
|
||||
### Empty
|
||||
|
||||
A bare `Callable` without any type arguments:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
def _(c: Callable):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
### Invalid parameter type argument
|
||||
|
||||
When it's not a list:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
# error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
|
||||
def _(c: Callable[int, str]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
Or, when it's a literal type:
|
||||
|
||||
```py
|
||||
# error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
|
||||
def _(c: Callable[42, str]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
Or, when one of the parameter type is invalid in the list:
|
||||
|
||||
```py
|
||||
def _(c: Callable[[int, 42, str, False], None]):
|
||||
# revealed: (int, @Todo(number literal in type expression), str, @Todo(boolean literal in type expression), /) -> None
|
||||
reveal_type(c)
|
||||
```
|
||||
|
||||
### Missing return type
|
||||
|
||||
Using a parameter list:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
|
||||
def _(c: Callable[[int, str]]):
|
||||
reveal_type(c) # revealed: (int, str, /) -> Unknown
|
||||
```
|
||||
|
||||
Or, an ellipsis:
|
||||
|
||||
```py
|
||||
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
|
||||
def _(c: Callable[...]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
### More than two arguments
|
||||
|
||||
We can't reliably infer the callable type if there are more then 2 arguments because we don't know
|
||||
which argument corresponds to either the parameters or the return type.
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
|
||||
def _(c: Callable[[int], str, str]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
## Simple
|
||||
|
||||
A simple `Callable` with multiple parameters and a return type:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
def _(c: Callable[[int, str], int]):
|
||||
reveal_type(c) # revealed: (int, str, /) -> int
|
||||
```
|
||||
|
||||
## Nested
|
||||
|
||||
A nested `Callable` as one of the parameter types:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
def _(c: Callable[[Callable[[int], str]], int]):
|
||||
reveal_type(c) # revealed: ((int, /) -> str, /) -> int
|
||||
```
|
||||
|
||||
And, as the return type:
|
||||
|
||||
```py
|
||||
def _(c: Callable[[int, str], Callable[[int], int]]):
|
||||
reveal_type(c) # revealed: (int, str, /) -> (int, /) -> int
|
||||
```
|
||||
|
||||
## Gradual form
|
||||
|
||||
The `Callable` special form supports the use of `...` in place of the list of parameter types. This
|
||||
is a [gradual form] indicating that the type is consistent with any input signature:
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
def gradual_form(c: Callable[..., str]):
|
||||
reveal_type(c) # revealed: (...) -> str
|
||||
```
|
||||
|
||||
## Using `typing.Concatenate`
|
||||
|
||||
Using `Concatenate` as the first argument to `Callable`:
|
||||
|
||||
```py
|
||||
from typing_extensions import Callable, Concatenate
|
||||
|
||||
def _(c: Callable[Concatenate[int, str, ...], int]):
|
||||
reveal_type(c) # revealed: (*args: @Todo(todo signature *args), **kwargs: @Todo(todo signature **kwargs)) -> int
|
||||
```
|
||||
|
||||
And, as one of the parameter types:
|
||||
|
||||
```py
|
||||
def _(c: Callable[[Concatenate[int, str, ...], int], int]):
|
||||
reveal_type(c) # revealed: (*args: @Todo(todo signature *args), **kwargs: @Todo(todo signature **kwargs)) -> int
|
||||
```
|
||||
|
||||
## Using `typing.ParamSpec`
|
||||
|
||||
Using a `ParamSpec` in a `Callable` annotation:
|
||||
|
||||
```py
|
||||
from typing_extensions import Callable
|
||||
|
||||
# TODO: Not an error; remove once `ParamSpec` is supported
|
||||
# error: [invalid-type-form]
|
||||
def _[**P1](c: Callable[P1, int]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
And, using the legacy syntax:
|
||||
|
||||
```py
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
P2 = ParamSpec("P2")
|
||||
|
||||
# TODO: Not an error; remove once `ParamSpec` is supported
|
||||
# error: [invalid-type-form]
|
||||
def _(c: Callable[P2, int]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
## Using `typing.Unpack`
|
||||
|
||||
Using the unpack operator (`*`):
|
||||
|
||||
```py
|
||||
from typing_extensions import Callable, TypeVarTuple
|
||||
|
||||
Ts = TypeVarTuple("Ts")
|
||||
|
||||
def _(c: Callable[[int, *Ts], int]):
|
||||
reveal_type(c) # revealed: (*args: @Todo(todo signature *args), **kwargs: @Todo(todo signature **kwargs)) -> int
|
||||
```
|
||||
|
||||
And, using the legacy syntax using `Unpack`:
|
||||
|
||||
```py
|
||||
from typing_extensions import Unpack
|
||||
|
||||
def _(c: Callable[[int, Unpack[Ts]], int]):
|
||||
reveal_type(c) # revealed: (*args: @Todo(todo signature *args), **kwargs: @Todo(todo signature **kwargs)) -> int
|
||||
```
|
||||
|
||||
## Member lookup
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
def _(c: Callable[[int], int]):
|
||||
reveal_type(c.__init__) # revealed: Literal[__init__]
|
||||
reveal_type(c.__class__) # revealed: type
|
||||
|
||||
# TODO: The member lookup for `Callable` uses `object` which does not have a `__call__`
|
||||
# attribute. We could special case `__call__` in this context. Refer to
|
||||
# https://github.com/astral-sh/ruff/pull/16493#discussion_r1985098508 for more details.
|
||||
# error: [unresolved-attribute] "Type `(int, /) -> int` has no attribute `__call__`"
|
||||
reveal_type(c.__call__) # revealed: Unknown
|
||||
```
|
||||
|
||||
[gradual form]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-gradual-form
|
||||
@@ -38,8 +38,7 @@ If `__future__.annotations` is imported, annotations *are* deferred.
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
def get_foo() -> Foo:
|
||||
return Foo()
|
||||
def get_foo() -> Foo: ...
|
||||
|
||||
class Foo: ...
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Tests for invalid types in type expressions
|
||||
|
||||
## Invalid types are rejected
|
||||
|
||||
Many types are illegal in the context of a type expression:
|
||||
|
||||
```py
|
||||
import typing
|
||||
from knot_extensions import AlwaysTruthy, AlwaysFalsy
|
||||
from typing_extensions import Literal, Never
|
||||
|
||||
def _(
|
||||
a: type[int],
|
||||
b: AlwaysTruthy,
|
||||
c: AlwaysFalsy,
|
||||
d: Literal[True],
|
||||
e: Literal["bar"],
|
||||
f: Literal[b"foo"],
|
||||
g: tuple[int, str],
|
||||
h: Never,
|
||||
):
|
||||
def foo(): ...
|
||||
def invalid(
|
||||
i: a, # error: [invalid-type-form] "Variable of type `type[int]` is not allowed in a type expression"
|
||||
j: b, # error: [invalid-type-form]
|
||||
k: c, # error: [invalid-type-form]
|
||||
l: d, # error: [invalid-type-form]
|
||||
m: e, # error: [invalid-type-form]
|
||||
n: f, # error: [invalid-type-form]
|
||||
o: g, # error: [invalid-type-form]
|
||||
p: h, # error: [invalid-type-form]
|
||||
q: typing, # error: [invalid-type-form]
|
||||
r: foo, # error: [invalid-type-form]
|
||||
):
|
||||
reveal_type(i) # revealed: Unknown
|
||||
reveal_type(j) # revealed: Unknown
|
||||
reveal_type(k) # revealed: Unknown
|
||||
reveal_type(l) # revealed: Unknown
|
||||
reveal_type(m) # revealed: Unknown
|
||||
reveal_type(n) # revealed: Unknown
|
||||
reveal_type(o) # revealed: Unknown
|
||||
reveal_type(p) # revealed: Unknown
|
||||
reveal_type(q) # revealed: Unknown
|
||||
reveal_type(r) # revealed: Unknown
|
||||
```
|
||||
@@ -73,12 +73,12 @@ qux = (foo, bar)
|
||||
reveal_type(qux) # revealed: tuple[Literal["foo"], Literal["bar"]]
|
||||
|
||||
# TODO: Infer "LiteralString"
|
||||
reveal_type(foo.join(qux)) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(foo.join(qux)) # revealed: @Todo(decorated method)
|
||||
|
||||
template: LiteralString = "{}, {}"
|
||||
reveal_type(template) # revealed: Literal["{}, {}"]
|
||||
# TODO: Infer `LiteralString`
|
||||
reveal_type(template.format(foo, bar)) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(template.format(foo, bar)) # revealed: @Todo(decorated method)
|
||||
```
|
||||
|
||||
### Assignability
|
||||
|
||||
@@ -70,7 +70,8 @@ import typing
|
||||
|
||||
class ListSubclass(typing.List): ...
|
||||
|
||||
# revealed: tuple[Literal[ListSubclass], Literal[list], Literal[MutableSequence], Literal[Sequence], Literal[Reversible], Literal[Collection], Literal[Iterable], Literal[Container], @Todo(protocol), Literal[object]]
|
||||
# TODO: should have `Generic`, should not have `Unknown`
|
||||
# revealed: tuple[Literal[ListSubclass], Literal[list], Unknown, Literal[object]]
|
||||
reveal_type(ListSubclass.__mro__)
|
||||
|
||||
class DictSubclass(typing.Dict): ...
|
||||
|
||||
@@ -116,8 +116,8 @@ MyType = int
|
||||
class Aliases:
|
||||
MyType = str
|
||||
|
||||
forward: "MyType" = "value"
|
||||
not_forward: MyType = "value"
|
||||
forward: "MyType"
|
||||
not_forward: MyType
|
||||
|
||||
reveal_type(Aliases.forward) # revealed: str
|
||||
reveal_type(Aliases.not_forward) # revealed: str
|
||||
|
||||
@@ -18,7 +18,7 @@ def f(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
|
||||
# TODO: should understand the annotation
|
||||
reveal_type(args) # revealed: tuple
|
||||
|
||||
reveal_type(Alias) # revealed: @Todo(Invalid or unsupported `KnownInstanceType` in `Type::to_type_expression`)
|
||||
reveal_type(Alias) # revealed: @Todo(Unsupported or invalid type in a type expression)
|
||||
|
||||
def g() -> TypeGuard[int]: ...
|
||||
def h() -> TypeIs[int]: ...
|
||||
@@ -29,13 +29,11 @@ def i(callback: Callable[Concatenate[int, P], R_co], *args: P.args, **kwargs: P.
|
||||
# TODO: should understand the annotation
|
||||
reveal_type(kwargs) # revealed: dict
|
||||
|
||||
# TODO: not an error; remove once `call` is implemented for `Callable`
|
||||
# error: [call-non-callable]
|
||||
return callback(42, *args, **kwargs)
|
||||
|
||||
class Foo:
|
||||
def method(self, x: Self):
|
||||
reveal_type(x) # revealed: @Todo(Invalid or unsupported `KnownInstanceType` in `Type::to_type_expression`)
|
||||
reveal_type(x) # revealed: @Todo(Unsupported or invalid type in a type expression)
|
||||
```
|
||||
|
||||
## Inheritance
|
||||
|
||||
@@ -75,7 +75,8 @@ def _(flag: bool):
|
||||
|
||||
f = Foo()
|
||||
|
||||
# error: [unsupported-operator] "Operator `+=` is unsupported between objects of type `Foo` and `Literal["Hello, world!"]`"
|
||||
# TODO: We should emit an `unsupported-operator` error here, possibly with the information
|
||||
# that `Foo.__iadd__` may be unbound as additional context.
|
||||
f += "Hello, world!"
|
||||
|
||||
reveal_type(f) # revealed: int | Unknown
|
||||
|
||||
@@ -54,12 +54,13 @@ c_instance.declared_and_bound = False
|
||||
# error: [invalid-assignment] "Object of type `Literal["incompatible"]` is not assignable to attribute `declared_and_bound` of type `bool`"
|
||||
c_instance.declared_and_bound = "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] "Attribute `inferred_from_value` can only be accessed on instances, not on the class object `Literal[C]` itself."
|
||||
# error: [unresolved-attribute] "Type `Literal[C]` has no attribute `inferred_from_value`"
|
||||
reveal_type(C.inferred_from_value) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error (pure instance variables cannot be accessed on the class)
|
||||
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
|
||||
# error: [invalid-attribute-access] "Cannot assign to instance attribute `inferred_from_value` from the class object `Literal[C]`"
|
||||
C.inferred_from_value = "overwritten on class"
|
||||
|
||||
# This assignment is fine:
|
||||
@@ -89,13 +90,13 @@ c_instance = C()
|
||||
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: str | None
|
||||
|
||||
# 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. We currently emit:
|
||||
# error: [unresolved-attribute] "Attribute `declared_and_bound` can only be accessed on instances, not on the class object `Literal[C]` itself."
|
||||
reveal_type(C.declared_and_bound) # revealed: Unknown
|
||||
# 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.declared_and_bound) # revealed: str | None
|
||||
|
||||
# Same as above. Mypy and pyright do not show an error here.
|
||||
# error: [invalid-attribute-access] "Cannot assign to instance attribute `declared_and_bound` from the class object `Literal[C]`"
|
||||
# TODO: same as above. We plan to emit a diagnostic here, even if both mypy
|
||||
# and pyright allow this.
|
||||
C.declared_and_bound = "overwritten on class"
|
||||
|
||||
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `declared_and_bound` of type `str | None`"
|
||||
@@ -115,11 +116,11 @@ c_instance = C()
|
||||
|
||||
reveal_type(c_instance.only_declared) # revealed: str
|
||||
|
||||
# Mypy and pyright do not show an error here. We treat this as a pure instance variable.
|
||||
# error: [unresolved-attribute] "Attribute `only_declared` can only be accessed on instances, not on the class object `Literal[C]` itself."
|
||||
reveal_type(C.only_declared) # revealed: Unknown
|
||||
# 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.only_declared) # revealed: str
|
||||
|
||||
# error: [invalid-attribute-access] "Cannot assign to instance attribute `only_declared` from the class object `Literal[C]`"
|
||||
# TODO: mypy and pyright do not show an error here, but we plan to emit one.
|
||||
C.only_declared = "overwritten on class"
|
||||
```
|
||||
|
||||
@@ -155,9 +156,7 @@ reveal_type(c_instance.declared_in_body_and_init) # revealed: str | None
|
||||
|
||||
reveal_type(c_instance.declared_in_body_defined_in_init) # revealed: str | None
|
||||
|
||||
# TODO: This should be `str | None`. Fixing this requires an overhaul of the `Symbol` API,
|
||||
# which is planned in https://github.com/astral-sh/ruff/issues/14297
|
||||
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: Unknown | str | None
|
||||
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: str | None
|
||||
|
||||
reveal_type(c_instance.bound_in_body_and_init) # revealed: Unknown | None | Literal["a"]
|
||||
```
|
||||
@@ -192,10 +191,11 @@ reveal_type(c_instance.declared_only) # revealed: bytes
|
||||
|
||||
reveal_type(c_instance.declared_and_bound) # revealed: bool
|
||||
|
||||
# error: [unresolved-attribute] "Attribute `inferred_from_value` can only be accessed on instances, not on the class object `Literal[C]` itself."
|
||||
# TODO: We already show an error here, but the message might be improved?
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.inferred_from_value) # revealed: Unknown
|
||||
|
||||
# error: [invalid-attribute-access] "Cannot assign to instance attribute `inferred_from_value` from the class object `Literal[C]`"
|
||||
# TODO: this should be an error
|
||||
C.inferred_from_value = "overwritten on class"
|
||||
```
|
||||
|
||||
@@ -348,11 +348,8 @@ reveal_type(C().y) # revealed: Unknown | str
|
||||
|
||||
```py
|
||||
class ContextManager:
|
||||
def __enter__(self) -> int | None:
|
||||
return 1
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
pass
|
||||
def __enter__(self) -> int | None: ...
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None: ...
|
||||
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
@@ -361,28 +358,9 @@ class C:
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.x) # revealed: Unknown | int | None
|
||||
```
|
||||
|
||||
#### Attributes defined in `with` statements, but with unpacking
|
||||
|
||||
```py
|
||||
class ContextManager:
|
||||
def __enter__(self) -> tuple[int | None, int]:
|
||||
return 1, 2
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
pass
|
||||
|
||||
class C:
|
||||
def __init__(self) -> None:
|
||||
with ContextManager() as (self.x, self.y):
|
||||
pass
|
||||
|
||||
c_instance = C()
|
||||
|
||||
reveal_type(c_instance.x) # revealed: Unknown | int | None
|
||||
reveal_type(c_instance.y) # revealed: Unknown | int
|
||||
# TODO: Should be `Unknown | int | None`
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(c_instance.x) # revealed: Unknown
|
||||
```
|
||||
|
||||
#### Attributes defined in comprehensions
|
||||
@@ -620,9 +598,6 @@ C.class_method()
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_class_variable) # revealed: Unknown
|
||||
|
||||
# TODO: should be no error when descriptor protocol is supported
|
||||
# and the assignment is properly attributed to the class method.
|
||||
# error: [invalid-attribute-access] "Cannot assign to instance attribute `pure_class_variable` from the class object `Literal[C]`"
|
||||
C.pure_class_variable = "overwritten on class"
|
||||
|
||||
# TODO: should be `Unknown | Literal["value set in class method"]` or
|
||||
@@ -728,90 +703,7 @@ reveal_type(Derived().declared_in_body) # revealed: int | None
|
||||
reveal_type(Derived().defined_in_init) # revealed: str | None
|
||||
```
|
||||
|
||||
## Accessing attributes on class objects
|
||||
|
||||
When accessing attributes on class objects, they are always looked up on the type of the class
|
||||
object first, i.e. on the metaclass:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Meta1:
|
||||
attr: Literal["metaclass value"] = "metaclass value"
|
||||
|
||||
class C1(metaclass=Meta1): ...
|
||||
|
||||
reveal_type(C1.attr) # revealed: Literal["metaclass value"]
|
||||
```
|
||||
|
||||
However, the metaclass attribute only takes precedence over a class-level attribute if it is a data
|
||||
descriptor. If it is a non-data descriptor or a normal attribute, the class-level attribute is used
|
||||
instead (see the [descriptor protocol tests] for data/non-data descriptor attributes):
|
||||
|
||||
```py
|
||||
class Meta2:
|
||||
attr: str = "metaclass value"
|
||||
|
||||
class C2(metaclass=Meta2):
|
||||
attr: Literal["class value"] = "class value"
|
||||
|
||||
reveal_type(C2.attr) # revealed: Literal["class value"]
|
||||
```
|
||||
|
||||
If the class-level attribute is only partially defined, we union the metaclass attribute with the
|
||||
class-level attribute:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Meta3:
|
||||
attr1 = "metaclass value"
|
||||
attr2: Literal["metaclass value"] = "metaclass value"
|
||||
|
||||
class C3(metaclass=Meta3):
|
||||
if flag:
|
||||
attr1 = "class value"
|
||||
# TODO: Neither mypy nor pyright show an error here, but we could consider emitting a conflicting-declaration diagnostic here.
|
||||
attr2: Literal["class value"] = "class value"
|
||||
|
||||
reveal_type(C3.attr1) # revealed: Unknown | Literal["metaclass value", "class value"]
|
||||
reveal_type(C3.attr2) # revealed: Literal["metaclass value", "class value"]
|
||||
```
|
||||
|
||||
If the *metaclass* attribute is only partially defined, we emit a `possibly-unbound-attribute`
|
||||
diagnostic:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Meta4:
|
||||
if flag:
|
||||
attr1: str = "metaclass value"
|
||||
|
||||
class C4(metaclass=Meta4): ...
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(C4.attr1) # revealed: str
|
||||
```
|
||||
|
||||
Finally, if both the metaclass attribute and the class-level attribute are only partially defined,
|
||||
we union them and emit a `possibly-unbound-attribute` diagnostic:
|
||||
|
||||
```py
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class Meta5:
|
||||
if flag1:
|
||||
attr1 = "metaclass value"
|
||||
|
||||
class C5(metaclass=Meta5):
|
||||
if flag2:
|
||||
attr1 = "class value"
|
||||
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(C5.attr1) # revealed: Unknown | Literal["metaclass value", "class value"]
|
||||
```
|
||||
|
||||
## Unions of attributes
|
||||
|
||||
If the (meta)class is a union type or if the attribute on the (meta) class has a union type, we
|
||||
infer those union types accordingly:
|
||||
## Union of attributes
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
@@ -823,186 +715,14 @@ def _(flag: bool):
|
||||
class C1:
|
||||
x = 2
|
||||
|
||||
reveal_type(C1.x) # revealed: Unknown | Literal[1, 2]
|
||||
|
||||
class C2:
|
||||
if flag:
|
||||
x = 3
|
||||
else:
|
||||
x = 4
|
||||
|
||||
reveal_type(C1.x) # revealed: Unknown | Literal[1, 2]
|
||||
reveal_type(C2.x) # revealed: Unknown | Literal[3, 4]
|
||||
|
||||
if flag:
|
||||
class Meta3(type):
|
||||
x = 5
|
||||
|
||||
else:
|
||||
class Meta3(type):
|
||||
x = 6
|
||||
|
||||
class C3(metaclass=Meta3): ...
|
||||
reveal_type(C3.x) # revealed: Unknown | Literal[5, 6]
|
||||
|
||||
class Meta4(type):
|
||||
if flag:
|
||||
x = 7
|
||||
else:
|
||||
x = 8
|
||||
|
||||
class C4(metaclass=Meta4): ...
|
||||
reveal_type(C4.x) # revealed: Unknown | Literal[7, 8]
|
||||
```
|
||||
|
||||
## Unions with possibly unbound paths
|
||||
|
||||
### Definite boundness within a class
|
||||
|
||||
In this example, the `x` attribute is not defined in the `C2` element of the union:
|
||||
|
||||
```py
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class C1:
|
||||
x = 1
|
||||
|
||||
class C2: ...
|
||||
|
||||
class C3:
|
||||
x = 3
|
||||
|
||||
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]
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `C1 | C2 | C3` is possibly unbound"
|
||||
reveal_type(C().x) # revealed: Unknown | Literal[1, 3]
|
||||
```
|
||||
|
||||
### Possibly-unbound within a class
|
||||
|
||||
We raise the same diagnostic if the attribute is possibly-unbound in at least one element of the
|
||||
union:
|
||||
|
||||
```py
|
||||
def _(flag: bool, flag1: bool, flag2: bool):
|
||||
class C1:
|
||||
x = 1
|
||||
|
||||
class C2:
|
||||
if flag:
|
||||
x = 2
|
||||
|
||||
class C3:
|
||||
x = 3
|
||||
|
||||
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]
|
||||
|
||||
# Note: we might want to consider ignoring possibly-unbound diagnostics for instance attributes eventually,
|
||||
# see the "Possibly unbound/undeclared instance attribute" section below.
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `C1 | C2 | C3` is possibly unbound"
|
||||
reveal_type(C().x) # revealed: Unknown | Literal[1, 2, 3]
|
||||
```
|
||||
|
||||
### Possibly-unbound within gradual types
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def _(flag: bool):
|
||||
class Base:
|
||||
x: Any
|
||||
|
||||
class Derived(Base):
|
||||
if flag:
|
||||
# Redeclaring `x` with a more static type is okay in terms of LSP.
|
||||
x: int
|
||||
|
||||
reveal_type(Derived().x) # revealed: int | Any
|
||||
```
|
||||
|
||||
### Attribute possibly unbound on a subclass but not on a superclass
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Foo:
|
||||
x = 1
|
||||
|
||||
class Bar(Foo):
|
||||
if flag:
|
||||
x = 2
|
||||
|
||||
reveal_type(Bar.x) # revealed: Unknown | Literal[2, 1]
|
||||
|
||||
reveal_type(Bar().x) # revealed: Unknown | Literal[2, 1]
|
||||
```
|
||||
|
||||
### Attribute possibly unbound on a subclass and on a superclass
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Foo:
|
||||
if flag:
|
||||
x = 1
|
||||
|
||||
class Bar(Foo):
|
||||
if flag:
|
||||
x = 2
|
||||
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(Bar.x) # revealed: Unknown | Literal[2, 1]
|
||||
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(Bar().x) # revealed: Unknown | Literal[2, 1]
|
||||
```
|
||||
|
||||
### Possibly unbound/undeclared instance attribute
|
||||
|
||||
#### Possibly unbound and undeclared
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Foo:
|
||||
if flag:
|
||||
x: int
|
||||
|
||||
def __init(self):
|
||||
if flag:
|
||||
self.x = 1
|
||||
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(Foo().x) # revealed: int | Unknown
|
||||
```
|
||||
|
||||
#### Possibly unbound
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Foo:
|
||||
def __init(self):
|
||||
if flag:
|
||||
self.x = 1
|
||||
|
||||
# Emitting a diagnostic in a case like this is not something we support, and it's unclear
|
||||
# if we ever will (or want to)
|
||||
reveal_type(Foo().x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
### Unions with all paths unbound
|
||||
|
||||
If the symbol is unbound in all elements of the union, we detect that:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class C1: ...
|
||||
class C2: ...
|
||||
C = C1 if flag else C2
|
||||
|
||||
# error: [unresolved-attribute] "Type `Literal[C1, C2]` has no attribute `x`"
|
||||
reveal_type(C.x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Inherited class attributes
|
||||
@@ -1042,133 +762,82 @@ reveal_type(A.__mro__)
|
||||
reveal_type(A.X) # revealed: Unknown | Literal[42]
|
||||
```
|
||||
|
||||
## Intersections of attributes
|
||||
## Unions with possibly unbound paths
|
||||
|
||||
### Attribute only available on one element
|
||||
### Definite boundness within a class
|
||||
|
||||
In this example, the `x` attribute is not defined in the `C2` element of the union:
|
||||
|
||||
```py
|
||||
from knot_extensions import Intersection
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class C1:
|
||||
x = 1
|
||||
|
||||
class A:
|
||||
x: int = 1
|
||||
class C2: ...
|
||||
|
||||
class B: ...
|
||||
class C3:
|
||||
x = 3
|
||||
|
||||
def _(a_and_b: Intersection[A, B]):
|
||||
reveal_type(a_and_b.x) # revealed: int
|
||||
C = C1 if flag1 else C2 if flag2 else C3
|
||||
|
||||
# Same for class objects
|
||||
def _(a_and_b: Intersection[type[A], type[B]]):
|
||||
reveal_type(a_and_b.x) # revealed: int
|
||||
# error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C1, C2, C3]` is possibly unbound"
|
||||
reveal_type(C.x) # revealed: Unknown | Literal[1, 3]
|
||||
```
|
||||
|
||||
### Attribute available on both elements
|
||||
### Possibly-unbound within a class
|
||||
|
||||
We raise the same diagnostic if the attribute is possibly-unbound in at least one element of the
|
||||
union:
|
||||
|
||||
```py
|
||||
from knot_extensions import Intersection
|
||||
def _(flag: bool, flag1: bool, flag2: bool):
|
||||
class C1:
|
||||
x = 1
|
||||
|
||||
class P: ...
|
||||
class Q: ...
|
||||
class C2:
|
||||
if flag:
|
||||
x = 2
|
||||
|
||||
class A:
|
||||
x: P = P()
|
||||
class C3:
|
||||
x = 3
|
||||
|
||||
class B:
|
||||
x: Q = Q()
|
||||
C = C1 if flag1 else C2 if flag2 else C3
|
||||
|
||||
def _(a_and_b: Intersection[A, B]):
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
|
||||
# Same for class objects
|
||||
def _(a_and_b: Intersection[type[A], type[B]]):
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
# 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]
|
||||
```
|
||||
|
||||
### Possible unboundness
|
||||
### Attribute possibly unbound on a subclass but not on a superclass
|
||||
|
||||
```py
|
||||
from knot_extensions import Intersection
|
||||
|
||||
class P: ...
|
||||
class Q: ...
|
||||
|
||||
def _(flag: bool):
|
||||
class A1:
|
||||
class Foo:
|
||||
x = 1
|
||||
|
||||
class Bar(Foo):
|
||||
if flag:
|
||||
x: P = P()
|
||||
x = 2
|
||||
|
||||
class B1: ...
|
||||
|
||||
def inner1(a_and_b: Intersection[A1, B1]):
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: P
|
||||
# Same for class objects
|
||||
def inner1_class(a_and_b: Intersection[type[A1], type[B1]]):
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: P
|
||||
|
||||
class A2:
|
||||
if flag:
|
||||
x: P = P()
|
||||
|
||||
class B1:
|
||||
x: Q = Q()
|
||||
|
||||
def inner2(a_and_b: Intersection[A2, B1]):
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
# Same for class objects
|
||||
def inner2_class(a_and_b: Intersection[type[A2], type[B1]]):
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
|
||||
class A3:
|
||||
if flag:
|
||||
x: P = P()
|
||||
|
||||
class B3:
|
||||
if flag:
|
||||
x: Q = Q()
|
||||
|
||||
def inner3(a_and_b: Intersection[A3, B3]):
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
# Same for class objects
|
||||
def inner3_class(a_and_b: Intersection[type[A3], type[B3]]):
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
|
||||
class A4: ...
|
||||
class B4: ...
|
||||
|
||||
def inner4(a_and_b: Intersection[A4, B4]):
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: Unknown
|
||||
# Same for class objects
|
||||
def inner4_class(a_and_b: Intersection[type[A4], type[B4]]):
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(a_and_b.x) # revealed: Unknown
|
||||
reveal_type(Bar.x) # revealed: Unknown | Literal[2, 1]
|
||||
```
|
||||
|
||||
### Intersection of implicit instance attributes
|
||||
### Attribute possibly unbound on a subclass and on a superclass
|
||||
|
||||
```py
|
||||
from knot_extensions import Intersection
|
||||
def _(flag: bool):
|
||||
class Foo:
|
||||
if flag:
|
||||
x = 1
|
||||
|
||||
class P: ...
|
||||
class Q: ...
|
||||
class Bar(Foo):
|
||||
if flag:
|
||||
x = 2
|
||||
|
||||
class A:
|
||||
def __init__(self):
|
||||
self.x: P = P()
|
||||
|
||||
class B:
|
||||
def __init__(self):
|
||||
self.x: Q = Q()
|
||||
|
||||
def _(a_and_b: Intersection[A, B]):
|
||||
reveal_type(a_and_b.x) # revealed: P & Q
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(Bar.x) # revealed: Unknown | Literal[2, 1]
|
||||
```
|
||||
|
||||
## Attribute access on `Any`
|
||||
### Attribute access on `Any`
|
||||
|
||||
The union of the set of types that `Any` could materialise to is equivalent to `object`. It follows
|
||||
from this that attribute access on `Any` resolves to `Any` if the attribute does not exist on
|
||||
@@ -1199,112 +868,29 @@ reveal_type(C.__mro__) # revealed: tuple[Literal[C], Literal[B], Any, Literal[A
|
||||
reveal_type(C.x) # revealed: Literal[1] & Any
|
||||
```
|
||||
|
||||
## Classes with custom `__getattr__` methods
|
||||
### Unions with all paths unbound
|
||||
|
||||
### Basic
|
||||
|
||||
If a type provides a custom `__getattr__` method, we use the return type of that method as the type
|
||||
for unknown attributes. Consider the following `CustomGetAttr` class:
|
||||
If the symbol is unbound in all elements of the union, we detect that:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
def _(flag: bool):
|
||||
class C1: ...
|
||||
class C2: ...
|
||||
C = C1 if flag else C2
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
|
||||
class GetAttrReturnType: ...
|
||||
|
||||
class CustomGetAttr:
|
||||
class_attr: int = 1
|
||||
|
||||
if flag():
|
||||
possibly_unbound: bytes = b"a"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.instance_attr: str = "a"
|
||||
|
||||
def __getattr__(self, name: str) -> GetAttrReturnType:
|
||||
return GetAttrReturnType()
|
||||
```
|
||||
|
||||
We can access arbitrary attributes on instances of this class, and the type of the attribute will be
|
||||
`GetAttrReturnType`:
|
||||
|
||||
```py
|
||||
c = CustomGetAttr()
|
||||
|
||||
reveal_type(c.whatever) # revealed: GetAttrReturnType
|
||||
```
|
||||
|
||||
If an attribute is defined on the class, it takes precedence over the `__getattr__` method:
|
||||
|
||||
```py
|
||||
reveal_type(c.class_attr) # revealed: int
|
||||
```
|
||||
|
||||
If the class attribute is possibly unbound, we union the type of the attribute with the fallback
|
||||
type of the `__getattr__` method:
|
||||
|
||||
```py
|
||||
reveal_type(c.possibly_unbound) # revealed: bytes | GetAttrReturnType
|
||||
```
|
||||
|
||||
Instance attributes also take precedence over the `__getattr__` method:
|
||||
|
||||
```py
|
||||
# Note: we could attempt to union with the fallback type of `__getattr__` here, as we currently do not
|
||||
# attempt to determine if instance attributes are always bound or not. Neither mypy nor pyright do this,
|
||||
# so it's not a priority.
|
||||
reveal_type(c.instance_attr) # revealed: str
|
||||
```
|
||||
|
||||
### Type of the `name` parameter
|
||||
|
||||
If the `name` parameter of the `__getattr__` method is annotated with a (union of) literal type(s),
|
||||
we only consider the attribute access to be valid if the accessed attribute is one of them:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class Date:
|
||||
def __getattr__(self, name: Literal["day", "month", "year"]) -> int:
|
||||
return 0
|
||||
|
||||
date = Date()
|
||||
|
||||
reveal_type(date.day) # revealed: int
|
||||
reveal_type(date.month) # revealed: int
|
||||
reveal_type(date.year) # revealed: int
|
||||
|
||||
# error: [unresolved-attribute] "Type `Date` has no attribute `century`"
|
||||
reveal_type(date.century) # revealed: Unknown
|
||||
```
|
||||
|
||||
### `argparse.Namespace`
|
||||
|
||||
A standard library example of a class with a custom `__getattr__` method is `argparse.Namespace`:
|
||||
|
||||
```py
|
||||
import argparse
|
||||
|
||||
def _(ns: argparse.Namespace):
|
||||
reveal_type(ns.whatever) # revealed: Any
|
||||
# error: [unresolved-attribute] "Type `Literal[C1, C2]` has no attribute `x`"
|
||||
reveal_type(C.x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Objects of all types have a `__class__` method
|
||||
|
||||
The type of `x.__class__` is the same as `x`'s meta-type. `x.__class__` is always the same value as
|
||||
`type(x)`.
|
||||
|
||||
```py
|
||||
import typing_extensions
|
||||
|
||||
reveal_type(typing_extensions.__class__) # revealed: Literal[ModuleType]
|
||||
reveal_type(type(typing_extensions)) # revealed: Literal[ModuleType]
|
||||
|
||||
a = 42
|
||||
reveal_type(a.__class__) # revealed: Literal[int]
|
||||
reveal_type(type(a)) # revealed: Literal[int]
|
||||
|
||||
b = "42"
|
||||
reveal_type(b.__class__) # revealed: Literal[str]
|
||||
@@ -1320,13 +906,8 @@ reveal_type(e.__class__) # revealed: Literal[tuple]
|
||||
|
||||
def f(a: int, b: typing_extensions.LiteralString, c: int | str, d: type[str]):
|
||||
reveal_type(a.__class__) # revealed: type[int]
|
||||
reveal_type(type(a)) # revealed: type[int]
|
||||
|
||||
reveal_type(b.__class__) # revealed: Literal[str]
|
||||
reveal_type(type(b)) # revealed: Literal[str]
|
||||
|
||||
reveal_type(c.__class__) # revealed: type[int] | type[str]
|
||||
reveal_type(type(c)) # revealed: type[int] | type[str]
|
||||
|
||||
# `type[type]`, a.k.a., either the class `type` or some subclass of `type`.
|
||||
# It would be incorrect to infer `Literal[type]` here,
|
||||
@@ -1343,8 +924,6 @@ reveal_type(Foo.__class__) # revealed: Literal[type]
|
||||
|
||||
## Module attributes
|
||||
|
||||
### Basic
|
||||
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
@@ -1379,7 +958,7 @@ for mod.global_symbol in IntIterable():
|
||||
pass
|
||||
```
|
||||
|
||||
### Nested module attributes
|
||||
## Nested attributes
|
||||
|
||||
`outer/__init__.py`:
|
||||
|
||||
@@ -1453,8 +1032,8 @@ Most attribute accesses on bool-literal types are delegated to `builtins.bool`,
|
||||
bools are instances of that class:
|
||||
|
||||
```py
|
||||
reveal_type(True.__and__) # revealed: <bound method `__and__` of `Literal[True]`>
|
||||
reveal_type(False.__or__) # revealed: <bound method `__or__` of `Literal[False]`>
|
||||
reveal_type(True.__and__) # revealed: @Todo(decorated method)
|
||||
reveal_type(False.__or__) # revealed: @Todo(decorated method)
|
||||
```
|
||||
|
||||
Some attributes are special-cased, however:
|
||||
@@ -1557,20 +1136,6 @@ class C:
|
||||
reveal_type(C().x) # revealed: Unknown
|
||||
```
|
||||
|
||||
### Accessing attributes on `Never`
|
||||
|
||||
Arbitrary attributes can be accessed on `Never` without emitting any errors:
|
||||
|
||||
```py
|
||||
from typing_extensions import Never
|
||||
|
||||
def f(never: Never):
|
||||
reveal_type(never.arbitrary_attribute) # revealed: Never
|
||||
|
||||
# Assigning `Never` to an attribute on `Never` is also allowed:
|
||||
never.another_attribute = never
|
||||
```
|
||||
|
||||
### Builtin types attributes
|
||||
|
||||
This test can probably be removed eventually, but we currently include it because we do not yet
|
||||
@@ -1610,7 +1175,6 @@ reveal_type(C.a_none) # revealed: None
|
||||
Some of the tests in the *Class and instance variables* section draw inspiration from
|
||||
[pyright's documentation] on this topic.
|
||||
|
||||
[descriptor protocol tests]: descriptor_protocol.md
|
||||
[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
|
||||
|
||||
@@ -259,17 +259,11 @@ class A:
|
||||
class B:
|
||||
__add__ = A()
|
||||
|
||||
reveal_type(B() + B()) # revealed: Unknown | int
|
||||
```
|
||||
|
||||
Note that we union with `Unknown` here because `__add__` is not declared. We do infer just `int` if
|
||||
the callable is declared:
|
||||
|
||||
```py
|
||||
class B2:
|
||||
__add__: A = A()
|
||||
|
||||
reveal_type(B2() + B2()) # revealed: int
|
||||
# TODO: this could be `int` if we declare `B.__add__` using a `Callable` type
|
||||
# TODO: Should not be an error: `A` instance is not a method descriptor, don't prepend `self` arg.
|
||||
# Revealed type should be `Unknown | int`.
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `B` and `B`"
|
||||
reveal_type(B() + B()) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Integration test: numbers from typeshed
|
||||
@@ -312,7 +306,7 @@ reveal_type(1 + A()) # revealed: A
|
||||
reveal_type(A() + "foo") # revealed: A
|
||||
# TODO should be `A` since `str.__add__` doesn't support `A` instances
|
||||
# TODO overloads
|
||||
reveal_type("foo" + A()) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type("foo" + A()) # revealed: @Todo(return type)
|
||||
|
||||
reveal_type(A() + b"foo") # revealed: A
|
||||
# TODO should be `A` since `bytes.__add__` doesn't support `A` instances
|
||||
@@ -320,7 +314,7 @@ reveal_type(b"foo" + A()) # revealed: bytes
|
||||
|
||||
reveal_type(A() + ()) # revealed: A
|
||||
# TODO this should be `A`, since `tuple.__add__` doesn't support `A` instances
|
||||
reveal_type(() + A()) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(() + A()) # revealed: @Todo(return type)
|
||||
|
||||
literal_string_instance = "foo" * 1_000_000_000
|
||||
# the test is not testing what it's meant to be testing if this isn't a `LiteralString`:
|
||||
@@ -329,7 +323,7 @@ reveal_type(literal_string_instance) # revealed: LiteralString
|
||||
reveal_type(A() + literal_string_instance) # revealed: A
|
||||
# TODO should be `A` since `str.__add__` doesn't support `A` instances
|
||||
# TODO overloads
|
||||
reveal_type(literal_string_instance + A()) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(literal_string_instance + A()) # revealed: @Todo(return type)
|
||||
```
|
||||
|
||||
## Operations involving instances of classes inheriting from `Any`
|
||||
@@ -357,20 +351,6 @@ class Y(Foo): ...
|
||||
reveal_type(X() + Y()) # revealed: int
|
||||
```
|
||||
|
||||
## Operations involving types with invalid `__bool__` methods
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
a = NotBoolable()
|
||||
|
||||
# error: [unsupported-bool-conversion]
|
||||
10 and a and True
|
||||
```
|
||||
|
||||
## Unsupported
|
||||
|
||||
### Dunder as instance attribute
|
||||
@@ -406,12 +386,10 @@ A left-hand dunder method doesn't apply for the right-hand operand, or vice vers
|
||||
|
||||
```py
|
||||
class A:
|
||||
def __add__(self, other) -> int:
|
||||
return 1
|
||||
def __add__(self, other) -> int: ...
|
||||
|
||||
class B:
|
||||
def __radd__(self, other) -> int:
|
||||
return 1
|
||||
def __radd__(self, other) -> int: ...
|
||||
|
||||
class C: ...
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ reveal_type(-3 // 3) # revealed: Literal[-1]
|
||||
reveal_type(-3 / 3) # revealed: float
|
||||
reveal_type(5 % 3) # revealed: Literal[2]
|
||||
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `Literal[2]` and `Literal["f"]`"
|
||||
# TODO: Should emit `unsupported-operator` but we don't understand the bases of `str`, so we think
|
||||
# it inherits `Unknown`, so we think `str.__radd__` is `Unknown` instead of nonexistent.
|
||||
reveal_type(2 + "f") # revealed: Unknown
|
||||
|
||||
def lhs(x: int):
|
||||
@@ -50,9 +51,9 @@ reveal_type(1 ** (largest_u32 + 1)) # revealed: int
|
||||
reveal_type(2**largest_u32) # revealed: int
|
||||
|
||||
def variable(x: int):
|
||||
reveal_type(x**2) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(2**x) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(x**x) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(x**2) # revealed: @Todo(return type)
|
||||
reveal_type(2**x) # revealed: @Todo(return type)
|
||||
reveal_type(x**x) # revealed: @Todo(return type)
|
||||
```
|
||||
|
||||
## Division by Zero
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Binary operations on union types
|
||||
|
||||
Binary operations on union types are only available if they are supported for all possible
|
||||
combinations of types:
|
||||
|
||||
```py
|
||||
def f1(i: int, u: int | None):
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `int` and `int | None`"
|
||||
reveal_type(i + u) # revealed: Unknown
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `int | None` and `int`"
|
||||
reveal_type(u + i) # revealed: Unknown
|
||||
```
|
||||
|
||||
`int` can be added to `int`, and `str` can be added to `str`, but expressions of type `int | str`
|
||||
cannot be added, because that would require addition of `int` and `str` or vice versa:
|
||||
|
||||
```py
|
||||
def f2(i: int, s: str, int_or_str: int | str):
|
||||
i + i
|
||||
s + s
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `int | str` and `int | str`"
|
||||
reveal_type(int_or_str + int_or_str) # revealed: Unknown
|
||||
```
|
||||
|
||||
However, if an operation is supported for all possible combinations, the result will be a union of
|
||||
the possible outcomes:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def f3(two_or_three: Literal[2, 3], a_or_b: Literal["a", "b"]):
|
||||
reveal_type(two_or_three + two_or_three) # revealed: Literal[4, 5, 6]
|
||||
reveal_type(two_or_three**two_or_three) # revealed: Literal[4, 8, 9, 27]
|
||||
|
||||
reveal_type(a_or_b + a_or_b) # revealed: Literal["aa", "ab", "ba", "bb"]
|
||||
|
||||
reveal_type(two_or_three * a_or_b) # revealed: Literal["aa", "bb", "aaa", "bbb"]
|
||||
```
|
||||
|
||||
We treat a type annotation of `float` as a union of `int` and `float`, so union handling is relevant
|
||||
here:
|
||||
|
||||
```py
|
||||
def f4(x: float, y: float):
|
||||
reveal_type(x + y) # revealed: int | float
|
||||
reveal_type(x - y) # revealed: int | float
|
||||
reveal_type(x * y) # revealed: int | float
|
||||
reveal_type(x / y) # revealed: int | float
|
||||
reveal_type(x // y) # revealed: int | float
|
||||
reveal_type(x % y) # revealed: int | float
|
||||
```
|
||||
@@ -69,8 +69,7 @@ without raising an error.
|
||||
from typing import Any
|
||||
|
||||
def any() -> Any: ...
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
a: int
|
||||
b: str
|
||||
@@ -127,8 +126,7 @@ inferred types:
|
||||
from typing import Any
|
||||
|
||||
def any() -> Any: ...
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
a = 1
|
||||
b = 2
|
||||
@@ -166,8 +164,7 @@ error for both `a` and `b`:
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag():
|
||||
a: Any = 1
|
||||
@@ -197,8 +194,7 @@ seems inconsistent when compared to the case just above.
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag():
|
||||
a: int
|
||||
@@ -252,8 +248,7 @@ inconsistent when compared to the "possibly-undeclared-and-possibly-unbound" cas
|
||||
`mod.py`:
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
if flag:
|
||||
a = 1
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Calling builtins
|
||||
|
||||
## `bool` with incorrect arguments
|
||||
|
||||
```py
|
||||
class NotBool:
|
||||
__bool__ = None
|
||||
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments to class `bool`: expected 1, got 2"
|
||||
bool(1, 2)
|
||||
|
||||
# TODO: We should emit an `unsupported-bool-conversion` error here because the argument doesn't implement `__bool__` correctly.
|
||||
bool(NotBool())
|
||||
```
|
||||
|
||||
## Calls to `type()`
|
||||
|
||||
A single-argument call to `type()` returns an object that has the argument's meta-type. (This is
|
||||
tested more extensively in `crates/red_knot_python_semantic/resources/mdtest/attributes.md`,
|
||||
alongside the tests for the `__class__` attribute.)
|
||||
|
||||
```py
|
||||
reveal_type(type(1)) # revealed: Literal[int]
|
||||
```
|
||||
|
||||
But a three-argument call to type creates a dynamic instance of the `type` class:
|
||||
|
||||
```py
|
||||
reveal_type(type("Foo", (), {})) # revealed: type
|
||||
```
|
||||
|
||||
Other numbers of arguments are invalid
|
||||
|
||||
```py
|
||||
# error: [no-matching-overload] "No overload of class `type` matches arguments"
|
||||
type("Foo", ())
|
||||
|
||||
# error: [no-matching-overload] "No overload of class `type` matches arguments"
|
||||
type("Foo", (), {}, weird_other_arg=42)
|
||||
```
|
||||
@@ -25,8 +25,7 @@ reveal_type(b) # revealed: Unknown
|
||||
def _(flag: bool):
|
||||
class PossiblyNotCallable:
|
||||
if flag:
|
||||
def __call__(self) -> int:
|
||||
return 1
|
||||
def __call__(self) -> int: ...
|
||||
|
||||
a = PossiblyNotCallable()
|
||||
result = a() # error: "Object of type `PossiblyNotCallable` is not callable (possibly unbound `__call__` method)"
|
||||
@@ -39,8 +38,7 @@ def _(flag: bool):
|
||||
def _(flag: bool):
|
||||
if flag:
|
||||
class PossiblyUnbound:
|
||||
def __call__(self) -> int:
|
||||
return 1
|
||||
def __call__(self) -> int: ...
|
||||
|
||||
# error: [possibly-unresolved-reference]
|
||||
a = PossiblyUnbound()
|
||||
@@ -66,8 +64,7 @@ def _(flag: bool):
|
||||
if flag:
|
||||
__call__ = 1
|
||||
else:
|
||||
def __call__(self) -> int:
|
||||
return 1
|
||||
def __call__(self) -> int: ...
|
||||
|
||||
a = NonCallable()
|
||||
# error: [call-non-callable] "Object of type `Literal[1]` is not callable"
|
||||
@@ -85,7 +82,7 @@ class C:
|
||||
|
||||
c = C()
|
||||
|
||||
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of bound method `__call__`; expected type `int`"
|
||||
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of function `__call__`; expected type `int`"
|
||||
reveal_type(c("foo")) # revealed: int
|
||||
```
|
||||
|
||||
@@ -99,7 +96,7 @@ class C:
|
||||
|
||||
c = C()
|
||||
|
||||
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of bound method `__call__`; expected type `int`"
|
||||
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of function `__call__`; expected type `int`"
|
||||
reveal_type(c()) # revealed: int
|
||||
```
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# Dunder calls
|
||||
|
||||
## Introduction
|
||||
|
||||
This test suite explains and documents how dunder methods are looked up and called. Throughout the
|
||||
document, we use `__getitem__` as an example, but the same principles apply to other dunder methods.
|
||||
|
||||
Dunder methods are implicitly called when using certain syntax. For example, the index operator
|
||||
`obj[key]` calls the `__getitem__` method under the hood. Exactly *how* a dunder method is looked up
|
||||
and called works slightly different from regular methods. Dunder methods are not looked up on `obj`
|
||||
directly, but rather on `type(obj)`. But in many ways, they still *act* as if they were called on
|
||||
`obj` directly. If the `__getitem__` member of `type(obj)` is a descriptor, it is called with `obj`
|
||||
as the `instance` argument to `__get__`. A desugared version of `obj[key]` is roughly equivalent to
|
||||
`getitem_desugared(obj, key)` as defined below:
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def find_name_in_mro(typ: type, name: str) -> Any:
|
||||
# See implementation in https://docs.python.org/3/howto/descriptor.html#invocation-from-an-instance
|
||||
pass
|
||||
|
||||
def getitem_desugared(obj: object, key: object) -> object:
|
||||
getitem_callable = find_name_in_mro(type(obj), "__getitem__")
|
||||
if hasattr(getitem_callable, "__get__"):
|
||||
getitem_callable = getitem_callable.__get__(obj, type(obj))
|
||||
|
||||
return getitem_callable(key)
|
||||
```
|
||||
|
||||
In the following tests, we demonstrate that we implement this behavior correctly.
|
||||
|
||||
## Operating on class objects
|
||||
|
||||
If we invoke a dunder method on a class, it is looked up on the *meta* class, since any class is an
|
||||
instance of its metaclass:
|
||||
|
||||
```py
|
||||
class Meta(type):
|
||||
def __getitem__(cls, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
class DunderOnMetaclass(metaclass=Meta):
|
||||
pass
|
||||
|
||||
reveal_type(DunderOnMetaclass[0]) # revealed: str
|
||||
```
|
||||
|
||||
If the dunder method is only present on the class itself, it will not be called:
|
||||
|
||||
```py
|
||||
class ClassWithNormalDunder:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
# error: [non-subscriptable]
|
||||
ClassWithNormalDunder[0]
|
||||
```
|
||||
|
||||
## Operating on instances
|
||||
|
||||
When invoking a dunder method on an instance of a class, it is looked up on the class:
|
||||
|
||||
```py
|
||||
class ClassWithNormalDunder:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
class_with_normal_dunder = ClassWithNormalDunder()
|
||||
|
||||
reveal_type(class_with_normal_dunder[0]) # revealed: str
|
||||
```
|
||||
|
||||
Which can be demonstrated by trying to attach a dunder method to an instance, which will not work:
|
||||
|
||||
```py
|
||||
def external_getitem(instance, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
class ThisFails:
|
||||
def __init__(self):
|
||||
self.__getitem__ = external_getitem
|
||||
|
||||
this_fails = ThisFails()
|
||||
|
||||
# error: [non-subscriptable] "Cannot subscript object of type `ThisFails` with no `__getitem__` method"
|
||||
reveal_type(this_fails[0]) # revealed: Unknown
|
||||
```
|
||||
|
||||
However, the attached dunder method *can* be called if accessed directly:
|
||||
|
||||
```py
|
||||
reveal_type(this_fails.__getitem__(this_fails, 0)) # revealed: Unknown | str
|
||||
```
|
||||
|
||||
The instance-level method is also not called when the class-level method is present:
|
||||
|
||||
```py
|
||||
def external_getitem1(instance, key) -> str:
|
||||
return "a"
|
||||
|
||||
def external_getitem2(key) -> int:
|
||||
return 1
|
||||
|
||||
def _(flag: bool):
|
||||
class ThisFails:
|
||||
if flag:
|
||||
__getitem__ = external_getitem1
|
||||
|
||||
def __init__(self):
|
||||
self.__getitem__ = external_getitem2
|
||||
|
||||
this_fails = ThisFails()
|
||||
|
||||
# error: [call-possibly-unbound-method]
|
||||
reveal_type(this_fails[0]) # revealed: Unknown | str
|
||||
```
|
||||
|
||||
## When the dunder is not a method
|
||||
|
||||
A dunder can also be a non-method callable:
|
||||
|
||||
```py
|
||||
class SomeCallable:
|
||||
def __call__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
class ClassWithNonMethodDunder:
|
||||
__getitem__: SomeCallable = SomeCallable()
|
||||
|
||||
class_with_callable_dunder = ClassWithNonMethodDunder()
|
||||
|
||||
reveal_type(class_with_callable_dunder[0]) # revealed: str
|
||||
```
|
||||
|
||||
## Dunders are looked up using the descriptor protocol
|
||||
|
||||
Here, we demonstrate that the descriptor protocol is invoked when looking up a dunder method. Note
|
||||
that the `instance` argument is on object of type `ClassWithDescriptorDunder`:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
class SomeCallable:
|
||||
def __call__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
class Descriptor:
|
||||
def __get__(self, instance: ClassWithDescriptorDunder, owner: type[ClassWithDescriptorDunder]) -> SomeCallable:
|
||||
return SomeCallable()
|
||||
|
||||
class ClassWithDescriptorDunder:
|
||||
__getitem__: Descriptor = Descriptor()
|
||||
|
||||
class_with_descriptor_dunder = ClassWithDescriptorDunder()
|
||||
|
||||
reveal_type(class_with_descriptor_dunder[0]) # revealed: str
|
||||
```
|
||||
|
||||
## Dunders can not be overwritten on instances
|
||||
|
||||
If we attempt to overwrite a dunder method on an instance, it does not affect the behavior of
|
||||
implicit dunder calls:
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
def f(self):
|
||||
# TODO: This should emit an `invalid-assignment` diagnostic once we understand the type of `self`
|
||||
self.__getitem__ = None
|
||||
|
||||
# This is still fine, and simply calls the `__getitem__` method on the class
|
||||
reveal_type(C()[0]) # revealed: str
|
||||
```
|
||||
|
||||
## Calling a union of dunder methods
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class C:
|
||||
if flag:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
else:
|
||||
def __getitem__(self, key: int) -> bytes:
|
||||
return bytes()
|
||||
|
||||
c = C()
|
||||
reveal_type(c[0]) # revealed: str | bytes
|
||||
|
||||
if flag:
|
||||
class D:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
else:
|
||||
class D:
|
||||
def __getitem__(self, key: int) -> bytes:
|
||||
return bytes()
|
||||
|
||||
d = D()
|
||||
reveal_type(d[0]) # revealed: str | bytes
|
||||
```
|
||||
|
||||
## Calling a possibly-unbound dunder method
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class C:
|
||||
if flag:
|
||||
def __getitem__(self, key: int) -> str:
|
||||
return str(key)
|
||||
|
||||
c = C()
|
||||
# error: [call-possibly-unbound-method]
|
||||
reveal_type(c[0]) # revealed: str
|
||||
```
|
||||
@@ -37,8 +37,6 @@ def foo() -> int:
|
||||
return 42
|
||||
|
||||
def decorator(func) -> Callable[[], int]:
|
||||
# TODO: no error
|
||||
# error: [invalid-return-type]
|
||||
return foo
|
||||
|
||||
@decorator
|
||||
@@ -46,7 +44,7 @@ def bar() -> str:
|
||||
return "bar"
|
||||
|
||||
# TODO: should reveal `int`, as the decorator replaces `bar` with `foo`
|
||||
reveal_type(bar()) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(bar()) # revealed: @Todo(return type)
|
||||
```
|
||||
|
||||
## Invalid callable
|
||||
|
||||
@@ -12,7 +12,7 @@ import inspect
|
||||
|
||||
class Descriptor:
|
||||
def __get__(self, instance, owner) -> str:
|
||||
return "a"
|
||||
return 1
|
||||
|
||||
class C:
|
||||
normal: int = 1
|
||||
@@ -59,7 +59,7 @@ import sys
|
||||
reveal_type(inspect.getattr_static(sys, "platform")) # revealed: LiteralString
|
||||
reveal_type(inspect.getattr_static(inspect, "getattr_static")) # revealed: Literal[getattr_static]
|
||||
|
||||
reveal_type(inspect.getattr_static(1, "real")) # revealed: Literal[real]
|
||||
reveal_type(inspect.getattr_static(1, "real")) # revealed: Literal[1]
|
||||
```
|
||||
|
||||
(Implicit) instance attributes can also be accessed through `inspect.getattr_static`:
|
||||
@@ -72,23 +72,6 @@ class D:
|
||||
reveal_type(inspect.getattr_static(D(), "instance_attr")) # revealed: int
|
||||
```
|
||||
|
||||
And attributes on metaclasses can be accessed when probing the class:
|
||||
|
||||
```py
|
||||
class Meta(type):
|
||||
attr: int = 1
|
||||
|
||||
class E(metaclass=Meta): ...
|
||||
|
||||
reveal_type(inspect.getattr_static(E, "attr")) # revealed: int
|
||||
```
|
||||
|
||||
Metaclass attributes can not be added when probing an instance of the class:
|
||||
|
||||
```py
|
||||
reveal_type(inspect.getattr_static(E(), "attr", "non_existent")) # revealed: Literal["non_existent"]
|
||||
```
|
||||
|
||||
## Error cases
|
||||
|
||||
We can only infer precise types if the attribute is a literal string. In all other cases, we fall
|
||||
|
||||
@@ -235,198 +235,24 @@ method_wrapper(C(), None)
|
||||
method_wrapper(None, C)
|
||||
|
||||
# Passing `None` without an `owner` argument is an
|
||||
# error: [no-matching-overload] "No overload of method wrapper `__get__` of function `f` matches arguments"
|
||||
# error: [missing-argument] "No argument provided for required parameter `owner`"
|
||||
method_wrapper(None)
|
||||
|
||||
# Passing something that is not assignable to `type` as the `owner` argument is an
|
||||
# error: [no-matching-overload] "No overload of method wrapper `__get__` of function `f` matches arguments"
|
||||
# error: [invalid-argument-type] "Object of type `Literal[1]` cannot be assigned to parameter 2 (`owner`); expected type `type`"
|
||||
method_wrapper(None, 1)
|
||||
|
||||
# Passing `None` as the `owner` argument when `instance` is `None` is an
|
||||
# error: [no-matching-overload] "No overload of method wrapper `__get__` of function `f` matches arguments"
|
||||
# error: [invalid-argument-type] "Object of type `None` cannot be assigned to parameter 2 (`owner`); expected type `type`"
|
||||
method_wrapper(None, None)
|
||||
|
||||
# Calling `__get__` without any arguments is an
|
||||
# error: [no-matching-overload] "No overload of method wrapper `__get__` of function `f` matches arguments"
|
||||
# error: [missing-argument] "No argument provided for required parameter `instance`"
|
||||
method_wrapper()
|
||||
|
||||
# Calling `__get__` with too many positional arguments is an
|
||||
# error: [no-matching-overload] "No overload of method wrapper `__get__` of function `f` matches arguments"
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments: expected 2, got 3"
|
||||
method_wrapper(C(), C, "one too many")
|
||||
```
|
||||
|
||||
## Fallback to metaclass
|
||||
|
||||
When a method is accessed on a class object, it is looked up on the metaclass if it is not found on
|
||||
the class itself. This also creates a bound method that is bound to the class object itself:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
class Meta(type):
|
||||
def f(cls, arg: int) -> str:
|
||||
return "a"
|
||||
|
||||
class C(metaclass=Meta):
|
||||
pass
|
||||
|
||||
reveal_type(C.f) # revealed: <bound method `f` of `Literal[C]`>
|
||||
reveal_type(C.f(1)) # revealed: str
|
||||
```
|
||||
|
||||
The method `f` can not be accessed from an instance of the class:
|
||||
|
||||
```py
|
||||
# error: [unresolved-attribute] "Type `C` has no attribute `f`"
|
||||
C().f
|
||||
```
|
||||
|
||||
A metaclass function can be shadowed by a method on the class:
|
||||
|
||||
```py
|
||||
from typing import Any, Literal
|
||||
|
||||
class D(metaclass=Meta):
|
||||
def f(arg: int) -> Literal["a"]:
|
||||
return "a"
|
||||
|
||||
reveal_type(D.f(1)) # revealed: Literal["a"]
|
||||
```
|
||||
|
||||
If the class method is possibly unbound, we union the return types:
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
|
||||
class E(metaclass=Meta):
|
||||
if flag():
|
||||
def f(arg: int) -> Any:
|
||||
return "a"
|
||||
|
||||
reveal_type(E.f(1)) # revealed: str | Any
|
||||
```
|
||||
|
||||
## `@classmethod`
|
||||
|
||||
### Basic
|
||||
|
||||
When a `@classmethod` attribute is accessed, it returns a bound method object, even when accessed on
|
||||
the class object itself:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
class C:
|
||||
@classmethod
|
||||
def f(cls: type[C], x: int) -> str:
|
||||
return "a"
|
||||
|
||||
reveal_type(C.f) # revealed: <bound method `f` of `Literal[C]`>
|
||||
reveal_type(C().f) # revealed: <bound method `f` of `type[C]`>
|
||||
```
|
||||
|
||||
The `cls` method argument is then implicitly passed as the first argument when calling the method:
|
||||
|
||||
```py
|
||||
reveal_type(C.f(1)) # revealed: str
|
||||
reveal_type(C().f(1)) # revealed: str
|
||||
```
|
||||
|
||||
When the class method is called incorrectly, we detect it:
|
||||
|
||||
```py
|
||||
C.f("incorrect") # error: [invalid-argument-type]
|
||||
C.f() # error: [missing-argument]
|
||||
C.f(1, 2) # error: [too-many-positional-arguments]
|
||||
```
|
||||
|
||||
If the `cls` parameter is wrongly annotated, we emit an error at the call site:
|
||||
|
||||
```py
|
||||
class D:
|
||||
@classmethod
|
||||
def f(cls: D):
|
||||
# This function is wrongly annotated, it should be `type[D]` instead of `D`
|
||||
pass
|
||||
|
||||
# error: [invalid-argument-type] "Object of type `Literal[D]` cannot be assigned to parameter 1 (`cls`) of bound method `f`; expected type `D`"
|
||||
D.f()
|
||||
```
|
||||
|
||||
When a class method is accessed on a derived class, it is bound to that derived class:
|
||||
|
||||
```py
|
||||
class Derived(C):
|
||||
pass
|
||||
|
||||
reveal_type(Derived.f) # revealed: <bound method `f` of `Literal[Derived]`>
|
||||
reveal_type(Derived().f) # revealed: <bound method `f` of `type[Derived]`>
|
||||
|
||||
reveal_type(Derived.f(1)) # revealed: str
|
||||
reveal_type(Derived().f(1)) # revealed: str
|
||||
```
|
||||
|
||||
### Accessing the classmethod as a static member
|
||||
|
||||
Accessing a `@classmethod`-decorated function at runtime returns a `classmethod` object. We
|
||||
currently don't model this explicitly:
|
||||
|
||||
```py
|
||||
from inspect import getattr_static
|
||||
|
||||
class C:
|
||||
@classmethod
|
||||
def f(cls): ...
|
||||
|
||||
reveal_type(getattr_static(C, "f")) # revealed: Literal[f]
|
||||
reveal_type(getattr_static(C, "f").__get__) # revealed: <method-wrapper `__get__` of `f`>
|
||||
```
|
||||
|
||||
But we correctly model how the `classmethod` descriptor works:
|
||||
|
||||
```py
|
||||
reveal_type(getattr_static(C, "f").__get__(None, C)) # revealed: <bound method `f` of `Literal[C]`>
|
||||
reveal_type(getattr_static(C, "f").__get__(C(), C)) # revealed: <bound method `f` of `Literal[C]`>
|
||||
reveal_type(getattr_static(C, "f").__get__(C())) # revealed: <bound method `f` of `type[C]`>
|
||||
```
|
||||
|
||||
The `owner` argument takes precedence over the `instance` argument:
|
||||
|
||||
```py
|
||||
reveal_type(getattr_static(C, "f").__get__("dummy", C)) # revealed: <bound method `f` of `Literal[C]`>
|
||||
```
|
||||
|
||||
### Classmethods mixed with other decorators
|
||||
|
||||
When a `@classmethod` is additionally decorated with another decorator, it is still treated as a
|
||||
class method:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
def does_nothing[T](f: T) -> T:
|
||||
return f
|
||||
|
||||
class C:
|
||||
@classmethod
|
||||
@does_nothing
|
||||
def f1(cls: type[C], x: int) -> str:
|
||||
return "a"
|
||||
|
||||
@does_nothing
|
||||
@classmethod
|
||||
def f2(cls: type[C], x: int) -> str:
|
||||
return "a"
|
||||
|
||||
# TODO: We do not support decorators yet (only limited special cases). Eventually,
|
||||
# these should all return `str`:
|
||||
|
||||
reveal_type(C.f1(1)) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(C().f1(1)) # revealed: @Todo(return type of decorated function)
|
||||
|
||||
reveal_type(C.f2(1)) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(C().f2(1)) # revealed: @Todo(return type of decorated function)
|
||||
```
|
||||
|
||||
[functions and methods]: https://docs.python.org/3/howto/descriptor.html#functions-and-methods
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Never is callable
|
||||
|
||||
The type `Never` is callable with an arbitrary set of arguments. The result is always `Never`.
|
||||
|
||||
```py
|
||||
from typing_extensions import Never
|
||||
|
||||
def f(never: Never):
|
||||
reveal_type(never()) # revealed: Never
|
||||
reveal_type(never(1)) # revealed: Never
|
||||
reveal_type(never(1, "a", never, x=None)) # revealed: Never
|
||||
```
|
||||
@@ -1,50 +0,0 @@
|
||||
# Call `type[...]`
|
||||
|
||||
## Single class
|
||||
|
||||
### Trivial constructor
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
|
||||
def _(subclass_of_c: type[C]):
|
||||
reveal_type(subclass_of_c()) # revealed: C
|
||||
```
|
||||
|
||||
### Non-trivial constructor
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, x: int): ...
|
||||
|
||||
def _(subclass_of_c: type[C]):
|
||||
reveal_type(subclass_of_c(1)) # revealed: C
|
||||
|
||||
# TODO: Those should all be errors
|
||||
reveal_type(subclass_of_c("a")) # revealed: C
|
||||
reveal_type(subclass_of_c()) # revealed: C
|
||||
reveal_type(subclass_of_c(1, 2)) # revealed: C
|
||||
```
|
||||
|
||||
## Dynamic base
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
from knot_extensions import Unknown
|
||||
|
||||
def _(subclass_of_any: type[Any], subclass_of_unknown: type[Unknown]):
|
||||
reveal_type(subclass_of_any()) # revealed: Any
|
||||
reveal_type(subclass_of_any("any", "args", 1, 2)) # revealed: Any
|
||||
reveal_type(subclass_of_unknown()) # revealed: Unknown
|
||||
reveal_type(subclass_of_unknown("any", "args", 1, 2)) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Unions of classes
|
||||
|
||||
```py
|
||||
class A: ...
|
||||
class B: ...
|
||||
|
||||
def _(subclass_of_ab: type[A | B]):
|
||||
reveal_type(subclass_of_ab()) # revealed: A | B
|
||||
```
|
||||
@@ -56,7 +56,6 @@ def _(flag: bool, flag2: bool):
|
||||
else:
|
||||
def f() -> int:
|
||||
return 1
|
||||
# TODO we should mention all non-callable elements of the union
|
||||
# error: [call-non-callable] "Object of type `Literal[1]` is not callable"
|
||||
# revealed: int | Unknown
|
||||
reveal_type(f())
|
||||
@@ -82,12 +81,8 @@ def _(flag: bool):
|
||||
Calling a union where the arguments don't match the signature of all variants.
|
||||
|
||||
```py
|
||||
def f1(a: int) -> int:
|
||||
return a
|
||||
|
||||
def f2(a: str) -> str:
|
||||
return a
|
||||
|
||||
def f1(a: int) -> int: ...
|
||||
def f2(a: str) -> str: ...
|
||||
def _(flag: bool):
|
||||
if flag:
|
||||
f = f1
|
||||
@@ -113,38 +108,3 @@ def _(flag: bool):
|
||||
x = f(3)
|
||||
reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Union of binding errors
|
||||
|
||||
```py
|
||||
def f1(): ...
|
||||
def f2(): ...
|
||||
def _(flag: bool):
|
||||
if flag:
|
||||
f = f1
|
||||
else:
|
||||
f = f2
|
||||
|
||||
# TODO: we should show all errors from the union, not arbitrarily pick one union element
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments to function `f1`: expected 0, got 1"
|
||||
x = f(3)
|
||||
reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## One not-callable, one wrong argument
|
||||
|
||||
```py
|
||||
class C: ...
|
||||
|
||||
def f1(): ...
|
||||
def _(flag: bool):
|
||||
if flag:
|
||||
f = f1
|
||||
else:
|
||||
f = C()
|
||||
|
||||
# TODO: we should either show all union errors here, or prioritize the not-callable error
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments to function `f1`: expected 0, got 1"
|
||||
x = f(3)
|
||||
reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
@@ -160,45 +160,3 @@ reveal_type(42 in A()) # revealed: bool
|
||||
# error: [unsupported-operator] "Operator `in` is not supported for types `str` and `A`, in comparing `Literal["hello"]` with `A`"
|
||||
reveal_type("hello" in A()) # revealed: bool
|
||||
```
|
||||
|
||||
## Return type that doesn't implement `__bool__` correctly
|
||||
|
||||
`in` and `not in` operations will fail at runtime if the object on the right-hand side of the
|
||||
operation has a `__contains__` method that returns a type which is not convertible to `bool`. This
|
||||
is because of the way these operations are handled by the Python interpreter at runtime. If we
|
||||
assume that `y` is an object that has a `__contains__` method, the Python expression `x in y`
|
||||
desugars to a `contains(y, x)` call, where `contains` looks something like this:
|
||||
|
||||
```ignore
|
||||
def contains(y, x):
|
||||
return bool(type(y).__contains__(y, x))
|
||||
```
|
||||
|
||||
where the `bool()` conversion itself implicitly calls `__bool__` under the hood.
|
||||
|
||||
TODO: Ideally the message would explain to the user what's wrong. E.g,
|
||||
|
||||
```ignore
|
||||
error: [operator] cannot use `in` operator on object of type `WithContains`
|
||||
note: This is because the `in` operator implicitly calls `WithContains.__contains__`, but `WithContains.__contains__` is invalidly defined
|
||||
note: `WithContains.__contains__` is invalidly defined because it returns an instance of `NotBoolable`, which cannot be evaluated in a boolean context
|
||||
note: `NotBoolable` cannot be evaluated in a boolean context because its `__bool__` attribute is not callable
|
||||
```
|
||||
|
||||
It may also be more appropriate to use `unsupported-operator` as the error code.
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
class WithContains:
|
||||
def __contains__(self, item) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
# error: [unsupported-bool-conversion]
|
||||
10 in WithContains()
|
||||
# error: [unsupported-bool-conversion]
|
||||
10 not in WithContains()
|
||||
```
|
||||
|
||||
@@ -154,7 +154,7 @@ reveal_type(B() >= A()) # revealed: LeReturnType
|
||||
|
||||
class C:
|
||||
def __gt__(self, other: C) -> EqReturnType:
|
||||
return EqReturnType()
|
||||
return 42
|
||||
|
||||
def __ge__(self, other: C) -> NeReturnType:
|
||||
return NeReturnType()
|
||||
@@ -345,47 +345,3 @@ def f(x: bool, y: int):
|
||||
reveal_type(4.2 < x) # revealed: bool
|
||||
reveal_type(x < 4.2) # revealed: bool
|
||||
```
|
||||
|
||||
## Chained comparisons with objects that don't implement `__bool__` correctly
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
Python implicitly calls `bool` on the comparison result of preceding elements (but not for the last
|
||||
element) of a chained comparison.
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
class Comparable:
|
||||
def __lt__(self, item) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
def __gt__(self, item) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
# error: [unsupported-bool-conversion]
|
||||
10 < Comparable() < 20
|
||||
# error: [unsupported-bool-conversion]
|
||||
10 < Comparable() < Comparable()
|
||||
|
||||
Comparable() < Comparable() # fine
|
||||
```
|
||||
|
||||
## Callables as comparison dunders
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
class AlwaysTrue:
|
||||
def __call__(self, other: object) -> Literal[True]:
|
||||
return True
|
||||
|
||||
class A:
|
||||
__eq__: AlwaysTrue = AlwaysTrue()
|
||||
__lt__: AlwaysTrue = AlwaysTrue()
|
||||
|
||||
reveal_type(A() == A()) # revealed: Literal[True]
|
||||
reveal_type(A() < A()) # revealed: Literal[True]
|
||||
reveal_type(A() > A()) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
@@ -110,8 +110,7 @@ given operator:
|
||||
|
||||
```py
|
||||
class Container:
|
||||
def __contains__(self, x) -> bool:
|
||||
return False
|
||||
def __contains__(self, x) -> bool: ...
|
||||
|
||||
class NonContainer: ...
|
||||
|
||||
@@ -131,8 +130,7 @@ unsupported for the given operator:
|
||||
|
||||
```py
|
||||
class Container:
|
||||
def __contains__(self, x) -> bool:
|
||||
return False
|
||||
def __contains__(self, x) -> bool: ...
|
||||
|
||||
class NonContainer: ...
|
||||
|
||||
|
||||
@@ -22,19 +22,14 @@ Walking through examples:
|
||||
from __future__ import annotations
|
||||
|
||||
class A:
|
||||
def __lt__(self, other) -> A:
|
||||
return self
|
||||
|
||||
def __gt__(self, other) -> bool:
|
||||
return False
|
||||
def __lt__(self, other) -> A: ...
|
||||
def __gt__(self, other) -> bool: ...
|
||||
|
||||
class B:
|
||||
def __lt__(self, other) -> B:
|
||||
return self
|
||||
def __lt__(self, other) -> B: ...
|
||||
|
||||
class C:
|
||||
def __lt__(self, other) -> C:
|
||||
return self
|
||||
def __lt__(self, other) -> C: ...
|
||||
|
||||
x = A() < B() < C()
|
||||
reveal_type(x) # revealed: A & ~AlwaysTruthy | B
|
||||
|
||||
@@ -197,7 +197,7 @@ class LtReturnTypeOnB: ...
|
||||
|
||||
class B:
|
||||
def __lt__(self, o: B) -> LtReturnTypeOnB:
|
||||
return LtReturnTypeOnB()
|
||||
return set()
|
||||
|
||||
reveal_type((A(), B()) < (A(), B())) # revealed: LtReturnType | LtReturnTypeOnB | Literal[False]
|
||||
```
|
||||
@@ -334,61 +334,3 @@ reveal_type(a is not c) # revealed: Literal[True]
|
||||
For tuples like `tuple[int, ...]`, `tuple[Any, ...]`
|
||||
|
||||
// TODO
|
||||
|
||||
## Chained comparisons with elements that incorrectly implement `__bool__`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
For an operation `A() < A()` to succeed at runtime, the `A.__lt__` method does not necessarily need
|
||||
to return an object that is convertible to a `bool`. However, the return type _does_ need to be
|
||||
convertible to a `bool` for the operation `A() < A() < A()` (a _chained_ comparison) to succeed.
|
||||
This is because `A() < A() < A()` desugars to something like this, which involves several implicit
|
||||
conversions to `bool`:
|
||||
|
||||
```ignore
|
||||
def compute_chained_comparison():
|
||||
a1 = A()
|
||||
a2 = A()
|
||||
first_comparison = a1 < a2
|
||||
return first_comparison and (a2 < A())
|
||||
```
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 5
|
||||
|
||||
class Comparable:
|
||||
def __lt__(self, other) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
def __gt__(self, other) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
a = (1, Comparable())
|
||||
b = (1, Comparable())
|
||||
|
||||
# error: [unsupported-bool-conversion]
|
||||
a < b < b
|
||||
|
||||
a < b # fine
|
||||
```
|
||||
|
||||
## Equality with elements that incorrectly implement `__bool__`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
Python does not generally attempt to coerce the result of `==` and `!=` operations between two
|
||||
arbitrary objects to a `bool`, but a comparison of tuples will fail if the result of comparing any
|
||||
pair of elements at equivalent positions cannot be converted to a `bool`:
|
||||
|
||||
```py
|
||||
class A:
|
||||
def __eq__(self, other) -> NotBoolable:
|
||||
return NotBoolable()
|
||||
|
||||
class NotBoolable:
|
||||
__bool__ = None
|
||||
|
||||
# error: [unsupported-bool-conversion]
|
||||
(A(),) == (A(),)
|
||||
```
|
||||
|
||||
@@ -104,8 +104,7 @@ class Iterator:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
def __iter__(self) -> Iterator: ...
|
||||
|
||||
# This is fine:
|
||||
x = [*Iterable()]
|
||||
|
||||
@@ -35,13 +35,3 @@ def _(flag: bool):
|
||||
x = 1 if flag else None
|
||||
reveal_type(x) # revealed: Literal[1] | None
|
||||
```
|
||||
|
||||
## Condition with object that implements `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
3 if NotBoolable() else 4
|
||||
```
|
||||
|
||||
@@ -147,17 +147,3 @@ def _(flag: bool):
|
||||
|
||||
reveal_type(y) # revealed: Literal[0, 1]
|
||||
```
|
||||
|
||||
## Condition with object that implements `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
if NotBoolable():
|
||||
...
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
elif NotBoolable():
|
||||
...
|
||||
```
|
||||
|
||||
@@ -43,21 +43,3 @@ def _(target: int):
|
||||
|
||||
reveal_type(y) # revealed: Literal[2, 3, 4]
|
||||
```
|
||||
|
||||
## Guard with object that implements `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
def _(target: int, flag: NotBoolable):
|
||||
y = 1
|
||||
match target:
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
case 1 if flag:
|
||||
y = 2
|
||||
case 2:
|
||||
y = 3
|
||||
|
||||
reveal_type(y) # revealed: Literal[1, 2, 3]
|
||||
```
|
||||
|
||||
@@ -6,9 +6,7 @@ A descriptor is an attribute value that has one of the methods in the descriptor
|
||||
methods are `__get__()`, `__set__()`, and `__delete__()`. If any of those methods are defined for an
|
||||
attribute, it is said to be a descriptor.
|
||||
|
||||
## Basic properties
|
||||
|
||||
### Example
|
||||
## Basic example
|
||||
|
||||
An introductory example, modeled after a [simple example] in the primer on descriptors, involving a
|
||||
descriptor that returns a constant value:
|
||||
@@ -33,17 +31,21 @@ reveal_type(c.ten) # revealed: Literal[10]
|
||||
reveal_type(C.ten) # revealed: Literal[10]
|
||||
|
||||
# These are fine:
|
||||
c.ten = 10
|
||||
# TODO: This should not be an error
|
||||
c.ten = 10 # error: [invalid-assignment]
|
||||
C.ten = 10
|
||||
|
||||
# error: [invalid-assignment] "Object of type `Literal[11]` is not assignable to attribute `ten` of type `Literal[10]`"
|
||||
# TODO: This should be an error (as the wrong type is being implicitly passed to `Ten.__set__`),
|
||||
# but the error message is misleading.
|
||||
# error: [invalid-assignment] "Object of type `Literal[11]` is not assignable to attribute `ten` of type `Ten`"
|
||||
c.ten = 11
|
||||
|
||||
# TODO: same as above
|
||||
# error: [invalid-assignment] "Object of type `Literal[11]` is not assignable to attribute `ten` of type `Literal[10]`"
|
||||
C.ten = 11
|
||||
```
|
||||
|
||||
### Different types for `__get__` and `__set__`
|
||||
## Different types for `__get__` and `__set__`
|
||||
|
||||
The return type of `__get__` and the value type of `__set__` can be different:
|
||||
|
||||
@@ -65,25 +67,28 @@ c = C()
|
||||
|
||||
reveal_type(c.flexible_int) # revealed: int | None
|
||||
|
||||
# TODO: These should not be errors
|
||||
# error: [invalid-assignment]
|
||||
c.flexible_int = 42 # okay
|
||||
# TODO: This should not be an error
|
||||
# error: [invalid-assignment]
|
||||
c.flexible_int = "42" # also okay!
|
||||
|
||||
reveal_type(c.flexible_int) # revealed: int | None
|
||||
|
||||
# TODO: This should be an error
|
||||
# TODO: This should be an error, but the message needs to be improved.
|
||||
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `flexible_int` of type `FlexibleInt`"
|
||||
c.flexible_int = None # not okay
|
||||
|
||||
reveal_type(c.flexible_int) # revealed: int | None
|
||||
```
|
||||
|
||||
### Data and non-data descriptors
|
||||
## Data and non-data descriptors
|
||||
|
||||
Descriptors that define `__set__` or `__delete__` are called *data descriptors*. An example of a
|
||||
data descriptor is a `property` with a setter and/or a deleter. Descriptors that only define
|
||||
`__get__`, meanwhile, are called *non-data descriptors*. Examples include functions, `classmethod`
|
||||
or `staticmethod`.
|
||||
Descriptors that define `__set__` or `__delete__` are called *data descriptors*. An example\
|
||||
of a data descriptor is a `property` with a setter and/or a deleter.\
|
||||
Descriptors that only define `__get__`, meanwhile, are called *non-data descriptors*. Examples
|
||||
include\
|
||||
functions, `classmethod` or `staticmethod`).
|
||||
|
||||
The precedence chain for attribute access is (1) data descriptors, (2) instance attributes, and (3)
|
||||
non-data descriptors.
|
||||
@@ -95,7 +100,7 @@ class DataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
|
||||
return "data"
|
||||
|
||||
def __set__(self, instance: object, value: int) -> None:
|
||||
def __set__(self, instance: int, value) -> None:
|
||||
pass
|
||||
|
||||
class NonDataDescriptor:
|
||||
@@ -119,7 +124,12 @@ class C:
|
||||
|
||||
c = C()
|
||||
|
||||
reveal_type(c.data_descriptor) # revealed: Unknown | Literal["data"]
|
||||
# TODO: This should ideally be `Unknown | Literal["data"]`.
|
||||
#
|
||||
# - Pyright also wrongly shows `int | Literal['data']` here
|
||||
# - Mypy shows Literal["data"] here, but also shows Literal["non-data"] below.
|
||||
#
|
||||
reveal_type(c.data_descriptor) # revealed: Unknown | Literal["data", 1]
|
||||
|
||||
reveal_type(c.non_data_descriptor) # revealed: Unknown | Literal["non-data", 1]
|
||||
|
||||
@@ -133,58 +143,79 @@ reveal_type(C.non_data_descriptor) # revealed: Unknown | Literal["non-data"]
|
||||
C.data_descriptor = "something else" # This is okay
|
||||
```
|
||||
|
||||
### Partial fall back
|
||||
## Built-in `property` descriptor
|
||||
|
||||
Our implementation of the descriptor protocol takes into account that symbols can be possibly
|
||||
unbound. In those cases, we fall back to lower precedence steps of the descriptor protocol and union
|
||||
all possible results accordingly. We start by defining a data and a non-data descriptor:
|
||||
The built-in `property` decorator creates a descriptor. The names for attribute reads/writes are
|
||||
determined by the return type of the `name` method and the parameter type of the setter,
|
||||
respectively.
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
class C:
|
||||
_name: str | None = None
|
||||
|
||||
class DataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
|
||||
return "data"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name or "Unset"
|
||||
# TODO: No diagnostic should be emitted here
|
||||
# error: [unresolved-attribute] "Type `Literal[name]` has no attribute `setter`"
|
||||
@name.setter
|
||||
def name(self, value: str | None) -> None:
|
||||
self._value = value
|
||||
|
||||
def __set__(self, instance: object, value: int) -> None:
|
||||
pass
|
||||
c = C()
|
||||
|
||||
class NonDataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
|
||||
return "non-data"
|
||||
reveal_type(c._name) # revealed: str | None
|
||||
|
||||
# Should be `str`
|
||||
reveal_type(c.name) # revealed: @Todo(decorated method)
|
||||
|
||||
# Should be `builtins.property`
|
||||
reveal_type(C.name) # revealed: Literal[name]
|
||||
|
||||
# This is fine:
|
||||
c.name = "new"
|
||||
|
||||
c.name = None
|
||||
|
||||
# TODO: this should be an error
|
||||
c.name = 42
|
||||
```
|
||||
|
||||
Then, we demonstrate that we fall back to an instance attribute if a data descriptor is possibly
|
||||
unbound:
|
||||
## Built-in `classmethod` descriptor
|
||||
|
||||
Similarly to `property`, `classmethod` decorator creates an implicit descriptor that binds the first
|
||||
argument to the class instead of the instance.
|
||||
|
||||
```py
|
||||
def f1(flag: bool):
|
||||
class C1:
|
||||
if flag:
|
||||
attr = DataDescriptor()
|
||||
class C:
|
||||
def __init__(self, value: str) -> None:
|
||||
self._name: str = value
|
||||
|
||||
def f(self):
|
||||
self.attr = "normal"
|
||||
@classmethod
|
||||
def factory(cls, value: str) -> "C":
|
||||
return cls(value)
|
||||
|
||||
reveal_type(C1().attr) # revealed: Unknown | Literal["data", "normal"]
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
return cls.__name__
|
||||
|
||||
c1 = C.factory("test") # okay
|
||||
|
||||
# TODO: should be `C`
|
||||
reveal_type(c1) # revealed: @Todo(return type)
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(C.get_name()) # revealed: @Todo(return type)
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(C("42").get_name()) # revealed: @Todo(decorated method)
|
||||
```
|
||||
|
||||
We never treat implicit instance attributes as definitely bound, so we fall back to the non-data
|
||||
descriptor here:
|
||||
## Descriptors only work when used as class variables
|
||||
|
||||
```py
|
||||
def f2(flag: bool):
|
||||
class C2:
|
||||
def f(self):
|
||||
self.attr = "normal"
|
||||
attr = NonDataDescriptor()
|
||||
From the descriptor guide:
|
||||
|
||||
reveal_type(C2().attr) # revealed: Unknown | Literal["non-data", "normal"]
|
||||
```
|
||||
|
||||
### Descriptors only work when used as class variables
|
||||
|
||||
Descriptors only work when used as class variables. When put in instances, they have no effect.
|
||||
> Descriptors only work when used as class variables. When put in instances, they have no effect.
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
@@ -197,182 +228,8 @@ class C:
|
||||
def __init__(self):
|
||||
self.ten: Ten = Ten()
|
||||
|
||||
reveal_type(C().ten) # revealed: Ten
|
||||
```
|
||||
|
||||
## Descriptor protocol for class objects
|
||||
|
||||
When attributes are accessed on a class object, the following [precedence chain] is used:
|
||||
|
||||
- Data descriptor on the metaclass
|
||||
- Data or non-data descriptor on the class
|
||||
- Class attribute
|
||||
- Non-data descriptor on the metaclass
|
||||
- Metaclass attribute
|
||||
|
||||
To verify this, we define a data and a non-data descriptor:
|
||||
|
||||
```py
|
||||
from typing import Literal, Any
|
||||
|
||||
class DataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
|
||||
return "data"
|
||||
|
||||
def __set__(self, instance: object, value: str) -> None:
|
||||
pass
|
||||
|
||||
class NonDataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
|
||||
return "non-data"
|
||||
```
|
||||
|
||||
First, we make sure that the descriptors are correctly accessed when defined on the metaclass or the
|
||||
class:
|
||||
|
||||
```py
|
||||
class Meta1(type):
|
||||
meta_data_descriptor: DataDescriptor = DataDescriptor()
|
||||
meta_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
|
||||
|
||||
class C1(metaclass=Meta1):
|
||||
class_data_descriptor: DataDescriptor = DataDescriptor()
|
||||
class_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
|
||||
|
||||
reveal_type(C1.meta_data_descriptor) # revealed: Literal["data"]
|
||||
reveal_type(C1.meta_non_data_descriptor) # revealed: Literal["non-data"]
|
||||
|
||||
reveal_type(C1.class_data_descriptor) # revealed: Literal["data"]
|
||||
reveal_type(C1.class_non_data_descriptor) # revealed: Literal["non-data"]
|
||||
```
|
||||
|
||||
Next, we demonstrate that a *metaclass data descriptor* takes precedence over all class-level
|
||||
attributes:
|
||||
|
||||
```py
|
||||
class Meta2(type):
|
||||
meta_data_descriptor1: DataDescriptor = DataDescriptor()
|
||||
meta_data_descriptor2: DataDescriptor = DataDescriptor()
|
||||
|
||||
class ClassLevelDataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> Literal["class level data descriptor"]:
|
||||
return "class level data descriptor"
|
||||
|
||||
def __set__(self, instance: object, value: str) -> None:
|
||||
pass
|
||||
|
||||
class C2(metaclass=Meta2):
|
||||
meta_data_descriptor1: Literal["value on class"] = "value on class"
|
||||
meta_data_descriptor2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
|
||||
|
||||
reveal_type(C2.meta_data_descriptor1) # revealed: Literal["data"]
|
||||
reveal_type(C2.meta_data_descriptor2) # revealed: Literal["data"]
|
||||
```
|
||||
|
||||
On the other hand, normal metaclass attributes and metaclass non-data descriptors are shadowed by
|
||||
class-level attributes (descriptor or not):
|
||||
|
||||
```py
|
||||
class Meta3(type):
|
||||
meta_attribute1: Literal["value on metaclass"] = "value on metaclass"
|
||||
meta_attribute2: Literal["value on metaclass"] = "value on metaclass"
|
||||
meta_non_data_descriptor1: NonDataDescriptor = NonDataDescriptor()
|
||||
meta_non_data_descriptor2: NonDataDescriptor = NonDataDescriptor()
|
||||
|
||||
class C3(metaclass=Meta3):
|
||||
meta_attribute1: Literal["value on class"] = "value on class"
|
||||
meta_attribute2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
|
||||
meta_non_data_descriptor1: Literal["value on class"] = "value on class"
|
||||
meta_non_data_descriptor2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
|
||||
|
||||
reveal_type(C3.meta_attribute1) # revealed: Literal["value on class"]
|
||||
reveal_type(C3.meta_attribute2) # revealed: Literal["class level data descriptor"]
|
||||
reveal_type(C3.meta_non_data_descriptor1) # revealed: Literal["value on class"]
|
||||
reveal_type(C3.meta_non_data_descriptor2) # revealed: Literal["class level data descriptor"]
|
||||
```
|
||||
|
||||
Finally, metaclass attributes and metaclass non-data descriptors are only accessible when they are
|
||||
not shadowed by class-level attributes:
|
||||
|
||||
```py
|
||||
class Meta4(type):
|
||||
meta_attribute: Literal["value on metaclass"] = "value on metaclass"
|
||||
meta_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
|
||||
|
||||
class C4(metaclass=Meta4): ...
|
||||
|
||||
reveal_type(C4.meta_attribute) # revealed: Literal["value on metaclass"]
|
||||
reveal_type(C4.meta_non_data_descriptor) # revealed: Literal["non-data"]
|
||||
```
|
||||
|
||||
When a metaclass data descriptor is possibly unbound, we union the result type of its `__get__`
|
||||
method with an underlying class level attribute, if present:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Meta5(type):
|
||||
if flag:
|
||||
meta_data_descriptor1: DataDescriptor = DataDescriptor()
|
||||
meta_data_descriptor2: DataDescriptor = DataDescriptor()
|
||||
|
||||
class C5(metaclass=Meta5):
|
||||
meta_data_descriptor1: Literal["value on class"] = "value on class"
|
||||
|
||||
reveal_type(C5.meta_data_descriptor1) # revealed: Literal["data", "value on class"]
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(C5.meta_data_descriptor2) # revealed: Literal["data"]
|
||||
```
|
||||
|
||||
When a class-level attribute is possibly unbound, we union its (descriptor protocol) type with the
|
||||
metaclass attribute (unless it's a data descriptor, which always takes precedence):
|
||||
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def _(flag: bool):
|
||||
class Meta6(type):
|
||||
attribute1: DataDescriptor = DataDescriptor()
|
||||
attribute2: NonDataDescriptor = NonDataDescriptor()
|
||||
attribute3: Literal["value on metaclass"] = "value on metaclass"
|
||||
|
||||
class C6(metaclass=Meta6):
|
||||
if flag:
|
||||
attribute1: Literal["value on class"] = "value on class"
|
||||
attribute2: Literal["value on class"] = "value on class"
|
||||
attribute3: Literal["value on class"] = "value on class"
|
||||
attribute4: Literal["value on class"] = "value on class"
|
||||
|
||||
reveal_type(C6.attribute1) # revealed: Literal["data"]
|
||||
reveal_type(C6.attribute2) # revealed: Literal["non-data", "value on class"]
|
||||
reveal_type(C6.attribute3) # revealed: Literal["value on metaclass", "value on class"]
|
||||
# error: [possibly-unbound-attribute]
|
||||
reveal_type(C6.attribute4) # revealed: Literal["value on class"]
|
||||
```
|
||||
|
||||
Finally, we can also have unions of various types of attributes:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Meta7(type):
|
||||
if flag:
|
||||
union_of_metaclass_attributes: Literal[1] = 1
|
||||
union_of_metaclass_data_descriptor_and_attribute: DataDescriptor = DataDescriptor()
|
||||
else:
|
||||
union_of_metaclass_attributes: Literal[2] = 2
|
||||
union_of_metaclass_data_descriptor_and_attribute: Literal[2] = 2
|
||||
|
||||
class C7(metaclass=Meta7):
|
||||
if flag:
|
||||
union_of_class_attributes: Literal[1] = 1
|
||||
union_of_class_data_descriptor_and_attribute: DataDescriptor = DataDescriptor()
|
||||
else:
|
||||
union_of_class_attributes: Literal[2] = 2
|
||||
union_of_class_data_descriptor_and_attribute: Literal[2] = 2
|
||||
|
||||
reveal_type(C7.union_of_metaclass_attributes) # revealed: Literal[1, 2]
|
||||
reveal_type(C7.union_of_metaclass_data_descriptor_and_attribute) # revealed: Literal["data", 2]
|
||||
reveal_type(C7.union_of_class_attributes) # revealed: Literal[1, 2]
|
||||
reveal_type(C7.union_of_class_data_descriptor_and_attribute) # revealed: Literal["data", 2]
|
||||
# TODO: Should be Ten
|
||||
reveal_type(C().ten) # revealed: Literal[10]
|
||||
```
|
||||
|
||||
## Descriptors distinguishing between class and instance access
|
||||
@@ -405,12 +262,63 @@ reveal_type(C.d) # revealed: LiteralString
|
||||
reveal_type(C().d) # revealed: LiteralString
|
||||
```
|
||||
|
||||
## Descriptor protocol for dunder methods
|
||||
## Undeclared descriptor arguments
|
||||
|
||||
Dunder methods are always looked up on the meta-type. There is no instance fallback. This means that
|
||||
an implicit dunder call on an instance-like object will not only look up the dunder method on the
|
||||
class object, without considering instance attributes. And an implicit dunder call on a class object
|
||||
will look up the dunder method on the metaclass, without considering class attributes.
|
||||
If a descriptor attribute is not declared, we union with `Unknown`, just like for regular
|
||||
attributes, since that attribute could be overwritten externally. Even a data descriptor with a
|
||||
`__set__` method can be overwritten when accessed through a class object.
|
||||
|
||||
```py
|
||||
class Descriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
def __set__(self, instance: object, value: int) -> None:
|
||||
pass
|
||||
|
||||
class C:
|
||||
descriptor = Descriptor()
|
||||
|
||||
C.descriptor = "something else"
|
||||
|
||||
# This could also be `Literal["something else"]` if we support narrowing of attribute types based on assignments
|
||||
reveal_type(C.descriptor) # revealed: Unknown | int
|
||||
```
|
||||
|
||||
## Descriptors with incorrect `__get__` signature
|
||||
|
||||
```py
|
||||
class Descriptor:
|
||||
# `__get__` method with missing parameters:
|
||||
def __get__(self) -> int:
|
||||
return 1
|
||||
|
||||
class C:
|
||||
descriptor: Descriptor = Descriptor()
|
||||
|
||||
# TODO: This should be an error
|
||||
reveal_type(C.descriptor) # revealed: Descriptor
|
||||
```
|
||||
|
||||
## Possibly-unbound `__get__` method
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class MaybeDescriptor:
|
||||
if flag:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
class C:
|
||||
descriptor: MaybeDescriptor = MaybeDescriptor()
|
||||
|
||||
# TODO: This should be `MaybeDescriptor | int`
|
||||
reveal_type(C.descriptor) # revealed: int
|
||||
```
|
||||
|
||||
## Dunder methods
|
||||
|
||||
Dunder methods are looked up on the meta type, but we still need to invoke the descriptor protocol:
|
||||
|
||||
```py
|
||||
class SomeCallable:
|
||||
@@ -430,77 +338,7 @@ reveal_type(b_instance(1)) # revealed: str
|
||||
b_instance("bla") # error: [invalid-argument-type]
|
||||
```
|
||||
|
||||
## Special descriptors
|
||||
|
||||
### Built-in `property` descriptor
|
||||
|
||||
The built-in `property` decorator creates a descriptor. The names for attribute reads/writes are
|
||||
determined by the return type of the `name` method and the parameter type of the setter,
|
||||
respectively.
|
||||
|
||||
```py
|
||||
class C:
|
||||
_name: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name or "Unset"
|
||||
# TODO: No diagnostic should be emitted here
|
||||
# error: [unresolved-attribute] "Type `Literal[name]` has no attribute `setter`"
|
||||
@name.setter
|
||||
def name(self, value: str | None) -> None:
|
||||
self._value = value
|
||||
|
||||
c = C()
|
||||
|
||||
reveal_type(c._name) # revealed: str | None
|
||||
|
||||
# TODO: Should be `str`
|
||||
reveal_type(c.name) # revealed: <bound method `name` of `C`>
|
||||
|
||||
# Should be `builtins.property`
|
||||
reveal_type(C.name) # revealed: Literal[name]
|
||||
|
||||
# TODO: These should not emit errors
|
||||
# error: [invalid-assignment]
|
||||
c.name = "new"
|
||||
|
||||
# error: [invalid-assignment]
|
||||
c.name = None
|
||||
|
||||
# TODO: this should be an error, but with a proper error message
|
||||
# error: [invalid-assignment] "Object of type `Literal[42]` is not assignable to attribute `name` of type `<bound method `name` of `C`>`"
|
||||
c.name = 42
|
||||
```
|
||||
|
||||
### Built-in `classmethod` descriptor
|
||||
|
||||
Similarly to `property`, `classmethod` decorator creates an implicit descriptor that binds the first
|
||||
argument to the class instead of the instance.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, value: str) -> None:
|
||||
self._name: str = value
|
||||
|
||||
@classmethod
|
||||
def factory(cls, value: str) -> "C":
|
||||
return cls(value)
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
return cls.__name__
|
||||
|
||||
c1 = C.factory("test") # okay
|
||||
|
||||
reveal_type(c1) # revealed: C
|
||||
|
||||
reveal_type(C.get_name()) # revealed: str
|
||||
|
||||
reveal_type(C("42").get_name()) # revealed: str
|
||||
```
|
||||
|
||||
### Functions as descriptors
|
||||
## Functions as descriptors
|
||||
|
||||
Functions are descriptors because they implement a `__get__` method. This is crucial in making sure
|
||||
that method calls work as expected. See [this test suite](./call/methods.md) for more information.
|
||||
@@ -550,202 +388,32 @@ Finally, we test some error cases for the call to the wrapper descriptor:
|
||||
|
||||
```py
|
||||
# Calling the wrapper descriptor without any arguments is an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [missing-argument] "No arguments provided for required parameters `self`, `instance`"
|
||||
wrapper_descriptor()
|
||||
|
||||
# Calling it without the `instance` argument is an also an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [missing-argument] "No argument provided for required parameter `instance`"
|
||||
wrapper_descriptor(f)
|
||||
|
||||
# Calling it without the `owner` argument if `instance` is not `None` is an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [missing-argument] "No argument provided for required parameter `owner`"
|
||||
wrapper_descriptor(f, None)
|
||||
|
||||
# But calling it with an instance is fine (in this case, the `owner` argument is optional):
|
||||
wrapper_descriptor(f, C())
|
||||
|
||||
# Calling it with something that is not a `FunctionType` as the first argument is an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [invalid-argument-type] "Object of type `Literal[1]` cannot be assigned to parameter 1 (`self`); expected type `FunctionType`"
|
||||
wrapper_descriptor(1, None, type(f))
|
||||
|
||||
# Calling it with something that is not a `type` as the `owner` argument is an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [invalid-argument-type] "Object of type `Literal[f]` cannot be assigned to parameter 3 (`owner`); expected type `type`"
|
||||
wrapper_descriptor(f, None, f)
|
||||
|
||||
# Calling it with too many positional arguments is an
|
||||
# error: [no-matching-overload] "No overload of wrapper descriptor `FunctionType.__get__` matches arguments"
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments: expected 3, got 4"
|
||||
wrapper_descriptor(f, None, type(f), "one too many")
|
||||
```
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
### `__get__` is called with correct arguments
|
||||
|
||||
This test makes sure that we call `__get__` with the right argument types for various scenarios:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
class TailoredForClassObjectAccess:
|
||||
def __get__(self, instance: None, owner: type[C]) -> int:
|
||||
return 1
|
||||
|
||||
class TailoredForInstanceAccess:
|
||||
def __get__(self, instance: C, owner: type[C] | None = None) -> str:
|
||||
return "a"
|
||||
|
||||
class TailoredForMetaclassAccess:
|
||||
def __get__(self, instance: type[C], owner: type[Meta]) -> bytes:
|
||||
return b"a"
|
||||
|
||||
class Meta(type):
|
||||
metaclass_access: TailoredForMetaclassAccess = TailoredForMetaclassAccess()
|
||||
|
||||
class C(metaclass=Meta):
|
||||
class_object_access: TailoredForClassObjectAccess = TailoredForClassObjectAccess()
|
||||
instance_access: TailoredForInstanceAccess = TailoredForInstanceAccess()
|
||||
|
||||
reveal_type(C.class_object_access) # revealed: int
|
||||
reveal_type(C().instance_access) # revealed: str
|
||||
reveal_type(C.metaclass_access) # revealed: bytes
|
||||
|
||||
# TODO: These should emit a diagnostic
|
||||
reveal_type(C().class_object_access) # revealed: TailoredForClassObjectAccess
|
||||
reveal_type(C.instance_access) # revealed: TailoredForInstanceAccess
|
||||
```
|
||||
|
||||
### Descriptors with incorrect `__get__` signature
|
||||
|
||||
```py
|
||||
class Descriptor:
|
||||
# `__get__` method with missing parameters:
|
||||
def __get__(self) -> int:
|
||||
return 1
|
||||
|
||||
class C:
|
||||
descriptor: Descriptor = Descriptor()
|
||||
|
||||
# TODO: This should be an error
|
||||
reveal_type(C.descriptor) # revealed: Descriptor
|
||||
|
||||
# TODO: This should be an error
|
||||
reveal_type(C().descriptor) # revealed: Descriptor
|
||||
```
|
||||
|
||||
### Undeclared descriptor arguments
|
||||
|
||||
If a descriptor attribute is not declared, we union with `Unknown`, just like for regular
|
||||
attributes, since that attribute could be overwritten externally. Even a data descriptor with a
|
||||
`__set__` method can be overwritten when accessed through a class object.
|
||||
|
||||
```py
|
||||
class Descriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
def __set__(self, instance: object, value: int) -> None:
|
||||
pass
|
||||
|
||||
class C:
|
||||
descriptor = Descriptor()
|
||||
|
||||
C.descriptor = "something else"
|
||||
|
||||
# This could also be `Literal["something else"]` if we support narrowing of attribute types based on assignments
|
||||
reveal_type(C.descriptor) # revealed: Unknown | int
|
||||
```
|
||||
|
||||
### Possibly unbound descriptor attributes
|
||||
|
||||
```py
|
||||
class DataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
def __set__(self, instance: int, value) -> None:
|
||||
pass
|
||||
|
||||
class NonDataDescriptor:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
def _(flag: bool):
|
||||
class PossiblyUnbound:
|
||||
if flag:
|
||||
non_data: NonDataDescriptor = NonDataDescriptor()
|
||||
data: DataDescriptor = DataDescriptor()
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `Literal[PossiblyUnbound]` is possibly unbound"
|
||||
reveal_type(PossiblyUnbound.non_data) # revealed: int
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `PossiblyUnbound` is possibly unbound"
|
||||
reveal_type(PossiblyUnbound().non_data) # revealed: int
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `data` on type `Literal[PossiblyUnbound]` is possibly unbound"
|
||||
reveal_type(PossiblyUnbound.data) # revealed: int
|
||||
|
||||
# error: [possibly-unbound-attribute] "Attribute `data` on type `PossiblyUnbound` is possibly unbound"
|
||||
reveal_type(PossiblyUnbound().data) # revealed: int
|
||||
```
|
||||
|
||||
### Possibly-unbound `__get__` method
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class MaybeDescriptor:
|
||||
if flag:
|
||||
def __get__(self, instance: object, owner: type | None = None) -> int:
|
||||
return 1
|
||||
|
||||
class C:
|
||||
descriptor: MaybeDescriptor = MaybeDescriptor()
|
||||
|
||||
reveal_type(C.descriptor) # revealed: int | MaybeDescriptor
|
||||
|
||||
reveal_type(C().descriptor) # revealed: int | MaybeDescriptor
|
||||
```
|
||||
|
||||
### Descriptors with non-function `__get__` callables that are descriptors themselves
|
||||
|
||||
The descriptor protocol is recursive, i.e. looking up `__get__` can involve triggering the
|
||||
descriptor protocol on the callable's `__call__` method:
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
class ReturnedCallable2:
|
||||
def __call__(self, descriptor: Descriptor1, instance: None, owner: type[C]) -> int:
|
||||
return 1
|
||||
|
||||
class ReturnedCallable1:
|
||||
def __call__(self, descriptor: Descriptor2, instance: Callable1, owner: type[Callable1]) -> ReturnedCallable2:
|
||||
return ReturnedCallable2()
|
||||
|
||||
class Callable3:
|
||||
def __call__(self, descriptor: Descriptor3, instance: Callable2, owner: type[Callable2]) -> ReturnedCallable1:
|
||||
return ReturnedCallable1()
|
||||
|
||||
class Descriptor3:
|
||||
__get__: Callable3 = Callable3()
|
||||
|
||||
class Callable2:
|
||||
__call__: Descriptor3 = Descriptor3()
|
||||
|
||||
class Descriptor2:
|
||||
__get__: Callable2 = Callable2()
|
||||
|
||||
class Callable1:
|
||||
__call__: Descriptor2 = Descriptor2()
|
||||
|
||||
class Descriptor1:
|
||||
__get__: Callable1 = Callable1()
|
||||
|
||||
class C:
|
||||
d: Descriptor1 = Descriptor1()
|
||||
|
||||
reveal_type(C.d) # revealed: int
|
||||
```
|
||||
|
||||
[descriptors]: https://docs.python.org/3/howto/descriptor.html
|
||||
[precedence chain]: https://github.com/python/cpython/blob/3.13/Objects/typeobject.c#L5393-L5481
|
||||
[simple example]: https://docs.python.org/3/howto/descriptor.html#simple-example-a-descriptor-that-returns-a-constant
|
||||
|
||||
@@ -182,16 +182,3 @@ class C:
|
||||
c = C()
|
||||
c("wrong") # error: [invalid-argument-type]
|
||||
```
|
||||
|
||||
## Calls to methods
|
||||
|
||||
Tests that we also see a reference to a function if the callable is a bound method.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def square(self, x: int) -> int:
|
||||
return x * x
|
||||
|
||||
c = C()
|
||||
c.square("hello") # error: [invalid-argument-type]
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# No matching overload diagnostics
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
## Calls to overloaded functions
|
||||
|
||||
TODO: Note that we do not yet support the `@overload` decorator to define overloaded functions in
|
||||
real Python code. We are instead testing a special-cased function where we create an overloaded
|
||||
signature internally. Update this to an `@overload` function in the Python snippet itself once we
|
||||
can.
|
||||
|
||||
```py
|
||||
type("Foo", ()) # error: [no-matching-overload]
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
## Condition with object that implements `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
assert NotBoolable()
|
||||
```
|
||||
@@ -101,55 +101,3 @@ reveal_type(bool([])) # revealed: bool
|
||||
reveal_type(bool({})) # revealed: bool
|
||||
reveal_type(bool(set())) # revealed: bool
|
||||
```
|
||||
|
||||
## `__bool__` returning `NoReturn`
|
||||
|
||||
```py
|
||||
from typing import NoReturn
|
||||
|
||||
class NotBoolable:
|
||||
def __bool__(self) -> NoReturn:
|
||||
raise NotImplementedError("This object can't be converted to a boolean")
|
||||
|
||||
# TODO: This should emit an error that `NotBoolable` can't be converted to a bool but it currently doesn't
|
||||
# because `Never` is assignable to `bool`. This probably requires dead code analysis to fix.
|
||||
if NotBoolable():
|
||||
...
|
||||
```
|
||||
|
||||
## Not callable `__bool__`
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = None
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
if NotBoolable():
|
||||
...
|
||||
```
|
||||
|
||||
## Not-boolable union
|
||||
|
||||
```py
|
||||
def test(cond: bool):
|
||||
class NotBoolable:
|
||||
__bool__ = None if cond else 3
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; it incorrectly implements `__bool__`"
|
||||
if NotBoolable():
|
||||
...
|
||||
```
|
||||
|
||||
## Union with some variants implementing `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
def test(cond: bool):
|
||||
class NotBoolable:
|
||||
__bool__ = None
|
||||
|
||||
a = 10 if cond else NotBoolable()
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `Literal[10] | NotBoolable`; its `__bool__` method isn't callable"
|
||||
if a:
|
||||
...
|
||||
```
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# `lambda` expression
|
||||
|
||||
## No parameters
|
||||
|
||||
`lambda` expressions can be defined without any parameters.
|
||||
|
||||
```py
|
||||
reveal_type(lambda: 1) # revealed: () -> @Todo(lambda return type)
|
||||
|
||||
# error: [unresolved-reference]
|
||||
reveal_type(lambda: a) # revealed: () -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
## With parameters
|
||||
|
||||
Unlike parameters in function definition, the parameters in a `lambda` expression cannot be
|
||||
annotated.
|
||||
|
||||
```py
|
||||
reveal_type(lambda a: a) # revealed: (a) -> @Todo(lambda return type)
|
||||
reveal_type(lambda a, b: a + b) # revealed: (a, b) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
But, it can have default values:
|
||||
|
||||
```py
|
||||
reveal_type(lambda a=1: a) # revealed: (a=Literal[1]) -> @Todo(lambda return type)
|
||||
reveal_type(lambda a, b=2: a) # revealed: (a, b=Literal[2]) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
And, positional-only parameters:
|
||||
|
||||
```py
|
||||
reveal_type(lambda a, b, /, c: c) # revealed: (a, b, /, c) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
And, keyword-only parameters:
|
||||
|
||||
```py
|
||||
reveal_type(lambda a, *, b=2, c: b) # revealed: (a, *, b=Literal[2], c) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
And, variadic parameter:
|
||||
|
||||
```py
|
||||
reveal_type(lambda *args: args) # revealed: (*args) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
And, keyword-varidic parameter:
|
||||
|
||||
```py
|
||||
reveal_type(lambda **kwargs: kwargs) # revealed: (**kwargs) -> @Todo(lambda return type)
|
||||
```
|
||||
|
||||
Mixing all of them together:
|
||||
|
||||
```py
|
||||
# revealed: (a, b, /, c=Literal[True], *args, *, d=Literal["default"], e=Literal[5], **kwargs) -> @Todo(lambda return type)
|
||||
reveal_type(lambda a, b, /, c=True, *args, d="default", e=5, **kwargs: None)
|
||||
```
|
||||
|
||||
## Parameter type
|
||||
|
||||
In addition to correctly inferring the `lambda` expression, the parameters should also be inferred
|
||||
correctly.
|
||||
|
||||
Using a parameter with no default value:
|
||||
|
||||
```py
|
||||
lambda x: reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
Using a parameter with default value:
|
||||
|
||||
```py
|
||||
lambda x=1: reveal_type(x) # revealed: Unknown | Literal[1]
|
||||
```
|
||||
|
||||
Using a variadic paramter:
|
||||
|
||||
```py
|
||||
# TODO: should be `tuple[Unknown, ...]` (needs generics)
|
||||
lambda *args: reveal_type(args) # revealed: tuple
|
||||
```
|
||||
|
||||
Using a keyword-varidic parameter:
|
||||
|
||||
```py
|
||||
# TODO: should be `dict[str, Unknown]` (needs generics)
|
||||
lambda **kwargs: reveal_type(kwargs) # revealed: dict
|
||||
```
|
||||
|
||||
## Nested `lambda` expressions
|
||||
|
||||
Here, a `lambda` expression is used as the default value for a parameter in another `lambda`
|
||||
expression.
|
||||
|
||||
```py
|
||||
reveal_type(lambda a=lambda x, y: 0: 2) # revealed: (a=(x, y) -> @Todo(lambda return type)) -> @Todo(lambda return type)
|
||||
```
|
||||
@@ -99,28 +99,22 @@ The returned value of `__len__` is implicitly and recursively converted to `int`
|
||||
from typing import Literal
|
||||
|
||||
class Zero:
|
||||
def __len__(self) -> Literal[0]:
|
||||
return 0
|
||||
def __len__(self) -> Literal[0]: ...
|
||||
|
||||
class ZeroOrOne:
|
||||
def __len__(self) -> Literal[0, 1]:
|
||||
return 0
|
||||
def __len__(self) -> Literal[0, 1]: ...
|
||||
|
||||
class ZeroOrTrue:
|
||||
def __len__(self) -> Literal[0, True]:
|
||||
return 0
|
||||
def __len__(self) -> Literal[0, True]: ...
|
||||
|
||||
class OneOrFalse:
|
||||
def __len__(self) -> Literal[1] | Literal[False]:
|
||||
return 1
|
||||
def __len__(self) -> Literal[1] | Literal[False]: ...
|
||||
|
||||
class OneOrFoo:
|
||||
def __len__(self) -> Literal[1, "foo"]:
|
||||
return 1
|
||||
def __len__(self) -> Literal[1, "foo"]: ...
|
||||
|
||||
class ZeroOrStr:
|
||||
def __len__(self) -> Literal[0] | str:
|
||||
return 0
|
||||
def __len__(self) -> Literal[0] | str: ...
|
||||
|
||||
reveal_type(len(Zero())) # revealed: Literal[0]
|
||||
reveal_type(len(ZeroOrOne())) # revealed: Literal[0, 1]
|
||||
@@ -140,12 +134,10 @@ reveal_type(len(ZeroOrStr())) # revealed: int
|
||||
from typing import Literal
|
||||
|
||||
class LiteralTrue:
|
||||
def __len__(self) -> Literal[True]:
|
||||
return True
|
||||
def __len__(self) -> Literal[True]: ...
|
||||
|
||||
class LiteralFalse:
|
||||
def __len__(self) -> Literal[False]:
|
||||
return False
|
||||
def __len__(self) -> Literal[False]: ...
|
||||
|
||||
reveal_type(len(LiteralTrue())) # revealed: Literal[1]
|
||||
reveal_type(len(LiteralFalse())) # revealed: Literal[0]
|
||||
@@ -165,24 +157,19 @@ class SomeEnum(Enum):
|
||||
INT_2 = 3_2
|
||||
|
||||
class Auto:
|
||||
def __len__(self) -> Literal[SomeEnum.AUTO]:
|
||||
return SomeEnum.AUTO
|
||||
def __len__(self) -> Literal[SomeEnum.AUTO]: ...
|
||||
|
||||
class Int:
|
||||
def __len__(self) -> Literal[SomeEnum.INT]:
|
||||
return SomeEnum.INT
|
||||
def __len__(self) -> Literal[SomeEnum.INT]: ...
|
||||
|
||||
class Str:
|
||||
def __len__(self) -> Literal[SomeEnum.STR]:
|
||||
return SomeEnum.STR
|
||||
def __len__(self) -> Literal[SomeEnum.STR]: ...
|
||||
|
||||
class Tuple:
|
||||
def __len__(self) -> Literal[SomeEnum.TUPLE]:
|
||||
return SomeEnum.TUPLE
|
||||
def __len__(self) -> Literal[SomeEnum.TUPLE]: ...
|
||||
|
||||
class IntUnion:
|
||||
def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]:
|
||||
return SomeEnum.INT
|
||||
def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]: ...
|
||||
|
||||
reveal_type(len(Auto())) # revealed: int
|
||||
reveal_type(len(Int())) # revealed: int
|
||||
@@ -197,8 +184,7 @@ reveal_type(len(IntUnion())) # revealed: int
|
||||
from typing import Literal
|
||||
|
||||
class Negative:
|
||||
def __len__(self) -> Literal[-1]:
|
||||
return -1
|
||||
def __len__(self) -> Literal[-1]: ...
|
||||
|
||||
# TODO: Emit a diagnostic
|
||||
reveal_type(len(Negative())) # revealed: int
|
||||
@@ -210,12 +196,10 @@ reveal_type(len(Negative())) # revealed: int
|
||||
from typing import Literal
|
||||
|
||||
class SecondOptionalArgument:
|
||||
def __len__(self, v: int = 0) -> Literal[0]:
|
||||
return 0
|
||||
def __len__(self, v: int = 0) -> Literal[0]: ...
|
||||
|
||||
class SecondRequiredArgument:
|
||||
def __len__(self, v: int) -> Literal[1]:
|
||||
return 1
|
||||
def __len__(self, v: int) -> Literal[1]: ...
|
||||
|
||||
# TODO: Emit a diagnostic
|
||||
reveal_type(len(SecondOptionalArgument())) # revealed: Literal[0]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# Function return type
|
||||
|
||||
When a function's return type is annotated, all return statements are checked to ensure that the
|
||||
type of the returned value is assignable to the annotated return type.
|
||||
|
||||
## Basic examples
|
||||
|
||||
A return value assignable to the annotated return type is valid.
|
||||
|
||||
```py
|
||||
def f() -> int:
|
||||
return 1
|
||||
```
|
||||
|
||||
The type of the value obtained by calling a function is the annotated return type, not the inferred
|
||||
return type.
|
||||
|
||||
```py
|
||||
reveal_type(f()) # revealed: int
|
||||
```
|
||||
|
||||
A `raise` is equivalent to a return of `Never`, which is assignable to any annotated return type.
|
||||
|
||||
```py
|
||||
def f() -> str:
|
||||
raise ValueError()
|
||||
|
||||
reveal_type(f()) # revealed: str
|
||||
```
|
||||
|
||||
## Stub functions
|
||||
|
||||
"Stub" function definitions (that is, function definitions with an empty body) are permissible in
|
||||
stub files, or in a few other locations: Protocol method definitions, abstract methods, and
|
||||
overloads. In this case the function body is considered to be omitted (thus no return type checking
|
||||
is performed on it), not assumed to implicitly return `None`.
|
||||
|
||||
A stub function's "empty" body may contain only an optional docstring, followed (optionally) by an
|
||||
ellipsis (`...`) or `pass`.
|
||||
|
||||
### In stub file
|
||||
|
||||
```pyi
|
||||
def f() -> int: ...
|
||||
|
||||
def f() -> int:
|
||||
pass
|
||||
|
||||
def f() -> int:
|
||||
"""Some docstring"""
|
||||
|
||||
def f() -> int:
|
||||
"""Some docstring"""
|
||||
...
|
||||
```
|
||||
|
||||
### In Protocol
|
||||
|
||||
```py
|
||||
from typing import Protocol
|
||||
|
||||
class Bar(Protocol):
|
||||
# TODO: no error
|
||||
# error: [invalid-return-type]
|
||||
def f(self) -> int: ...
|
||||
```
|
||||
|
||||
### In abstract method
|
||||
|
||||
```py
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class Foo(ABC):
|
||||
@abstractmethod
|
||||
# TODO: no error
|
||||
# error: [invalid-return-type]
|
||||
def f(self) -> int: ...
|
||||
@abstractmethod
|
||||
# error: [invalid-return-type]
|
||||
def g[T](self, x: T) -> T: ...
|
||||
```
|
||||
|
||||
### In overload
|
||||
|
||||
```py
|
||||
from typing import overload
|
||||
|
||||
@overload
|
||||
def f(x: int) -> int: ...
|
||||
@overload
|
||||
def f(x: str) -> str: ...
|
||||
def f(x: int | str):
|
||||
return x
|
||||
```
|
||||
|
||||
## Conditional return type
|
||||
|
||||
```py
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
return 1
|
||||
else:
|
||||
return 2
|
||||
|
||||
def f(cond: bool) -> int | None:
|
||||
if cond:
|
||||
return 1
|
||||
else:
|
||||
return
|
||||
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
return 1
|
||||
else:
|
||||
raise ValueError()
|
||||
|
||||
def f(cond: bool) -> str | int:
|
||||
if cond:
|
||||
return "a"
|
||||
else:
|
||||
return 1
|
||||
```
|
||||
|
||||
## Implicit return type
|
||||
|
||||
```py
|
||||
def f(cond: bool) -> int | None:
|
||||
if cond:
|
||||
return 1
|
||||
|
||||
# no implicit return
|
||||
def f() -> int:
|
||||
if True:
|
||||
return 1
|
||||
|
||||
# no implicit return
|
||||
def f(cond: bool) -> int:
|
||||
cond = True
|
||||
if cond:
|
||||
return 1
|
||||
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
cond = True
|
||||
else:
|
||||
return 1
|
||||
if cond:
|
||||
return 2
|
||||
```
|
||||
|
||||
## Invalid return type
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
# error: [invalid-return-type]
|
||||
def f() -> int:
|
||||
1
|
||||
|
||||
def f() -> str:
|
||||
# error: [invalid-return-type]
|
||||
return 1
|
||||
|
||||
def f() -> int:
|
||||
# error: [invalid-return-type]
|
||||
return
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# TODO: `invalid-return-type` error should be emitted
|
||||
def m(x: T) -> T: ...
|
||||
```
|
||||
|
||||
## Invalid return type in stub file
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```pyi
|
||||
def f() -> int:
|
||||
# error: [invalid-return-type]
|
||||
return ...
|
||||
|
||||
# error: [invalid-return-type]
|
||||
def foo() -> int:
|
||||
print("...")
|
||||
...
|
||||
|
||||
# error: [invalid-return-type]
|
||||
def foo() -> int:
|
||||
f"""{foo} is a function that ..."""
|
||||
...
|
||||
```
|
||||
|
||||
## Invalid conditional return type
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
def f(cond: bool) -> str:
|
||||
if cond:
|
||||
return "a"
|
||||
else:
|
||||
# error: [invalid-return-type]
|
||||
return 1
|
||||
|
||||
def f(cond: bool) -> str:
|
||||
if cond:
|
||||
# error: [invalid-return-type]
|
||||
return 1
|
||||
else:
|
||||
# error: [invalid-return-type]
|
||||
return 2
|
||||
```
|
||||
|
||||
## Invalid implicit return type
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
def f() -> None:
|
||||
if False:
|
||||
# error: [invalid-return-type]
|
||||
return 1
|
||||
|
||||
# error: [invalid-return-type]
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
return 1
|
||||
|
||||
# error: [invalid-return-type]
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
raise ValueError()
|
||||
|
||||
# error: [invalid-return-type]
|
||||
def f(cond: bool) -> int:
|
||||
if cond:
|
||||
cond = False
|
||||
else:
|
||||
return 1
|
||||
if cond:
|
||||
return 2
|
||||
```
|
||||
81
crates/red_knot_python_semantic/resources/mdtest/generics.md
Normal file
81
crates/red_knot_python_semantic/resources/mdtest/generics.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# PEP 695 Generics
|
||||
|
||||
## Class Declarations
|
||||
|
||||
Basic PEP 695 generics
|
||||
|
||||
```py
|
||||
class MyBox[T]:
|
||||
data: T
|
||||
box_model_number = 695
|
||||
|
||||
def __init__(self, data: T):
|
||||
self.data = data
|
||||
|
||||
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
|
||||
|
||||
reveal_type(MyBox.box_model_number) # revealed: Unknown | Literal[695]
|
||||
```
|
||||
|
||||
## Subclassing
|
||||
|
||||
```py
|
||||
class MyBox[T]:
|
||||
data: T
|
||||
|
||||
def __init__(self, data: T):
|
||||
self.data = data
|
||||
|
||||
# TODO not error on the subscripting
|
||||
# error: [non-subscriptable]
|
||||
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)
|
||||
```
|
||||
|
||||
## Cyclical class definition
|
||||
|
||||
In type stubs, classes can reference themselves in their base class definitions. For example, in
|
||||
`typeshed`, we have `class str(Sequence[str]): ...`.
|
||||
|
||||
This should hold true even with generics at play.
|
||||
|
||||
```pyi
|
||||
class Seq[T]: ...
|
||||
|
||||
# TODO not error on the subscripting
|
||||
class S[T](Seq[S]): ... # error: [non-subscriptable]
|
||||
|
||||
reveal_type(S) # revealed: Literal[S]
|
||||
```
|
||||
|
||||
## Type params
|
||||
|
||||
A PEP695 type variable defines a value of type `typing.TypeVar`.
|
||||
|
||||
```py
|
||||
def f[T]():
|
||||
reveal_type(T) # revealed: T
|
||||
reveal_type(T.__name__) # revealed: Literal["T"]
|
||||
```
|
||||
|
||||
## Minimum two constraints
|
||||
|
||||
A typevar with less than two constraints emits a diagnostic:
|
||||
|
||||
```py
|
||||
# error: [invalid-type-variable-constraints] "TypeVar must have at least two constrained types"
|
||||
def f[T: (int,)]():
|
||||
pass
|
||||
```
|
||||
@@ -1,200 +0,0 @@
|
||||
# Generic classes
|
||||
|
||||
## PEP 695 syntax
|
||||
|
||||
TODO: Add a `red_knot_extension` function that asserts whether a function or class is generic.
|
||||
|
||||
This is a generic class defined using PEP 695 syntax:
|
||||
|
||||
```py
|
||||
class C[T]: ...
|
||||
```
|
||||
|
||||
A class that inherits from a generic class, and fills its type parameters with typevars, is generic:
|
||||
|
||||
```py
|
||||
# TODO: no error
|
||||
# error: [non-subscriptable]
|
||||
class D[U](C[U]): ...
|
||||
```
|
||||
|
||||
A class that inherits from a generic class, but fills its type parameters with concrete types, is
|
||||
_not_ generic:
|
||||
|
||||
```py
|
||||
# TODO: no error
|
||||
# error: [non-subscriptable]
|
||||
class E(C[int]): ...
|
||||
```
|
||||
|
||||
A class that inherits from a generic class, and doesn't fill its type parameters at all, implicitly
|
||||
uses the default value for the typevar. In this case, that default type is `Unknown`, so `F`
|
||||
inherits from `C[Unknown]` and is not itself generic.
|
||||
|
||||
```py
|
||||
class F(C): ...
|
||||
```
|
||||
|
||||
## Legacy syntax
|
||||
|
||||
This is a generic class defined using the legacy syntax:
|
||||
|
||||
```py
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# TODO: no error
|
||||
# error: [invalid-base]
|
||||
class C(Generic[T]): ...
|
||||
```
|
||||
|
||||
A class that inherits from a generic class, and fills its type parameters with typevars, is generic.
|
||||
|
||||
```py
|
||||
class D(C[T]): ...
|
||||
```
|
||||
|
||||
(Examples `E` and `F` from above do not have analogues in the legacy syntax.)
|
||||
|
||||
## Inferring generic class parameters
|
||||
|
||||
The type parameter can be specified explicitly:
|
||||
|
||||
```py
|
||||
class C[T]:
|
||||
x: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: C[int]
|
||||
# error: [non-subscriptable]
|
||||
reveal_type(C[int]()) # revealed: C
|
||||
```
|
||||
|
||||
We can infer the type parameter from a type context:
|
||||
|
||||
```py
|
||||
c: C[int] = C()
|
||||
# TODO: revealed: C[int]
|
||||
reveal_type(c) # revealed: C
|
||||
```
|
||||
|
||||
The typevars of a fully specialized generic class should no longer be visible:
|
||||
|
||||
```py
|
||||
# TODO: revealed: int
|
||||
reveal_type(c.x) # revealed: T
|
||||
```
|
||||
|
||||
If the type parameter is not specified explicitly, and there are no constraints that let us infer a
|
||||
specific type, we infer the typevar's default type:
|
||||
|
||||
```py
|
||||
class D[T = int]: ...
|
||||
|
||||
# TODO: revealed: D[int]
|
||||
reveal_type(D()) # revealed: D
|
||||
```
|
||||
|
||||
If a typevar does not provide a default, we use `Unknown`:
|
||||
|
||||
```py
|
||||
# TODO: revealed: C[Unknown]
|
||||
reveal_type(C()) # revealed: C
|
||||
```
|
||||
|
||||
If the type of a constructor parameter is a class typevar, we can use that to infer the type
|
||||
parameter:
|
||||
|
||||
```py
|
||||
class E[T]:
|
||||
def __init__(self, x: T) -> None: ...
|
||||
|
||||
# TODO: revealed: E[int] or E[Literal[1]]
|
||||
reveal_type(E(1)) # revealed: E
|
||||
```
|
||||
|
||||
The types inferred from a type context and from a constructor parameter must be consistent with each
|
||||
other:
|
||||
|
||||
```py
|
||||
# TODO: error
|
||||
wrong_innards: E[int] = E("five")
|
||||
```
|
||||
|
||||
## Generic subclass
|
||||
|
||||
When a generic subclass fills its superclass's type parameter with one of its own, the actual types
|
||||
propagate through:
|
||||
|
||||
```py
|
||||
class Base[T]:
|
||||
x: T | None = None
|
||||
|
||||
# TODO: no error
|
||||
# error: [non-subscriptable]
|
||||
class Sub[U](Base[U]): ...
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: int | None
|
||||
# error: [non-subscriptable]
|
||||
reveal_type(Base[int].x) # revealed: T | None
|
||||
# TODO: revealed: int | None
|
||||
# error: [non-subscriptable]
|
||||
reveal_type(Sub[int].x) # revealed: T | None
|
||||
```
|
||||
|
||||
## Cyclic class definition
|
||||
|
||||
A class can use itself as the type parameter of one of its superclasses. (This is also known as the
|
||||
[curiously recurring template pattern][crtp] or [F-bounded quantification][f-bound].)
|
||||
|
||||
Here, `Sub` is not a generic class, since it fills its superclass's type parameter (with itself).
|
||||
|
||||
`stub.pyi`:
|
||||
|
||||
```pyi
|
||||
class Base[T]: ...
|
||||
# TODO: no error
|
||||
# error: [non-subscriptable]
|
||||
class Sub(Base[Sub]): ...
|
||||
|
||||
reveal_type(Sub) # revealed: Literal[Sub]
|
||||
```
|
||||
|
||||
A similar case can work in a non-stub file, if forward references are stringified:
|
||||
|
||||
`string_annotation.py`:
|
||||
|
||||
```py
|
||||
class Base[T]: ...
|
||||
|
||||
# TODO: no error
|
||||
# error: [non-subscriptable]
|
||||
class Sub(Base["Sub"]): ...
|
||||
|
||||
reveal_type(Sub) # revealed: Literal[Sub]
|
||||
```
|
||||
|
||||
In a non-stub file, without stringified forward references, this raises a `NameError`:
|
||||
|
||||
`bare_annotation.py`:
|
||||
|
||||
```py
|
||||
class Base[T]: ...
|
||||
|
||||
# TODO: error: [unresolved-reference]
|
||||
# error: [non-subscriptable]
|
||||
class Sub(Base[Sub]): ...
|
||||
```
|
||||
|
||||
## Another cyclic case
|
||||
|
||||
```pyi
|
||||
# TODO no error (generics)
|
||||
# error: [invalid-base]
|
||||
class Derived[T](list[Derived[T]]): ...
|
||||
```
|
||||
|
||||
[crtp]: https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern
|
||||
[f-bound]: https://en.wikipedia.org/wiki/Bounded_quantification#F-bounded_quantification
|
||||
@@ -1,246 +0,0 @@
|
||||
# Generic functions
|
||||
|
||||
## Typevar must be used at least twice
|
||||
|
||||
If you're only using a typevar for a single parameter, you don't need the typevar — just use
|
||||
`object` (or the typevar's upper bound):
|
||||
|
||||
```py
|
||||
# TODO: error, should be (x: object)
|
||||
def typevar_not_needed[T](x: T) -> None:
|
||||
pass
|
||||
|
||||
# TODO: error, should be (x: int)
|
||||
def bounded_typevar_not_needed[T: int](x: T) -> None:
|
||||
pass
|
||||
```
|
||||
|
||||
Typevars are only needed if you use them more than once. For instance, to specify that two
|
||||
parameters must both have the same type:
|
||||
|
||||
```py
|
||||
def two_params[T](x: T, y: T) -> T:
|
||||
return x
|
||||
```
|
||||
|
||||
or to specify that a return value is the same as a parameter:
|
||||
|
||||
```py
|
||||
def return_value[T](x: T) -> T:
|
||||
return x
|
||||
```
|
||||
|
||||
Each typevar must also appear _somewhere_ in the parameter list:
|
||||
|
||||
```py
|
||||
def absurd[T]() -> T:
|
||||
# There's no way to construct a T!
|
||||
raise ValueError("absurd")
|
||||
```
|
||||
|
||||
## Inferring generic function parameter types
|
||||
|
||||
If the type of a generic function parameter is a typevar, then we can infer what type that typevar
|
||||
is bound to at each call site.
|
||||
|
||||
TODO: Note that some of the TODO revealed types have two options, since we haven't decided yet
|
||||
whether we want to infer a more specific `Literal` type where possible, or use heuristics to weaken
|
||||
the inferred type to e.g. `int`.
|
||||
|
||||
```py
|
||||
def f[T](x: T) -> T:
|
||||
return x
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: int or Literal[1]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f(1)) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: float
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f(1.0)) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: bool or Literal[true]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f(True)) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str or Literal["string"]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f("string")) # revealed: T
|
||||
```
|
||||
|
||||
## Inferring “deep” generic parameter types
|
||||
|
||||
The matching up of call arguments and discovery of constraints on typevars can be a recursive
|
||||
process for arbitrarily-nested generic types in parameters.
|
||||
|
||||
```py
|
||||
def f[T](x: list[T]) -> T:
|
||||
return x[0]
|
||||
|
||||
# TODO: revealed: float
|
||||
reveal_type(f([1.0, 2.0])) # revealed: T
|
||||
```
|
||||
|
||||
## Typevar constraints
|
||||
|
||||
If a type parameter has an upper bound, that upper bound constrains which types can be used for that
|
||||
typevar. This effectively adds the upper bound as an intersection to every appearance of the typevar
|
||||
in the function.
|
||||
|
||||
```py
|
||||
def good_param[T: int](x: T) -> None:
|
||||
# TODO: revealed: T & int
|
||||
reveal_type(x) # revealed: T
|
||||
```
|
||||
|
||||
If the function is annotated as returning the typevar, this means that the upper bound is _not_
|
||||
assignable to that typevar, since return types are contravariant. In `bad`, we can infer that
|
||||
`x + 1` has type `int`. But `T` might be instantiated with a narrower type than `int`, and so the
|
||||
return value is not guaranteed to be compatible for all `T: int`.
|
||||
|
||||
```py
|
||||
def good_return[T: int](x: T) -> T:
|
||||
return x
|
||||
|
||||
def bad_return[T: int](x: T) -> T:
|
||||
# TODO: error: int is not assignable to T
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `T` and `Literal[1]`"
|
||||
return x + 1
|
||||
```
|
||||
|
||||
## All occurrences of the same typevar have the same type
|
||||
|
||||
If a typevar appears multiple times in a function signature, all occurrences have the same type.
|
||||
|
||||
```py
|
||||
def different_types[T, S](cond: bool, t: T, s: S) -> T:
|
||||
if cond:
|
||||
return t
|
||||
else:
|
||||
# error: [invalid-return-type] "Object of type `S` is not assignable to return type `T`"
|
||||
return s
|
||||
|
||||
def same_types[T](cond: bool, t1: T, t2: T) -> T:
|
||||
if cond:
|
||||
return t1
|
||||
else:
|
||||
return t2
|
||||
```
|
||||
|
||||
## All occurrences of the same constrained typevar have the same type
|
||||
|
||||
The above is true even when the typevars are constrained. Here, both `int` and `str` have `__add__`
|
||||
methods that are compatible with the return type, so the `return` expression is always well-typed:
|
||||
|
||||
```py
|
||||
def same_constrained_types[T: (int, str)](t1: T, t2: T) -> T:
|
||||
# TODO: no error
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `T` and `T`"
|
||||
return t1 + t2
|
||||
```
|
||||
|
||||
This is _not_ the same as a union type, because of this additional constraint that the two
|
||||
occurrences have the same type. In `unions_are_different`, `t1` and `t2` might have different types,
|
||||
and an `int` and a `str` cannot be added together:
|
||||
|
||||
```py
|
||||
def unions_are_different(t1: int | str, t2: int | str) -> int | str:
|
||||
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `int | str` and `int | str`"
|
||||
return t1 + t2
|
||||
```
|
||||
|
||||
## Typevar inference is a unification problem
|
||||
|
||||
When inferring typevar assignments in a generic function call, we cannot simply solve constraints
|
||||
eagerly for each parameter in turn. We must solve a unification problem involving all of the
|
||||
parameters simultaneously.
|
||||
|
||||
```py
|
||||
def two_params[T](x: T, y: T) -> T:
|
||||
return x
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(two_params("a", "b")) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str | int
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(two_params("a", 1)) # revealed: T
|
||||
```
|
||||
|
||||
```py
|
||||
def param_with_union[T](x: T | int, y: T) -> T:
|
||||
return y
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(param_with_union(1, "a")) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(param_with_union("a", "a")) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: int
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(param_with_union(1, 1)) # revealed: T
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: str | int
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(param_with_union("a", 1)) # revealed: T
|
||||
```
|
||||
|
||||
```py
|
||||
def tuple_param[T, S](x: T | S, y: tuple[T, S]) -> tuple[T, S]:
|
||||
return y
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: tuple[str, int]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(tuple_param("a", ("a", 1))) # revealed: tuple[T, S]
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: tuple[str, int]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(tuple_param(1, ("a", 1))) # revealed: tuple[T, S]
|
||||
```
|
||||
|
||||
## Inferring nested generic function calls
|
||||
|
||||
We can infer type assignments in nested calls to multiple generic functions. If they use the same
|
||||
type variable, we do not confuse the two; `T@f` and `T@g` have separate types in each example below.
|
||||
|
||||
```py
|
||||
def f[T](x: T) -> tuple[T, int]:
|
||||
return (x, 1)
|
||||
|
||||
def g[T](x: T) -> T | None:
|
||||
return x
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: tuple[str | None, int]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f(g("a"))) # revealed: tuple[T, int]
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: tuple[str, int] | None
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(g(f("a"))) # revealed: T | None
|
||||
```
|
||||
@@ -1,72 +0,0 @@
|
||||
# Legacy type variables
|
||||
|
||||
The tests in this file focus on how type variables are defined using the legacy notation. Most
|
||||
_uses_ of type variables are tested in other files in this directory; we do not duplicate every test
|
||||
for both type variable syntaxes.
|
||||
|
||||
Unless otherwise specified, all quotations come from the [Generics] section of the typing spec.
|
||||
|
||||
## Type variables
|
||||
|
||||
### Defining legacy type variables
|
||||
|
||||
> Generics can be parameterized by using a factory available in `typing` called `TypeVar`.
|
||||
|
||||
This was the only way to create type variables prior to PEP 695/Python 3.12. It is still available
|
||||
in newer Python releases.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
```
|
||||
|
||||
### Directly assigned to a variable
|
||||
|
||||
> A `TypeVar()` expression must always directly be assigned to a variable (it should not be used as
|
||||
> part of a larger expression).
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
# TODO: error
|
||||
TestList = list[TypeVar("W")]
|
||||
```
|
||||
|
||||
### `TypeVar` parameter must match variable name
|
||||
|
||||
> The argument to `TypeVar()` must be a string equal to the variable name to which it is assigned.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
# TODO: error
|
||||
T = TypeVar("Q")
|
||||
```
|
||||
|
||||
### No redefinition
|
||||
|
||||
> Type variables must not be redefined.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# TODO: error
|
||||
T = TypeVar("T")
|
||||
```
|
||||
|
||||
### Cannot have only one constraint
|
||||
|
||||
> `TypeVar` supports constraining parametric types to a fixed set of possible types...There should
|
||||
> be at least two constraints, if any; specifying a single constraint is disallowed.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
# TODO: error: [invalid-type-variable-constraints]
|
||||
T = TypeVar("T", int)
|
||||
```
|
||||
|
||||
[generics]: https://typing.readthedocs.io/en/latest/spec/generics.html
|
||||
@@ -1,51 +0,0 @@
|
||||
# PEP 695 Generics
|
||||
|
||||
[PEP 695] and Python 3.12 introduced new, more ergonomic syntax for type variables.
|
||||
|
||||
## Type variables
|
||||
|
||||
### Defining PEP 695 type variables
|
||||
|
||||
PEP 695 introduces a new syntax for defining type variables. The resulting type variables are
|
||||
instances of `typing.TypeVar`, just like legacy type variables.
|
||||
|
||||
```py
|
||||
def f[T]():
|
||||
reveal_type(type(T)) # revealed: Literal[TypeVar]
|
||||
reveal_type(T) # revealed: T
|
||||
reveal_type(T.__name__) # revealed: Literal["T"]
|
||||
```
|
||||
|
||||
### Cannot have only one constraint
|
||||
|
||||
> `TypeVar` supports constraining parametric types to a fixed set of possible types...There should
|
||||
> be at least two constraints, if any; specifying a single constraint is disallowed.
|
||||
|
||||
```py
|
||||
# error: [invalid-type-variable-constraints] "TypeVar must have at least two constrained types"
|
||||
def f[T: (int,)]():
|
||||
pass
|
||||
```
|
||||
|
||||
## Invalid uses
|
||||
|
||||
Note that many of the invalid uses of legacy typevars do not apply to PEP 695 typevars, since the
|
||||
PEP 695 syntax is only allowed places where typevars are allowed.
|
||||
|
||||
## Displaying typevars
|
||||
|
||||
We use a suffix when displaying the typevars of a generic function or class. This helps distinguish
|
||||
different uses of the same typevar.
|
||||
|
||||
```py
|
||||
def f[T](x: T, y: T) -> None:
|
||||
# TODO: revealed: T@f
|
||||
reveal_type(x) # revealed: T
|
||||
|
||||
class C[T]:
|
||||
def m(self, x: T) -> None:
|
||||
# TODO: revealed: T@c
|
||||
reveal_type(x) # revealed: T
|
||||
```
|
||||
|
||||
[pep 695]: https://peps.python.org/pep-0695/
|
||||
@@ -1,270 +0,0 @@
|
||||
# Scoping rules for type variables
|
||||
|
||||
Most of these tests come from the [Scoping rules for type variables][scoping] section of the typing
|
||||
spec.
|
||||
|
||||
## Typevar used outside of generic function or class
|
||||
|
||||
Typevars may only be used in generic function or class definitions.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# TODO: error
|
||||
x: T
|
||||
|
||||
class C:
|
||||
# TODO: error
|
||||
x: T
|
||||
|
||||
def f() -> None:
|
||||
# TODO: error
|
||||
x: T
|
||||
```
|
||||
|
||||
## Legacy typevar used multiple times
|
||||
|
||||
> A type variable used in a generic function could be inferred to represent different types in the
|
||||
> same code block.
|
||||
|
||||
This only applies to typevars defined using the legacy syntax, since the PEP 695 syntax creates a
|
||||
new distinct typevar for each occurrence.
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def f1(x: T) -> T:
|
||||
return x
|
||||
|
||||
def f2(x: T) -> T:
|
||||
return x
|
||||
|
||||
f1(1)
|
||||
f2("a")
|
||||
```
|
||||
|
||||
## Typevar inferred multiple times
|
||||
|
||||
> A type variable used in a generic function could be inferred to represent different types in the
|
||||
> same code block.
|
||||
|
||||
This also applies to a single generic function being used multiple times, instantiating the typevar
|
||||
to a different type each time.
|
||||
|
||||
```py
|
||||
def f[T](x: T) -> T:
|
||||
return x
|
||||
|
||||
# TODO: no error
|
||||
# TODO: revealed: int or Literal[1]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f(1)) # revealed: T
|
||||
# TODO: no error
|
||||
# TODO: revealed: str or Literal["a"]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(f("a")) # revealed: T
|
||||
```
|
||||
|
||||
## Methods can mention class typevars
|
||||
|
||||
> A type variable used in a method of a generic class that coincides with one of the variables that
|
||||
> parameterize this class is always bound to that variable.
|
||||
|
||||
```py
|
||||
class C[T]:
|
||||
def m1(self, x: T) -> T:
|
||||
return x
|
||||
|
||||
def m2(self, x: T) -> T:
|
||||
return x
|
||||
|
||||
c: C[int] = C()
|
||||
# TODO: no error
|
||||
# error: [invalid-argument-type]
|
||||
c.m1(1)
|
||||
# TODO: no error
|
||||
# error: [invalid-argument-type]
|
||||
c.m2(1)
|
||||
# TODO: expected type `int`
|
||||
# error: [invalid-argument-type] "Object of type `Literal["string"]` cannot be assigned to parameter 2 (`x`) of bound method `m2`; expected type `T`"
|
||||
c.m2("string")
|
||||
```
|
||||
|
||||
## Methods can mention other typevars
|
||||
|
||||
> A type variable used in a method that does not match any of the variables that parameterize the
|
||||
> class makes this method a generic function in that variable.
|
||||
|
||||
```py
|
||||
from typing import TypeVar, Generic
|
||||
|
||||
T = TypeVar("T")
|
||||
S = TypeVar("S")
|
||||
|
||||
# TODO: no error
|
||||
# error: [invalid-base]
|
||||
class Legacy(Generic[T]):
|
||||
def m(self, x: T, y: S) -> S:
|
||||
return y
|
||||
|
||||
legacy: Legacy[int] = Legacy()
|
||||
# TODO: revealed: str
|
||||
reveal_type(legacy.m(1, "string")) # revealed: @Todo(Invalid or unsupported `Instance` in `Type::to_type_expression`)
|
||||
```
|
||||
|
||||
With PEP 695 syntax, it is clearer that the method uses a separate typevar:
|
||||
|
||||
```py
|
||||
class C[T]:
|
||||
def m[S](self, x: T, y: S) -> S:
|
||||
return y
|
||||
|
||||
c: C[int] = C()
|
||||
# TODO: no errors
|
||||
# TODO: revealed: str
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type]
|
||||
reveal_type(c.m(1, "string")) # revealed: S
|
||||
```
|
||||
|
||||
## Unbound typevars
|
||||
|
||||
> Unbound type variables should not appear in the bodies of generic functions, or in the class
|
||||
> bodies apart from method definitions.
|
||||
|
||||
This is true with the legacy syntax:
|
||||
|
||||
```py
|
||||
from typing import TypeVar, Generic
|
||||
|
||||
T = TypeVar("T")
|
||||
S = TypeVar("S")
|
||||
|
||||
def f(x: T) -> None:
|
||||
x: list[T] = []
|
||||
# TODO: error
|
||||
y: list[S] = []
|
||||
|
||||
# TODO: no error
|
||||
# error: [invalid-base]
|
||||
class C(Generic[T]):
|
||||
# TODO: error
|
||||
x: list[S] = []
|
||||
|
||||
# This is not an error, as shown in the previous test
|
||||
def m(self, x: S) -> S:
|
||||
return x
|
||||
```
|
||||
|
||||
This is true with PEP 695 syntax, as well, though we must use the legacy syntax to define the
|
||||
unbound typevars:
|
||||
|
||||
`pep695.py`:
|
||||
|
||||
```py
|
||||
from typing import TypeVar
|
||||
|
||||
S = TypeVar("S")
|
||||
|
||||
def f[T](x: T) -> None:
|
||||
x: list[T] = []
|
||||
# TODO: error
|
||||
y: list[S] = []
|
||||
|
||||
class C[T]:
|
||||
# TODO: error
|
||||
x: list[S] = []
|
||||
|
||||
def m1(self, x: S) -> S:
|
||||
return x
|
||||
|
||||
def m2[S](self, x: S) -> S:
|
||||
return x
|
||||
```
|
||||
|
||||
## Nested formal typevars must be distinct
|
||||
|
||||
Generic functions and classes can be nested in each other, but it is an error for the same typevar
|
||||
to be used in nested generic definitions.
|
||||
|
||||
Note that the typing spec only mentions two specific versions of this rule:
|
||||
|
||||
> A generic class definition that appears inside a generic function should not use type variables
|
||||
> that parameterize the generic function.
|
||||
|
||||
and
|
||||
|
||||
> A generic class nested in another generic class cannot use the same type variables.
|
||||
|
||||
We assume that the more general form holds.
|
||||
|
||||
### Generic function within generic function
|
||||
|
||||
```py
|
||||
def f[T](x: T, y: T) -> None:
|
||||
def ok[S](a: S, b: S) -> None: ...
|
||||
|
||||
# TODO: error
|
||||
def bad[T](a: T, b: T) -> None: ...
|
||||
```
|
||||
|
||||
### Generic method within generic class
|
||||
|
||||
```py
|
||||
class C[T]:
|
||||
def ok[S](self, a: S, b: S) -> None: ...
|
||||
|
||||
# TODO: error
|
||||
def bad[T](self, a: T, b: T) -> None: ...
|
||||
```
|
||||
|
||||
### Generic class within generic function
|
||||
|
||||
```py
|
||||
from typing import Iterable
|
||||
|
||||
def f[T](x: T, y: T) -> None:
|
||||
class Ok[S]: ...
|
||||
# TODO: error for reuse of typevar
|
||||
class Bad1[T]: ...
|
||||
# TODO: no non-subscriptable error, error for reuse of typevar
|
||||
# error: [non-subscriptable]
|
||||
class Bad2(Iterable[T]): ...
|
||||
```
|
||||
|
||||
### Generic class within generic class
|
||||
|
||||
```py
|
||||
from typing import Iterable
|
||||
|
||||
class C[T]:
|
||||
class Ok1[S]: ...
|
||||
# TODO: error for reuse of typevar
|
||||
class Bad1[T]: ...
|
||||
# TODO: no non-subscriptable error, error for reuse of typevar
|
||||
# error: [non-subscriptable]
|
||||
class Bad2(Iterable[T]): ...
|
||||
```
|
||||
|
||||
## Class scopes do not cover inner scopes
|
||||
|
||||
Just like regular symbols, the typevars of a generic class are only available in that class's scope,
|
||||
and are not available in nested scopes.
|
||||
|
||||
```py
|
||||
class C[T]:
|
||||
ok1: list[T] = []
|
||||
|
||||
class Bad:
|
||||
# TODO: error
|
||||
bad: list[T] = []
|
||||
|
||||
class Inner[S]: ...
|
||||
ok2: Inner[T]
|
||||
```
|
||||
|
||||
[scoping]: https://typing.readthedocs.io/en/latest/spec/generics.html#scoping-rules-for-type-variables
|
||||
@@ -1,131 +0,0 @@
|
||||
# Case Sensitive Imports
|
||||
|
||||
```toml
|
||||
# TODO: This test should use the real file system instead of the memory file system.
|
||||
# but we can't change the file system yet because the tests would then start failing for
|
||||
# case-insensitive file systems.
|
||||
#system = "os"
|
||||
```
|
||||
|
||||
Python's import system is case-sensitive even on case-insensitive file system. This means, importing
|
||||
a module `a` should fail if the file in the search paths is named `A.py`. See
|
||||
[PEP 235](https://peps.python.org/pep-0235/).
|
||||
|
||||
## Correct casing
|
||||
|
||||
Importing a module where the name matches the file name's casing should succeed.
|
||||
|
||||
`a.py`:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
```python
|
||||
from a import Foo
|
||||
|
||||
reveal_type(Foo().x) # revealed: int
|
||||
```
|
||||
|
||||
## Incorrect casing
|
||||
|
||||
Importing a module where the name does not match the file name's casing should fail.
|
||||
|
||||
`A.py`:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
```python
|
||||
# error: [unresolved-import]
|
||||
from a import Foo
|
||||
```
|
||||
|
||||
## Multiple search paths with different cased modules
|
||||
|
||||
The resolved module is the first matching the file name's casing but Python falls back to later
|
||||
search paths if the file name's casing does not match.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
extra-paths = ["/search-1", "/search-2"]
|
||||
```
|
||||
|
||||
`/search-1/A.py`:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
`/search-2/a.py`:
|
||||
|
||||
```py
|
||||
class Bar:
|
||||
x: str = "test"
|
||||
```
|
||||
|
||||
```python
|
||||
from A import Foo
|
||||
from a import Bar
|
||||
|
||||
reveal_type(Foo().x) # revealed: int
|
||||
reveal_type(Bar().x) # revealed: str
|
||||
```
|
||||
|
||||
## Intermediate segments
|
||||
|
||||
`db/__init__.py`:
|
||||
|
||||
```py
|
||||
```
|
||||
|
||||
`db/a.py`:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
`correctly_cased.py`:
|
||||
|
||||
```python
|
||||
from db.a import Foo
|
||||
|
||||
reveal_type(Foo().x) # revealed: int
|
||||
```
|
||||
|
||||
Imports where some segments are incorrectly cased should fail.
|
||||
|
||||
`incorrectly_cased.py`:
|
||||
|
||||
```python
|
||||
# error: [unresolved-import]
|
||||
from DB.a import Foo
|
||||
|
||||
# error: [unresolved-import]
|
||||
from DB.A import Foo
|
||||
|
||||
# error: [unresolved-import]
|
||||
from db.A import Foo
|
||||
```
|
||||
|
||||
## Incorrect extension casing
|
||||
|
||||
The extension of imported python modules must be `.py` or `.pyi` but not `.PY` or `Py` or any
|
||||
variant where some characters are uppercase.
|
||||
|
||||
`a.PY`:
|
||||
|
||||
```py
|
||||
class Foo:
|
||||
x: int = 1
|
||||
```
|
||||
|
||||
```python
|
||||
# error: [unresolved-import]
|
||||
from a import Foo
|
||||
```
|
||||
@@ -91,16 +91,3 @@ match while:
|
||||
for x in foo.pass:
|
||||
pass
|
||||
```
|
||||
|
||||
## Invalid annotation
|
||||
|
||||
### `typing.Callable`
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
|
||||
# error: [invalid-syntax] "Expected index or slice expression"
|
||||
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
|
||||
def _(c: Callable[]):
|
||||
reveal_type(c) # revealed: (...) -> Unknown
|
||||
```
|
||||
|
||||
@@ -26,6 +26,23 @@ from typing import TYPE_CHECKING as TC
|
||||
reveal_type(TC) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
### Must originate from `typing`
|
||||
|
||||
Make sure we only use our special handling for `typing.TYPE_CHECKING` and not for other constants
|
||||
with the same name:
|
||||
|
||||
`constants.py`:
|
||||
|
||||
```py
|
||||
TYPE_CHECKING: bool = False
|
||||
```
|
||||
|
||||
```py
|
||||
from constants import TYPE_CHECKING
|
||||
|
||||
reveal_type(TYPE_CHECKING) # revealed: bool
|
||||
```
|
||||
|
||||
### `typing_extensions` re-export
|
||||
|
||||
This should behave in the same way as `typing.TYPE_CHECKING`:
|
||||
@@ -35,117 +52,3 @@ from typing_extensions import TYPE_CHECKING
|
||||
|
||||
reveal_type(TYPE_CHECKING) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
## User-defined `TYPE_CHECKING`
|
||||
|
||||
If we set `TYPE_CHECKING = False` directly instead of importing it from the `typing` module, it will
|
||||
still be treated as `True` during type checking. This behavior is for compatibility with other major
|
||||
type checkers, e.g. mypy and pyright.
|
||||
|
||||
### With no type annotation
|
||||
|
||||
```py
|
||||
TYPE_CHECKING = False
|
||||
reveal_type(TYPE_CHECKING) # revealed: Literal[True]
|
||||
if TYPE_CHECKING:
|
||||
type_checking = True
|
||||
if not TYPE_CHECKING:
|
||||
runtime = True
|
||||
|
||||
# type_checking is treated as unconditionally assigned.
|
||||
reveal_type(type_checking) # revealed: Literal[True]
|
||||
# error: [unresolved-reference]
|
||||
reveal_type(runtime) # revealed: Unknown
|
||||
```
|
||||
|
||||
### With a type annotation
|
||||
|
||||
We can also define `TYPE_CHECKING` with a type annotation. The type must be one to which `bool` can
|
||||
be assigned. Even in this case, the type of `TYPE_CHECKING` is still inferred to be `Literal[True]`.
|
||||
|
||||
```py
|
||||
TYPE_CHECKING: bool = False
|
||||
reveal_type(TYPE_CHECKING) # revealed: Literal[True]
|
||||
if TYPE_CHECKING:
|
||||
type_checking = True
|
||||
if not TYPE_CHECKING:
|
||||
runtime = True
|
||||
|
||||
reveal_type(type_checking) # revealed: Literal[True]
|
||||
# error: [unresolved-reference]
|
||||
reveal_type(runtime) # revealed: Unknown
|
||||
```
|
||||
|
||||
### Importing user-defined `TYPE_CHECKING`
|
||||
|
||||
`constants.py`:
|
||||
|
||||
```py
|
||||
TYPE_CHECKING = False
|
||||
```
|
||||
|
||||
`stub.pyi`:
|
||||
|
||||
```pyi
|
||||
TYPE_CHECKING: bool
|
||||
# or
|
||||
TYPE_CHECKING: bool = ...
|
||||
```
|
||||
|
||||
```py
|
||||
from constants import TYPE_CHECKING
|
||||
|
||||
reveal_type(TYPE_CHECKING) # revealed: Literal[True]
|
||||
|
||||
from stub import TYPE_CHECKING
|
||||
|
||||
reveal_type(TYPE_CHECKING) # revealed: Literal[True]
|
||||
```
|
||||
|
||||
### Invalid assignment to `TYPE_CHECKING`
|
||||
|
||||
Only `False` can be assigned to `TYPE_CHECKING`; any assignment other than `False` will result in an
|
||||
error. A type annotation to which `bool` is not assignable is also an error.
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING = True
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: bool = True
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: int = 1
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: str = "str"
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: str = False
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: Literal[False] = False
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: Literal[True] = False
|
||||
```
|
||||
|
||||
The same rules apply in a stub file:
|
||||
|
||||
```pyi
|
||||
from typing import Literal
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: str
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: str = False
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: Literal[False] = ...
|
||||
|
||||
# error: [invalid-type-checking-constant]
|
||||
TYPE_CHECKING: object = "str"
|
||||
```
|
||||
|
||||
@@ -105,11 +105,7 @@ reveal_type(x)
|
||||
|
||||
## With non-callable iterator
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
def _(flag: bool):
|
||||
class NotIterable:
|
||||
if flag:
|
||||
@@ -117,8 +113,7 @@ def _(flag: bool):
|
||||
else:
|
||||
__iter__: None = None
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in NotIterable():
|
||||
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
|
||||
pass
|
||||
|
||||
# revealed: Unknown
|
||||
@@ -128,25 +123,21 @@ def _(flag: bool):
|
||||
|
||||
## Invalid iterable
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
nonsense = 123
|
||||
for x in nonsense: # error: [not-iterable]
|
||||
for x in nonsense: # error: "Object of type `Literal[123]` is not iterable"
|
||||
pass
|
||||
```
|
||||
|
||||
## New over old style iteration protocol
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
class NotIterable:
|
||||
def __getitem__(self, key: int) -> int:
|
||||
return 42
|
||||
__iter__: None = None
|
||||
|
||||
for x in NotIterable(): # error: [not-iterable]
|
||||
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
|
||||
pass
|
||||
```
|
||||
|
||||
@@ -230,11 +221,7 @@ def _(flag: bool):
|
||||
|
||||
## Union type as iterable where one union element has no `__iter__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class TestIter:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
@@ -244,18 +231,14 @@ class Test:
|
||||
return TestIter()
|
||||
|
||||
def _(flag: bool):
|
||||
# error: [not-iterable]
|
||||
# error: [not-iterable] "Object of type `Test | Literal[42]` is not iterable because its `__iter__` method is possibly unbound"
|
||||
for x in Test() if flag else 42:
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## Union type as iterable where one union element has invalid `__iter__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class TestIter:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
@@ -270,7 +253,7 @@ class Test2:
|
||||
|
||||
def _(flag: bool):
|
||||
# TODO: Improve error message to state which union variant isn't iterable (https://github.com/astral-sh/ruff/issues/13989)
|
||||
# error: [not-iterable]
|
||||
# error: "Object of type `Test | Test2` is not iterable"
|
||||
for x in Test() if flag else Test2():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
@@ -286,464 +269,7 @@ class Test:
|
||||
def __iter__(self) -> TestIter | int:
|
||||
return TestIter()
|
||||
|
||||
# error: [not-iterable] "Object of type `Test` may not be iterable because its `__iter__` method returns an object of type `TestIter | int`, which may not have a `__next__` method"
|
||||
# error: [not-iterable] "Object of type `Test` is not iterable"
|
||||
for x in Test():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## Possibly-not-callable `__iter__` method
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class CustomCallable:
|
||||
if flag:
|
||||
def __call__(self, *args, **kwargs) -> Iterator:
|
||||
return Iterator()
|
||||
else:
|
||||
__call__: None = None
|
||||
|
||||
class Iterable1:
|
||||
__iter__: CustomCallable = CustomCallable()
|
||||
|
||||
class Iterable2:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
else:
|
||||
__iter__: None = None
|
||||
|
||||
# error: [not-iterable] "Object of type `Iterable1` may not be iterable because its `__iter__` attribute (with type `CustomCallable`) may not be callable"
|
||||
for x in Iterable1():
|
||||
# TODO... `int` might be ideal here?
|
||||
reveal_type(x) # revealed: int | Unknown
|
||||
|
||||
# error: [not-iterable] "Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `<bound method `__iter__` of `Iterable2`> | None`) may not be callable"
|
||||
for y in Iterable2():
|
||||
# TODO... `int` might be ideal here?
|
||||
reveal_type(y) # revealed: int | Unknown
|
||||
```
|
||||
|
||||
## `__iter__` method with a bad signature
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self, extra_arg) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## `__iter__` does not return an iterator
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Bad:
|
||||
def __iter__(self) -> int:
|
||||
return 42
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Bad():
|
||||
reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## `__iter__` returns an object with a possibly unbound `__next__` method
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Iterator:
|
||||
if flag:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
# error: [not-iterable] "Object of type `Iterable` may not be iterable because its `__iter__` method returns an object of type `Iterator`, which may not have a `__next__` method"
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## `__iter__` returns an iterator with an invalid `__next__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterator1:
|
||||
def __next__(self, extra_arg) -> int:
|
||||
return 42
|
||||
|
||||
class Iterator2:
|
||||
__next__: None = None
|
||||
|
||||
class Iterable1:
|
||||
def __iter__(self) -> Iterator1:
|
||||
return Iterator1()
|
||||
|
||||
class Iterable2:
|
||||
def __iter__(self) -> Iterator2:
|
||||
return Iterator2()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
reveal_type(x) # revealed: int
|
||||
|
||||
# error: [not-iterable]
|
||||
for y in Iterable2():
|
||||
reveal_type(y) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Possibly unbound `__iter__` and bad `__getitem__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
def _(flag: bool):
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
# invalid signature because it only accepts a `str`,
|
||||
# but the old-style iteration protocol will pass it an `int`
|
||||
def __getitem__(self, key: str) -> bytes:
|
||||
return bytes()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int | bytes
|
||||
```
|
||||
|
||||
## Possibly unbound `__iter__` and not-callable `__getitem__`
|
||||
|
||||
This snippet tests that we infer the element type correctly in the following edge case:
|
||||
|
||||
- `__iter__` is a method with the correct parameter spec that returns a valid iterator; BUT
|
||||
- `__iter__` is possibly unbound; AND
|
||||
- `__getitem__` is set to a non-callable type
|
||||
|
||||
It's important that we emit a diagnostic here, but it's also important that we still use the return
|
||||
type of the iterator's `__next__` method as the inferred type of `x` in the `for` loop:
|
||||
|
||||
```py
|
||||
def _(flag: bool):
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
__getitem__: None = None
|
||||
|
||||
# error: [not-iterable] "Object of type `Iterable` may not be iterable because it may not have an `__iter__` method and its `__getitem__` attribute has type `None`, which is not callable"
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## Possibly unbound `__iter__` and possibly unbound `__getitem__`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
def _(flag1: bool, flag2: bool):
|
||||
class Iterable:
|
||||
if flag1:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
if flag2:
|
||||
def __getitem__(self, key: int) -> bytes:
|
||||
return bytes()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int | bytes
|
||||
```
|
||||
|
||||
## No `__iter__` method and `__getitem__` is not callable
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Bad:
|
||||
__getitem__: None = None
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Bad():
|
||||
reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
## Possibly-not-callable `__getitem__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
def _(flag: bool):
|
||||
class CustomCallable:
|
||||
if flag:
|
||||
def __call__(self, *args, **kwargs) -> int:
|
||||
return 42
|
||||
else:
|
||||
__call__: None = None
|
||||
|
||||
class Iterable1:
|
||||
__getitem__: CustomCallable = CustomCallable()
|
||||
|
||||
class Iterable2:
|
||||
if flag:
|
||||
def __getitem__(self, key: int) -> int:
|
||||
return 42
|
||||
else:
|
||||
__getitem__: None = None
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
# TODO... `int` might be ideal here?
|
||||
reveal_type(x) # revealed: int | Unknown
|
||||
|
||||
# error: [not-iterable]
|
||||
for y in Iterable2():
|
||||
# TODO... `int` might be ideal here?
|
||||
reveal_type(y) # revealed: int | Unknown
|
||||
```
|
||||
|
||||
## Bad `__getitem__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterable:
|
||||
# invalid because it will implicitly be passed an `int`
|
||||
# by the interpreter
|
||||
def __getitem__(self, key: str) -> int:
|
||||
return 42
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
## Possibly unbound `__iter__` but definitely bound `__getitem__`
|
||||
|
||||
Here, we should not emit a diagnostic: if `__iter__` is unbound, we should fallback to
|
||||
`__getitem__`:
|
||||
|
||||
```py
|
||||
class Iterator:
|
||||
def __next__(self) -> str:
|
||||
return "foo"
|
||||
|
||||
def _(flag: bool):
|
||||
class Iterable:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
def __getitem__(self, key: int) -> bytes:
|
||||
return b"foo"
|
||||
|
||||
for x in Iterable():
|
||||
reveal_type(x) # revealed: str | bytes
|
||||
```
|
||||
|
||||
## Possibly invalid `__iter__` methods
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterator:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
|
||||
def _(flag: bool):
|
||||
class Iterable1:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
else:
|
||||
def __iter__(self, invalid_extra_arg) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
reveal_type(x) # revealed: int
|
||||
|
||||
class Iterable2:
|
||||
if flag:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
else:
|
||||
__iter__: None = None
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable2():
|
||||
# TODO: `int` would probably be better here:
|
||||
reveal_type(x) # revealed: int | Unknown
|
||||
```
|
||||
|
||||
## Possibly invalid `__next__` method
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
def _(flag: bool):
|
||||
class Iterator1:
|
||||
if flag:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
else:
|
||||
def __next__(self, invalid_extra_arg) -> str:
|
||||
return "foo"
|
||||
|
||||
class Iterator2:
|
||||
if flag:
|
||||
def __next__(self) -> int:
|
||||
return 42
|
||||
else:
|
||||
__next__: None = None
|
||||
|
||||
class Iterable1:
|
||||
def __iter__(self) -> Iterator1:
|
||||
return Iterator1()
|
||||
|
||||
class Iterable2:
|
||||
def __iter__(self) -> Iterator2:
|
||||
return Iterator2()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
reveal_type(x) # revealed: int | str
|
||||
|
||||
# error: [not-iterable]
|
||||
for y in Iterable2():
|
||||
# TODO: `int` would probably be better here:
|
||||
reveal_type(y) # revealed: int | Unknown
|
||||
```
|
||||
|
||||
## Possibly invalid `__getitem__` methods
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
def _(flag: bool):
|
||||
class Iterable1:
|
||||
if flag:
|
||||
def __getitem__(self, item: int) -> str:
|
||||
return "foo"
|
||||
else:
|
||||
__getitem__: None = None
|
||||
|
||||
class Iterable2:
|
||||
if flag:
|
||||
def __getitem__(self, item: int) -> str:
|
||||
return "foo"
|
||||
else:
|
||||
def __getitem__(self, item: str) -> int:
|
||||
return 42
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
# TODO: `str` might be better
|
||||
reveal_type(x) # revealed: str | Unknown
|
||||
|
||||
# error: [not-iterable]
|
||||
for y in Iterable2():
|
||||
reveal_type(y) # revealed: str | int
|
||||
```
|
||||
|
||||
## Possibly unbound `__iter__` and possibly invalid `__getitem__`
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
from typing_extensions import reveal_type
|
||||
|
||||
class Iterator:
|
||||
def __next__(self) -> bytes:
|
||||
return b"foo"
|
||||
|
||||
def _(flag: bool, flag2: bool):
|
||||
class Iterable1:
|
||||
if flag:
|
||||
def __getitem__(self, item: int) -> str:
|
||||
return "foo"
|
||||
else:
|
||||
__getitem__: None = None
|
||||
|
||||
if flag2:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
class Iterable2:
|
||||
if flag:
|
||||
def __getitem__(self, item: int) -> str:
|
||||
return "foo"
|
||||
else:
|
||||
def __getitem__(self, item: str) -> int:
|
||||
return 42
|
||||
if flag2:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
|
||||
# error: [not-iterable]
|
||||
for x in Iterable1():
|
||||
# TODO: `bytes | str` might be better
|
||||
reveal_type(x) # revealed: bytes | str | Unknown
|
||||
|
||||
# error: [not-iterable]
|
||||
for y in Iterable2():
|
||||
reveal_type(y) # revealed: bytes | str | int
|
||||
```
|
||||
|
||||
## Never is iterable
|
||||
|
||||
```py
|
||||
from typing_extensions import Never
|
||||
|
||||
def f(never: Never):
|
||||
for x in never:
|
||||
reveal_type(x) # revealed: Never
|
||||
```
|
||||
|
||||
@@ -10,8 +10,7 @@ class Iterator:
|
||||
return 42
|
||||
|
||||
class Iterable:
|
||||
def __iter__(self) -> Iterator:
|
||||
return Iterator()
|
||||
def __iter__(self) -> Iterator: ...
|
||||
|
||||
def generator_function():
|
||||
yield from Iterable()
|
||||
|
||||
@@ -116,14 +116,3 @@ def _(flag: bool, flag2: bool):
|
||||
# error: [possibly-unresolved-reference]
|
||||
y
|
||||
```
|
||||
|
||||
## Condition with object that implements `__bool__` incorrectly
|
||||
|
||||
```py
|
||||
class NotBoolable:
|
||||
__bool__ = 3
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `NotBoolable`; its `__bool__` method isn't callable"
|
||||
while NotBoolable():
|
||||
...
|
||||
```
|
||||
|
||||
@@ -163,11 +163,10 @@ reveal_type(B.__class__) # revealed: Literal[M]
|
||||
## Non-class
|
||||
|
||||
When a class has an explicit `metaclass` that is not a class, but is a callable that accepts
|
||||
`type.__new__` arguments, we should return the meta-type of its return type.
|
||||
`type.__new__` arguments, we should return the meta type of its return type.
|
||||
|
||||
```py
|
||||
def f(*args, **kwargs) -> int:
|
||||
return 1
|
||||
def f(*args, **kwargs) -> int: ...
|
||||
|
||||
class A(metaclass=f): ...
|
||||
|
||||
|
||||
@@ -22,13 +22,11 @@ def _(flag: bool):
|
||||
|
||||
# invalid invocation, too many positional args
|
||||
reveal_type(x) # revealed: Literal[1] | None
|
||||
# error: [too-many-positional-arguments] "Too many positional arguments to class `bool`: expected 1, got 2"
|
||||
if bool(x is not None, 5):
|
||||
if bool(x is not None, 5): # TODO diagnostic
|
||||
reveal_type(x) # revealed: Literal[1] | None
|
||||
|
||||
# invalid invocation, too many kwargs
|
||||
reveal_type(x) # revealed: Literal[1] | None
|
||||
# error: [unknown-argument] "Argument `y` does not match any known parameter of class `bool`"
|
||||
if bool(x is not None, y=5):
|
||||
if bool(x is not None, y=5): # TODO diagnostic
|
||||
reveal_type(x) # revealed: Literal[1] | None
|
||||
```
|
||||
|
||||
@@ -97,7 +97,12 @@ else:
|
||||
## No narrowing for instances of `builtins.type`
|
||||
|
||||
```py
|
||||
def _(flag: bool, t: type):
|
||||
def _(flag: bool):
|
||||
t = type("t", (), {})
|
||||
|
||||
# This isn't testing what we want it to test if we infer anything more precise here:
|
||||
reveal_type(t) # revealed: type
|
||||
|
||||
x = 1 if flag else "foo"
|
||||
|
||||
if isinstance(x, t):
|
||||
|
||||
@@ -112,7 +112,8 @@ def _(flag: bool):
|
||||
reveal_type(t) # revealed: Literal[NoneType]
|
||||
|
||||
if issubclass(t, type(None)):
|
||||
reveal_type(t) # revealed: Literal[NoneType]
|
||||
# TODO: this should be just `Literal[NoneType]`
|
||||
reveal_type(t) # revealed: Literal[int, NoneType]
|
||||
```
|
||||
|
||||
## `classinfo` contains multiple types
|
||||
@@ -170,8 +171,7 @@ if issubclass(t, int):
|
||||
def issubclass(c, ci):
|
||||
return True
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
t = int if flag() else str
|
||||
if issubclass(t, int):
|
||||
@@ -183,8 +183,7 @@ if issubclass(t, int):
|
||||
```py
|
||||
issubclass_alias = issubclass
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
t = int if flag() else str
|
||||
if issubclass_alias(t, int):
|
||||
@@ -196,8 +195,7 @@ if issubclass_alias(t, int):
|
||||
```py
|
||||
from builtins import issubclass as imported_issubclass
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
t = int if flag() else str
|
||||
if imported_issubclass(t, int):
|
||||
@@ -209,8 +207,7 @@ if imported_issubclass(t, int):
|
||||
```py
|
||||
from typing import Any
|
||||
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
t = int if flag() else str
|
||||
|
||||
@@ -233,8 +230,7 @@ if issubclass(t, Any):
|
||||
### Do not narrow if there are keyword arguments
|
||||
|
||||
```py
|
||||
def flag() -> bool:
|
||||
return True
|
||||
def flag() -> bool: ...
|
||||
|
||||
t = int if flag() else str
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ def _(flag: bool):
|
||||
## Class patterns
|
||||
|
||||
```py
|
||||
def get_object() -> object:
|
||||
return object()
|
||||
def get_object() -> object: ...
|
||||
|
||||
class A: ...
|
||||
class B: ...
|
||||
@@ -43,12 +42,10 @@ reveal_type(x) # revealed: object
|
||||
## Class pattern with guard
|
||||
|
||||
```py
|
||||
def get_object() -> object:
|
||||
return object()
|
||||
def get_object() -> object: ...
|
||||
|
||||
class A:
|
||||
def y() -> int:
|
||||
return 1
|
||||
def y() -> int: ...
|
||||
|
||||
class B: ...
|
||||
|
||||
|
||||
@@ -233,20 +233,16 @@ reveal_type(y) # revealed: A
|
||||
from typing import Literal
|
||||
|
||||
class MetaAmbiguous(type):
|
||||
def __bool__(self) -> bool:
|
||||
return True
|
||||
def __bool__(self) -> bool: ...
|
||||
|
||||
class MetaFalsy(type):
|
||||
def __bool__(self) -> Literal[False]:
|
||||
return False
|
||||
def __bool__(self) -> Literal[False]: ...
|
||||
|
||||
class MetaTruthy(type):
|
||||
def __bool__(self) -> Literal[True]:
|
||||
return True
|
||||
def __bool__(self) -> Literal[True]: ...
|
||||
|
||||
class MetaDeferred(type):
|
||||
def __bool__(self) -> MetaAmbiguous:
|
||||
return MetaAmbiguous()
|
||||
def __bool__(self) -> MetaAmbiguous: ...
|
||||
|
||||
class AmbiguousClass(metaclass=MetaAmbiguous): ...
|
||||
class FalsyClass(metaclass=MetaFalsy): ...
|
||||
@@ -270,7 +266,7 @@ def _(
|
||||
if af:
|
||||
reveal_type(af) # revealed: type[AmbiguousClass] & ~AlwaysFalsy
|
||||
|
||||
# error: [unsupported-bool-conversion] "Boolean conversion is unsupported for type `MetaDeferred`; the return type of its bool method (`MetaAmbiguous`) isn't assignable to `bool"
|
||||
# TODO: Emit a diagnostic (`d` is not valid in boolean context)
|
||||
if d:
|
||||
# TODO: Should be `Unknown`
|
||||
reveal_type(d) # revealed: type[DeferredClass] & ~AlwaysFalsy
|
||||
@@ -304,24 +300,20 @@ def _(x: bool | str):
|
||||
reveal_type(x and A()) # revealed: Literal[False] | str & ~AlwaysTruthy | A
|
||||
|
||||
class Falsy:
|
||||
def __bool__(self) -> Literal[False]:
|
||||
return False
|
||||
def __bool__(self) -> Literal[False]: ...
|
||||
|
||||
class Truthy:
|
||||
def __bool__(self) -> Literal[True]:
|
||||
return True
|
||||
def __bool__(self) -> Literal[True]: ...
|
||||
|
||||
def _(x: Falsy | Truthy):
|
||||
reveal_type(x or A()) # revealed: Truthy | A
|
||||
reveal_type(x and A()) # revealed: Falsy | A
|
||||
|
||||
class MetaFalsy(type):
|
||||
def __bool__(self) -> Literal[False]:
|
||||
return False
|
||||
def __bool__(self) -> Literal[False]: ...
|
||||
|
||||
class MetaTruthy(type):
|
||||
def __bool__(self) -> Literal[True]:
|
||||
return True
|
||||
def __bool__(self) -> Literal[True]: ...
|
||||
|
||||
class FalsyClass(metaclass=MetaFalsy): ...
|
||||
class TruthyClass(metaclass=MetaTruthy): ...
|
||||
|
||||
@@ -88,7 +88,7 @@ def _(x: str | int):
|
||||
|
||||
```py
|
||||
def _(x: str | int):
|
||||
# error: [no-matching-overload] "No overload of class `type` matches arguments"
|
||||
# TODO: we could issue a diagnostic here
|
||||
if type(object=x) is str:
|
||||
reveal_type(x) # revealed: str | int
|
||||
```
|
||||
|
||||
@@ -9,8 +9,7 @@ is retained after the loop.
|
||||
## Basic `while` loop
|
||||
|
||||
```py
|
||||
def next_item() -> int | None:
|
||||
return 1
|
||||
def next_item() -> int | None: ...
|
||||
|
||||
x = next_item()
|
||||
|
||||
@@ -24,8 +23,7 @@ reveal_type(x) # revealed: None
|
||||
## `while` loop with `else`
|
||||
|
||||
```py
|
||||
def next_item() -> int | None:
|
||||
return 1
|
||||
def next_item() -> int | None: ...
|
||||
|
||||
x = next_item()
|
||||
|
||||
@@ -43,8 +41,7 @@ reveal_type(x) # revealed: None
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def next_item() -> Literal[1, 2, 3]:
|
||||
raise NotImplementedError
|
||||
def next_item() -> Literal[1, 2, 3]: ...
|
||||
|
||||
x = next_item()
|
||||
|
||||
|
||||
@@ -341,12 +341,10 @@ annotation are looked up lazily, even if they occur in an eager scope.
|
||||
### Eager annotations in a Python file
|
||||
|
||||
```py
|
||||
from typing import ClassVar
|
||||
|
||||
x = int
|
||||
|
||||
class C:
|
||||
var: ClassVar[x]
|
||||
var: x
|
||||
|
||||
reveal_type(C.var) # revealed: int
|
||||
|
||||
@@ -358,12 +356,10 @@ x = str
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
x = int
|
||||
|
||||
class C:
|
||||
var: ClassVar[x]
|
||||
var: x
|
||||
|
||||
reveal_type(C.var) # revealed: Unknown | str
|
||||
|
||||
@@ -373,12 +369,10 @@ x = str
|
||||
### Deferred annotations in a stub file
|
||||
|
||||
```pyi
|
||||
from typing import ClassVar
|
||||
|
||||
x = int
|
||||
|
||||
class C:
|
||||
var: ClassVar[x]
|
||||
var: x
|
||||
|
||||
reveal_type(C.var) # revealed: Unknown | str
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ is unbound.
|
||||
```py
|
||||
reveal_type(__name__) # revealed: str
|
||||
reveal_type(__file__) # revealed: str | None
|
||||
reveal_type(__loader__) # revealed: LoaderProtocol | None
|
||||
reveal_type(__loader__) # revealed: @Todo(instance attribute on class with dynamic base) | None
|
||||
reveal_type(__package__) # revealed: str | None
|
||||
reveal_type(__doc__) # revealed: str | None
|
||||
|
||||
@@ -136,43 +136,3 @@ if returns_bool():
|
||||
reveal_type(__file__) # revealed: Literal[42]
|
||||
reveal_type(__name__) # revealed: Literal[1] | str
|
||||
```
|
||||
|
||||
## Implicit global attributes in the current module override implicit globals from builtins
|
||||
|
||||
Here, we take the type of the implicit global symbol `__name__` from the `types.ModuleType` stub
|
||||
(which in this custom typeshed specifies the type as `bytes`). This is because the `main` module has
|
||||
an implicit `__name__` global that shadows the builtin `__name__` symbol.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/builtins.pyi`:
|
||||
|
||||
```pyi
|
||||
class object: ...
|
||||
class int: ...
|
||||
class bytes: ...
|
||||
|
||||
__name__: int = 42
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/types.pyi`:
|
||||
|
||||
```pyi
|
||||
class ModuleType:
|
||||
__name__: bytes
|
||||
```
|
||||
|
||||
`/typeshed/stdlib/typing_extensions.pyi`:
|
||||
|
||||
```pyi
|
||||
def reveal_type(obj, /): ...
|
||||
```
|
||||
|
||||
`main.py`:
|
||||
|
||||
```py
|
||||
reveal_type(__name__) # revealed: bytes
|
||||
```
|
||||
|
||||
@@ -167,7 +167,7 @@ class A:
|
||||
__slots__ = ()
|
||||
__slots__ += ("a", "b")
|
||||
|
||||
reveal_type(A.__slots__) # revealed: @Todo(return type of decorated function)
|
||||
reveal_type(A.__slots__) # revealed: @Todo(return type)
|
||||
|
||||
class B:
|
||||
__slots__ = ("c", "d")
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: for.md - For loops - Bad `__getitem__` method
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/loops/for.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from typing_extensions import reveal_type
|
||||
2 |
|
||||
3 | class Iterable:
|
||||
4 | # invalid because it will implicitly be passed an `int`
|
||||
5 | # by the interpreter
|
||||
6 | def __getitem__(self, key: str) -> int:
|
||||
7 | return 42
|
||||
8 |
|
||||
9 | # error: [not-iterable]
|
||||
10 | for x in Iterable():
|
||||
11 | reveal_type(x) # revealed: int
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:not-iterable
|
||||
--> /src/mdtest_snippet.py:10:10
|
||||
|
|
||||
9 | # error: [not-iterable]
|
||||
10 | for x in Iterable():
|
||||
| ^^^^^^^^^^ Object of type `Iterable` is not iterable because it has no `__iter__` method and its `__getitem__` method has an incorrect signature for the old-style iteration protocol (expected a signature at least as permissive as `def __getitem__(self, key: int): ...`)
|
||||
11 | reveal_type(x) # revealed: int
|
||||
|
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
info: revealed-type
|
||||
--> /src/mdtest_snippet.py:11:5
|
||||
|
|
||||
9 | # error: [not-iterable]
|
||||
10 | for x in Iterable():
|
||||
11 | reveal_type(x) # revealed: int
|
||||
| -------------- info: Revealed type is `int`
|
||||
|
|
||||
|
||||
```
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: for.md - For loops - Invalid iterable
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/loops/for.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | nonsense = 123
|
||||
2 | for x in nonsense: # error: [not-iterable]
|
||||
3 | pass
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:not-iterable
|
||||
--> /src/mdtest_snippet.py:2:10
|
||||
|
|
||||
1 | nonsense = 123
|
||||
2 | for x in nonsense: # error: [not-iterable]
|
||||
| ^^^^^^^^ Object of type `Literal[123]` is not iterable because it doesn't have an `__iter__` method or a `__getitem__` method
|
||||
3 | pass
|
||||
|
|
||||
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: for.md - For loops - New over old style iteration protocol
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/loops/for.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | class NotIterable:
|
||||
2 | def __getitem__(self, key: int) -> int:
|
||||
3 | return 42
|
||||
4 | __iter__: None = None
|
||||
5 |
|
||||
6 | for x in NotIterable(): # error: [not-iterable]
|
||||
7 | pass
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:not-iterable
|
||||
--> /src/mdtest_snippet.py:6:10
|
||||
|
|
||||
4 | __iter__: None = None
|
||||
5 |
|
||||
6 | for x in NotIterable(): # error: [not-iterable]
|
||||
| ^^^^^^^^^^^^^ Object of type `NotIterable` is not iterable because its `__iter__` attribute has type `None`, which is not callable
|
||||
7 | pass
|
||||
|
|
||||
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
source: crates/red_knot_test/src/lib.rs
|
||||
expression: snapshot
|
||||
---
|
||||
---
|
||||
mdtest name: for.md - For loops - No `__iter__` method and `__getitem__` is not callable
|
||||
mdtest path: crates/red_knot_python_semantic/resources/mdtest/loops/for.md
|
||||
---
|
||||
|
||||
# Python source files
|
||||
|
||||
## mdtest_snippet.py
|
||||
|
||||
```
|
||||
1 | from typing_extensions import reveal_type
|
||||
2 |
|
||||
3 | class Bad:
|
||||
4 | __getitem__: None = None
|
||||
5 |
|
||||
6 | # error: [not-iterable]
|
||||
7 | for x in Bad():
|
||||
8 | reveal_type(x) # revealed: Unknown
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
|
||||
```
|
||||
error: lint:not-iterable
|
||||
--> /src/mdtest_snippet.py:7:10
|
||||
|
|
||||
6 | # error: [not-iterable]
|
||||
7 | for x in Bad():
|
||||
| ^^^^^ Object of type `Bad` is not iterable because it has no `__iter__` method and its `__getitem__` attribute has type `None`, which is not callable
|
||||
8 | reveal_type(x) # revealed: Unknown
|
||||
|
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
info: revealed-type
|
||||
--> /src/mdtest_snippet.py:8:5
|
||||
|
|
||||
6 | # error: [not-iterable]
|
||||
7 | for x in Bad():
|
||||
8 | reveal_type(x) # revealed: Unknown
|
||||
| -------------- info: Revealed type is `Unknown`
|
||||
|
|
||||
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user